chore: update merge branch with latest v2
This commit is contained in:
commit
bbac57819b
396 changed files with 7730 additions and 8310 deletions
5
bun.lock
5
bun.lock
|
|
@ -114,10 +114,12 @@
|
|||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
|
@ -1015,6 +1017,7 @@
|
|||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
|
@ -5949,6 +5952,8 @@
|
|||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="],
|
||||
|
|
|
|||
|
|
@ -495,7 +495,6 @@ async function subscribeSessionEvents() {
|
|||
console.log("Subscribing to session events...")
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||
|
|
|
|||
|
|
@ -33,11 +33,9 @@ test.describe("timeline tool state stability", () => {
|
|||
}
|
||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||
const questionID = "prt_state_question"
|
||||
const todoID = "prt_state_todo"
|
||||
const initial = [
|
||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
textPart("prt_state_following", "Following lightweight tools"),
|
||||
]
|
||||
const childID = "ses_timeline_child"
|
||||
|
|
@ -49,7 +47,6 @@ test.describe("timeline tool state stability", () => {
|
|||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
|
|
@ -105,7 +102,6 @@ test.describe("timeline tool state stability", () => {
|
|||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -269,7 +269,6 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
|||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ async function mockServers(page: Page, requests: string[]) {
|
|||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ async function mockServers(page: Page) {
|
|||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ test.describe("session timeline projection", () => {
|
|||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
"prt_question",
|
||||
"question",
|
||||
|
|
@ -65,7 +64,6 @@ test.describe("session timeline projection", () => {
|
|||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -17,13 +17,11 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
|
|||
error: "The user dismissed this question",
|
||||
}),
|
||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
for (let index = 0; index < ordinary.length; index++) {
|
||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||
const projectID = "proj_todo_dock_navigation"
|
||||
const sourceID = "ses_todo_dock_source"
|
||||
const otherID = "ses_todo_dock_other"
|
||||
const sourceTitle = "Todo dock animation"
|
||||
const otherTitle = "Separate session"
|
||||
|
||||
const activeTodos = [
|
||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||
]
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||
|
||||
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
const events: EventPayload[] = []
|
||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "todo-dock-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
todos: (sessionID) => todos[sessionID] ?? [],
|
||||
})
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(sessionHref(sourceID))
|
||||
await expectSessionTitle(page, sourceTitle)
|
||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
events.push(statusEvent(sourceID, "busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
|
||||
await page.waitForTimeout(700)
|
||||
const opening = sampleDock(page, 1_000)
|
||||
todos[sourceID] = activeTodos
|
||||
events.push(todoEvent(sourceID, activeTodos))
|
||||
await expect(dock).toBeVisible()
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
const returningOpen = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
||||
expect(openSamples.length).toBeGreaterThan(0)
|
||||
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
||||
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
|
||||
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
||||
const closing = sampleDock(page, 1_000)
|
||||
todos[sourceID] = completedTodos
|
||||
events.push(todoEvent(sourceID, completedTodos))
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
todos[sourceID] = []
|
||||
events.push(todoEvent(sourceID, []))
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
const returningEmpty = sampleDock(page, 700)
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
||||
})
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||
}
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, dirBase64, server, sessionIDs }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = sessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
function sampleDock(page: Page, duration: number) {
|
||||
return page.evaluate(async (duration) => {
|
||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < duration) {
|
||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||
const clip = dock?.parentElement?.parentElement
|
||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||
samples.push({
|
||||
present: !!dock,
|
||||
height: clip?.getBoundingClientRect().height ?? 0,
|
||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||
})
|
||||
await new Promise(requestAnimationFrame)
|
||||
}
|
||||
return samples
|
||||
}, duration)
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ async function mockServer(page: Page) {
|
|||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
|
|
|
|||
|
|
@ -229,7 +229,6 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
|||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export interface MockServerConfig {
|
|||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||
events?: () => unknown[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
|
|
@ -96,8 +95,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
return json(route, message)
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function createPromptInputStoryRuntime() {
|
||||
|
|
@ -104,94 +102,6 @@ function PromptInputExample() {
|
|||
)
|
||||
}
|
||||
|
||||
const todos: Todo[] = [
|
||||
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||
]
|
||||
|
||||
function PromptInputWithOpenDock() {
|
||||
const input = createPromptInputStoryRuntime()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
activeTab: undefined as string | undefined,
|
||||
todoCollapsed: false,
|
||||
})
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [],
|
||||
options: ["build"],
|
||||
get current() {
|
||||
return controls.agent
|
||||
},
|
||||
loading: false,
|
||||
visible: true,
|
||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||
},
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
active: () => controls.activeTab,
|
||||
all: () => [],
|
||||
open: () => {},
|
||||
setActive: (tab: string) => setControls("activeTab", tab),
|
||||
},
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
const state = {
|
||||
blocked: () => false,
|
||||
questionRequest: () => undefined,
|
||||
permissionRequest: () => undefined,
|
||||
permissionResponding: () => false,
|
||||
decide: () => {},
|
||||
todos: () => todos,
|
||||
dock: () => true,
|
||||
closing: () => false,
|
||||
opening: () => false,
|
||||
}
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt: input.state,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: {
|
||||
collapsed: () => controls.todoCollapsed,
|
||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||
},
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: () => {},
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputControls}
|
||||
{...input}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/PromptInput",
|
||||
id: "app-prompt-input",
|
||||
|
|
@ -206,12 +116,3 @@ export const Basic = {
|
|||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DockAlreadyOpen = {
|
||||
render: () => (
|
||||
<div class="pt-10">
|
||||
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||
<PromptInputWithOpenDock />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,8 +221,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const key = pendingKey(sessionID)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ const sessionFields = new Set([
|
|||
"session_status",
|
||||
"session_working",
|
||||
"session_diff",
|
||||
"todo",
|
||||
"permission",
|
||||
"question",
|
||||
"message",
|
||||
|
|
@ -115,7 +114,6 @@ export const createDirSyncContext = (
|
|||
index(sessionID)
|
||||
},
|
||||
diff: serverSync.session.diff,
|
||||
todo: serverSync.session.todo,
|
||||
history: serverSync.session.history,
|
||||
evict(sessionID: string) {
|
||||
serverSync.session.evict(sessionID)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ function directoryState() {
|
|||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
|
|
|
|||
|
|
@ -223,7 +223,6 @@ export function createChildStoreManager(input: {
|
|||
return (type ?? "idle") !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
get mcp_ready() {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ const baseState = (input: Partial<State> = {}) =>
|
|||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
|
|
@ -216,7 +215,6 @@ describe("applyDirectoryEvent", () => {
|
|||
message: { ses_1: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] },
|
||||
permission: { ses_1: [] },
|
||||
question: { ses_1: [] },
|
||||
session_status: { ses_1: { type: "busy" } },
|
||||
|
|
@ -237,7 +235,6 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
|
|
@ -262,7 +259,6 @@ describe("applyDirectoryEvent", () => {
|
|||
message: { [item.info.id]: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
|
||||
session_diff: { [item.info.id]: [] },
|
||||
todo: { [item.info.id]: [] },
|
||||
permission: { [item.info.id]: [] },
|
||||
question: { [item.info.id]: [] },
|
||||
session_status: { [item.info.id]: { type: "busy" } },
|
||||
|
|
@ -283,7 +279,6 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.message[item.info.id]).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff[item.info.id]).toBeUndefined()
|
||||
expect(store.todo[item.info.id]).toBeUndefined()
|
||||
expect(store.permission[item.info.id]).toBeUndefined()
|
||||
expect(store.question[item.info.id]).toBeUndefined()
|
||||
expect(store.session_status[item.info.id]).toBeUndefined()
|
||||
|
|
@ -294,7 +289,6 @@ describe("applyDirectoryEvent", () => {
|
|||
const dropped = rootSession({ id: "ses_b" })
|
||||
const kept = rootSession({ id: "ses_a" })
|
||||
const message = userMessage("msg_1", dropped.id)
|
||||
const todos: string[] = []
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
limit: 1,
|
||||
|
|
@ -302,7 +296,6 @@ describe("applyDirectoryEvent", () => {
|
|||
message: { [dropped.id]: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
|
||||
session_diff: { [dropped.id]: [] },
|
||||
todo: { [dropped.id]: [] },
|
||||
permission: { [dropped.id]: [] },
|
||||
question: { [dropped.id]: [] },
|
||||
session_status: { [dropped.id]: { type: "busy" } },
|
||||
|
|
@ -316,21 +309,15 @@ describe("applyDirectoryEvent", () => {
|
|||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
setSessionTodo(sessionID, value) {
|
||||
if (value !== undefined) return
|
||||
todos.push(sessionID)
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.session.map((x) => x.id)).toEqual([kept.id])
|
||||
expect(store.message[dropped.id]).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||
expect(store.todo[dropped.id]).toBeUndefined()
|
||||
expect(store.permission[dropped.id]).toBeUndefined()
|
||||
expect(store.question[dropped.id]).toBeUndefined()
|
||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||
expect(todos).toEqual([dropped.id])
|
||||
})
|
||||
|
||||
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type {
|
|||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
|
|
@ -19,7 +18,6 @@ import { diffs as list, message as clean } from "@/utils/diffs"
|
|||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const SESSION_CONTENT_EVENTS = new Set([
|
||||
"session.diff",
|
||||
"todo.updated",
|
||||
"session.status",
|
||||
"message.updated",
|
||||
"message.removed",
|
||||
|
|
@ -62,13 +60,8 @@ export function applyGlobalEvent(input: {
|
|||
)
|
||||
}
|
||||
|
||||
function cleanupSessionCaches(
|
||||
setStore: SetStoreFunction<State>,
|
||||
sessionID: string,
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
|
||||
if (!sessionID) return
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, [sessionID])
|
||||
|
|
@ -76,17 +69,11 @@ function cleanupSessionCaches(
|
|||
)
|
||||
}
|
||||
|
||||
export function cleanupDroppedSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
next: Session[],
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
|
||||
const keep = new Set(next.map((item) => item.id))
|
||||
const stale = [
|
||||
...Object.keys(store.message),
|
||||
...Object.keys(store.session_diff),
|
||||
...Object.keys(store.todo),
|
||||
...Object.keys(store.permission),
|
||||
...Object.keys(store.question),
|
||||
...Object.keys(store.session_status),
|
||||
|
|
@ -95,9 +82,6 @@ export function cleanupDroppedSessionCaches(
|
|||
.filter((sessionID): sessionID is string => !!sessionID),
|
||||
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
|
||||
if (stale.length === 0) return
|
||||
for (const sessionID of stale) {
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, stale)
|
||||
|
|
@ -114,7 +98,6 @@ export function applyDirectoryEvent(input: {
|
|||
loadLsp: () => void
|
||||
loadReferences?: () => void
|
||||
vcsCache?: VcsCache
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||
retainedLimit?: number
|
||||
sessionContent?: boolean
|
||||
permission?: State["permission"]
|
||||
|
|
@ -138,7 +121,7 @@ export function applyDirectoryEvent(input: {
|
|||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
|
||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||
break
|
||||
}
|
||||
|
|
@ -155,7 +138,7 @@ export function applyDirectoryEvent(input: {
|
|||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
cleanupSessionCaches(input.setStore, info.id)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
|
|
@ -168,7 +151,7 @@ export function applyDirectoryEvent(input: {
|
|||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
|
|
@ -182,7 +165,7 @@ export function applyDirectoryEvent(input: {
|
|||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
cleanupSessionCaches(input.setStore, info.id)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
|
|
@ -192,12 +175,6 @@ export function applyDirectoryEvent(input: {
|
|||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
break
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
input.setSessionTodo?.(props.sessionID, props.todos)
|
||||
break
|
||||
}
|
||||
case "session.status": {
|
||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
||||
input.setStore("session_status", props.sessionID, reconcile(props.status))
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type {
|
|||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
|
|
@ -34,7 +33,6 @@ describe("app session cache", () => {
|
|||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
|
|
@ -43,7 +41,6 @@ describe("app session cache", () => {
|
|||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] as Todo[] },
|
||||
message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
|
|
@ -56,7 +53,6 @@ describe("app session cache", () => {
|
|||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
|
|
@ -68,7 +64,6 @@ describe("app session cache", () => {
|
|||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
|
|
@ -77,7 +72,6 @@ describe("app session cache", () => {
|
|||
} = {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: { ses_1: [m] },
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
|
@ -13,7 +12,6 @@ export const SESSION_CACHE_LIMIT = 40
|
|||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
|
|
@ -36,7 +34,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
|||
|
||||
for (const sessionID of stale) {
|
||||
delete store.message[sessionID]
|
||||
delete store.todo[sessionID]
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import type {
|
|||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
|
|
@ -53,9 +52,6 @@ export type State = {
|
|||
session_diff: {
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ type SessionView = {
|
|||
reviewOpen?: string[]
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
todoCollapsed?: boolean
|
||||
}
|
||||
|
||||
type TabHandoff = {
|
||||
|
|
@ -836,18 +835,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
setScroll(tab: string, pos: SessionScroll) {
|
||||
scroll.setScroll(key(), tab, pos)
|
||||
},
|
||||
todoCollapsed: {
|
||||
get: () => s().todoCollapsed ?? false,
|
||||
set(collapsed: boolean) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed })
|
||||
} else {
|
||||
setStore("sessionView", session, "todoCollapsed", collapsed)
|
||||
}
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
opened: terminalOpened,
|
||||
open() {
|
||||
|
|
|
|||
|
|
@ -151,7 +151,6 @@ function setup(sessions: Record<string, Session>) {
|
|||
return response()
|
||||
},
|
||||
diff: async () => ({ data: [] }),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
return { get, messages, store: createServerSession(client) }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type {
|
|||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
|
|
@ -140,7 +139,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
|
|
@ -153,7 +151,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
const requests = new Map<string, Promise<Session>>()
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const messageLoads = new Map<string, MessageLoadState>()
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
|
|
@ -199,7 +196,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
|
|
@ -254,8 +250,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
!requests.has(sessionID) &&
|
||||
!messageLoads.has(sessionID) &&
|
||||
!inflight.has(sessionID) &&
|
||||
!inflightDiff.has(sessionID) &&
|
||||
!inflightTodo.has(sessionID)
|
||||
!inflightDiff.has(sessionID)
|
||||
)
|
||||
generations.delete(sessionID)
|
||||
}
|
||||
|
|
@ -418,7 +413,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightDiff.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
messageLoads.delete(sessionID)
|
||||
pendingParts.delete(sessionID)
|
||||
orphanParts.delete(sessionID)
|
||||
|
|
@ -448,7 +442,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
|
|
@ -773,11 +766,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||
return
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
return
|
||||
}
|
||||
case "session.status": {
|
||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
||||
setData("session_status", props.sessionID, reconcile(props.status))
|
||||
|
|
@ -1140,17 +1128,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
})
|
||||
})
|
||||
},
|
||||
todo(sessionID: string, options?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
})
|
||||
})
|
||||
},
|
||||
history: {
|
||||
more: (sessionID: string) =>
|
||||
data.message[sessionID] !== undefined &&
|
||||
|
|
|
|||
|
|
@ -577,9 +577,6 @@ export const dict = {
|
|||
"session.messages.loading": "جارٍ تحميل الرسائل...",
|
||||
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
|
||||
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
|
||||
"session.todo.title": "المهام",
|
||||
"session.todo.collapse": "طي",
|
||||
"session.todo.expand": "توسيع",
|
||||
"session.question.minimize": "تصغير السؤال",
|
||||
"session.question.restore": "استعادة السؤال",
|
||||
"session.question.pending.one": "{{count}} سؤال معلق",
|
||||
|
|
@ -867,8 +864,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
|
||||
"settings.permissions.tool.todowrite.title": "كتابة المهام",
|
||||
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
|
||||
"settings.permissions.tool.webfetch.title": "جلب الويب",
|
||||
"settings.permissions.tool.webfetch.description": "جلب محتوى من عنوان URL",
|
||||
"settings.permissions.tool.websearch.title": "بحث الويب",
|
||||
|
|
@ -931,7 +926,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
|
||||
"session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...",
|
||||
"session.review.noVcs.createGit.action": "إنشاء مستودع Git",
|
||||
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
|
||||
"session.question.progress": "{{current}} من {{total}} أسئلة",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "مستكشف الملفات",
|
||||
|
|
|
|||
|
|
@ -582,9 +582,6 @@ export const dict = {
|
|||
"session.messages.loading": "Carregando mensagens...",
|
||||
"session.messages.jumpToLatest": "Ir para a mais recente",
|
||||
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
|
||||
"session.todo.title": "Tarefas",
|
||||
"session.todo.collapse": "Recolher",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.question.minimize": "Minimizar pergunta",
|
||||
"session.question.restore": "Restaurar pergunta",
|
||||
"session.question.pending.one": "{{count}} pergunta pendente",
|
||||
|
|
@ -880,8 +877,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
|
||||
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
|
||||
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
|
||||
"settings.permissions.tool.webfetch.title": "Buscar Web",
|
||||
"settings.permissions.tool.webfetch.description": "Buscar conteúdo de uma URL",
|
||||
"settings.permissions.tool.websearch.title": "Pesquisa Web",
|
||||
|
|
@ -944,7 +939,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
|
||||
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
|
||||
"session.review.noVcs.createGit.action": "Criar repositório Git",
|
||||
"session.todo.progress": "{{done}} de {{total}} tarefas concluídas",
|
||||
"session.question.progress": "{{current}} de {{total}} perguntas",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Explorador de Arquivos",
|
||||
|
|
|
|||
|
|
@ -637,9 +637,6 @@ export const dict = {
|
|||
"session.messages.jumpToLatest": "Idi na najnovije",
|
||||
|
||||
"session.context.addToContext": "Dodaj {{selection}} u kontekst",
|
||||
"session.todo.title": "Zadaci",
|
||||
"session.todo.collapse": "Sažmi",
|
||||
"session.todo.expand": "Proširi",
|
||||
"session.question.minimize": "Minimiziraj pitanje",
|
||||
"session.question.restore": "Vrati pitanje",
|
||||
"session.question.pending.one": "{{count}} pitanje na čekanju",
|
||||
|
|
@ -954,8 +951,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
|
||||
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
|
||||
"settings.permissions.tool.webfetch.title": "Web preuzimanje",
|
||||
"settings.permissions.tool.webfetch.description": "Preuzmi sadržaj sa URL-a",
|
||||
"settings.permissions.tool.websearch.title": "Web pretraga",
|
||||
|
|
@ -1020,7 +1015,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu",
|
||||
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
|
||||
"session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
|
||||
"session.todo.progress": "{{done}} od {{total}} zadataka završeno",
|
||||
"session.question.progress": "{{current}} od {{total}} pitanja",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "File Explorer",
|
||||
|
|
|
|||
|
|
@ -632,9 +632,6 @@ export const dict = {
|
|||
|
||||
"session.messages.jumpToLatest": "Gå til seneste",
|
||||
"session.context.addToContext": "Tilføj {{selection}} til kontekst",
|
||||
"session.todo.title": "Opgaver",
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Udvid",
|
||||
"session.question.minimize": "Minimer spørgsmål",
|
||||
"session.question.restore": "Gendan spørgsmål",
|
||||
"session.question.pending.one": "{{count}} afventende spørgsmål",
|
||||
|
|
@ -946,8 +943,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv To-do",
|
||||
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
|
||||
"settings.permissions.tool.webfetch.title": "Webhentning",
|
||||
"settings.permissions.tool.webfetch.description": "Hent indhold fra en URL",
|
||||
"settings.permissions.tool.websearch.title": "Websøgning",
|
||||
|
|
@ -1012,7 +1007,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
|
||||
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
|
||||
"session.review.noVcs.createGit.action": "Opret Git-repository",
|
||||
"session.todo.progress": "{{done}} af {{total}} opgaver fuldført",
|
||||
"session.question.progress": "{{current}} af {{total}} spørgsmål",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Stifinder",
|
||||
|
|
|
|||
|
|
@ -591,9 +591,6 @@ export const dict = {
|
|||
"session.messages.loading": "Lade Nachrichten...",
|
||||
"session.messages.jumpToLatest": "Zum neuesten springen",
|
||||
"session.context.addToContext": "{{selection}} zum Kontext hinzufügen",
|
||||
"session.todo.title": "Aufgaben",
|
||||
"session.todo.collapse": "Einklappen",
|
||||
"session.todo.expand": "Ausklappen",
|
||||
"session.question.minimize": "Frage minimieren",
|
||||
"session.question.restore": "Frage wiederherstellen",
|
||||
"session.question.pending.one": "{{count}} ausstehende Frage",
|
||||
|
|
@ -892,8 +889,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
|
||||
"settings.permissions.tool.todowrite.title": "Todo schreiben",
|
||||
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
|
||||
"settings.permissions.tool.webfetch.title": "Web-Abruf",
|
||||
"settings.permissions.tool.webfetch.description": "Inhalt von einer URL abrufen",
|
||||
"settings.permissions.tool.websearch.title": "Web-Suche",
|
||||
|
|
@ -957,7 +952,6 @@ export const dict = {
|
|||
"Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git-Repository wird erstellt...",
|
||||
"session.review.noVcs.createGit.action": "Git-Repository erstellen",
|
||||
"session.todo.progress": "{{done}} von {{total}} Aufgaben erledigt",
|
||||
"session.question.progress": "{{current}} von {{total}} Fragen",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Datei-Explorer",
|
||||
|
|
|
|||
|
|
@ -659,10 +659,6 @@ export const dict = {
|
|||
"session.messages.jumpToLatest": "Jump to latest",
|
||||
|
||||
"session.context.addToContext": "Add {{selection}} to context",
|
||||
"session.todo.title": "Todos",
|
||||
"session.todo.collapse": "Collapse",
|
||||
"session.todo.expand": "Expand",
|
||||
"session.todo.progress": "{{done}} of {{total}} todos completed",
|
||||
"session.question.progress": "{{current}} of {{total}} questions",
|
||||
"session.question.minimize": "Minimize question",
|
||||
"session.question.restore": "Restore question",
|
||||
|
|
@ -1040,8 +1036,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Load a skill by name",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Run language server queries",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Update the todo list",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
|
|
|
|||
|
|
@ -638,9 +638,6 @@ export const dict = {
|
|||
"session.messages.jumpToLatest": "Ir al último",
|
||||
|
||||
"session.context.addToContext": "Añadir {{selection}} al contexto",
|
||||
"session.todo.title": "Tareas",
|
||||
"session.todo.collapse": "Contraer",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.question.minimize": "Minimizar pregunta",
|
||||
"session.question.restore": "Restaurar pregunta",
|
||||
"session.question.pending.one": "{{count}} pregunta pendiente",
|
||||
|
|
@ -962,8 +959,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
|
||||
"settings.permissions.tool.todowrite.title": "Escribir Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Obtener contenido de una URL",
|
||||
"settings.permissions.tool.websearch.title": "Búsqueda Web",
|
||||
|
|
@ -1028,7 +1023,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto",
|
||||
"session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...",
|
||||
"session.review.noVcs.createGit.action": "Crear repositorio Git",
|
||||
"session.todo.progress": "{{done}} de {{total}} tareas completadas",
|
||||
"session.question.progress": "{{current}} de {{total}} preguntas",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Explorador de archivos",
|
||||
|
|
|
|||
|
|
@ -587,9 +587,6 @@ export const dict = {
|
|||
"session.messages.loading": "Chargement des messages...",
|
||||
"session.messages.jumpToLatest": "Aller au dernier",
|
||||
"session.context.addToContext": "Ajouter {{selection}} au contexte",
|
||||
"session.todo.title": "Tâches",
|
||||
"session.todo.collapse": "Réduire",
|
||||
"session.todo.expand": "Développer",
|
||||
"session.question.minimize": "Réduire la question",
|
||||
"session.question.restore": "Restaurer la question",
|
||||
"session.question.pending.one": "{{count}} question en attente",
|
||||
|
|
@ -891,8 +888,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
|
||||
"settings.permissions.tool.todowrite.title": "Écrire Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
|
||||
"settings.permissions.tool.webfetch.title": "Récupération Web",
|
||||
"settings.permissions.tool.webfetch.description": "Récupérer le contenu d'une URL",
|
||||
"settings.permissions.tool.websearch.title": "Recherche Web",
|
||||
|
|
@ -955,7 +950,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet",
|
||||
"session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...",
|
||||
"session.review.noVcs.createGit.action": "Créer un dépôt Git",
|
||||
"session.todo.progress": "{{done}} tâches sur {{total}} terminées",
|
||||
"session.question.progress": "{{current}} questions sur {{total}}",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Explorateur de fichiers",
|
||||
|
|
|
|||
|
|
@ -579,9 +579,6 @@ export const dict = {
|
|||
"session.messages.loading": "メッセージを読み込み中...",
|
||||
"session.messages.jumpToLatest": "最新へジャンプ",
|
||||
"session.context.addToContext": "{{selection}}をコンテキストに追加",
|
||||
"session.todo.title": "ToDo",
|
||||
"session.todo.collapse": "折りたたむ",
|
||||
"session.todo.expand": "展開",
|
||||
"session.question.minimize": "質問を最小化",
|
||||
"session.question.restore": "質問を復元",
|
||||
"session.question.pending.one": "{{count}}件の保留中の質問",
|
||||
|
|
@ -874,8 +871,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
|
||||
"settings.permissions.tool.todowrite.title": "Todo書き込み",
|
||||
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
|
||||
"settings.permissions.tool.webfetch.title": "Web取得",
|
||||
"settings.permissions.tool.webfetch.description": "URLからコンテンツを取得",
|
||||
"settings.permissions.tool.websearch.title": "Web検索",
|
||||
|
|
@ -938,7 +933,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...",
|
||||
"session.review.noVcs.createGit.action": "Git リポジトリを作成",
|
||||
"session.todo.progress": "{{done}} 個中 {{total}} 個の Todo が完了",
|
||||
"session.question.progress": "{{total}} 問中 {{current}} 問",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "エクスプローラー",
|
||||
|
|
|
|||
|
|
@ -471,9 +471,6 @@ export const dict = {
|
|||
"session.messages.loading": "메시지 로드 중...",
|
||||
"session.messages.jumpToLatest": "최신으로 이동",
|
||||
"session.context.addToContext": "컨텍스트에 {{selection}} 추가",
|
||||
"session.todo.title": "할 일",
|
||||
"session.todo.collapse": "접기",
|
||||
"session.todo.expand": "펼치기",
|
||||
"session.followupDock.summary.one": "{{count}}개의 대기 중인 메시지",
|
||||
"session.followupDock.summary.other": "{{count}}개의 대기 중인 메시지",
|
||||
"session.followupDock.sendNow": "지금 전송",
|
||||
|
|
@ -721,8 +718,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
|
||||
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
|
||||
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
|
||||
"settings.permissions.tool.webfetch.title": "웹 가져오기",
|
||||
"settings.permissions.tool.webfetch.description": "URL에서 콘텐츠 가져오기",
|
||||
"settings.permissions.tool.websearch.title": "웹 검색",
|
||||
|
|
@ -785,7 +780,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...",
|
||||
"session.review.noVcs.createGit.action": "Git 저장소 생성",
|
||||
"session.todo.progress": "{{total}}개의 할 일 중 {{done}}개 완료",
|
||||
"session.question.progress": "{{total}}개의 질문 중 {{current}}개",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "파일 탐색기",
|
||||
|
|
|
|||
|
|
@ -532,9 +532,6 @@ export const dict = {
|
|||
"session.messages.jumpToLatest": "Hopp til nyeste",
|
||||
|
||||
"session.context.addToContext": "Legg til {{selection}} i kontekst",
|
||||
"session.todo.title": "Oppgaver",
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Utvid",
|
||||
"session.followupDock.summary.one": "{{count}} melding i kø",
|
||||
"session.followupDock.summary.other": "{{count}} meldinger i kø",
|
||||
"session.followupDock.sendNow": "Send nå",
|
||||
|
|
@ -806,8 +803,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
|
||||
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
|
||||
"settings.permissions.tool.webfetch.title": "Webhenting",
|
||||
"settings.permissions.tool.webfetch.description": "Hent innhold fra en URL",
|
||||
"settings.permissions.tool.websearch.title": "Websøk",
|
||||
|
|
@ -872,7 +867,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "Spor, gjennomgå og angre endringer i dette prosjektet",
|
||||
"session.review.noVcs.createGit.actionLoading": "Oppretter Git-depot...",
|
||||
"session.review.noVcs.createGit.action": "Opprett Git-depot",
|
||||
"session.todo.progress": "{{done}} av {{total}} oppgaver fullført",
|
||||
"session.question.progress": "{{current}} av {{total}} spørsmål",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Filutforsker",
|
||||
|
|
|
|||
|
|
@ -582,9 +582,6 @@ export const dict = {
|
|||
"session.messages.loading": "Ładowanie wiadomości...",
|
||||
"session.messages.jumpToLatest": "Przejdź do najnowszych",
|
||||
"session.context.addToContext": "Dodaj {{selection}} do kontekstu",
|
||||
"session.todo.title": "Zadania",
|
||||
"session.todo.collapse": "Zwiń",
|
||||
"session.todo.expand": "Rozwiń",
|
||||
"session.question.minimize": "Zminimalizuj pytanie",
|
||||
"session.question.restore": "Przywróć pytanie",
|
||||
"session.question.pending.one": "{{count}} oczekujące pytanie",
|
||||
|
|
@ -879,8 +876,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
|
||||
"settings.permissions.tool.todowrite.title": "Zapis Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
|
||||
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",
|
||||
"settings.permissions.tool.webfetch.description": "Pobieranie zawartości z adresu URL",
|
||||
"settings.permissions.tool.websearch.title": "Wyszukiwanie w sieci",
|
||||
|
|
@ -943,7 +938,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "Śledź, przeglądaj i cofaj zmiany w tym projekcie",
|
||||
"session.review.noVcs.createGit.actionLoading": "Tworzenie repozytorium Git...",
|
||||
"session.review.noVcs.createGit.action": "Utwórz repozytorium Git",
|
||||
"session.todo.progress": "Ukończono {{done}} z {{total}} zadań",
|
||||
"session.question.progress": "{{current}} z {{total}} pytań",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Eksplorator plików",
|
||||
|
|
|
|||
|
|
@ -635,9 +635,6 @@ export const dict = {
|
|||
"session.messages.jumpToLatest": "Перейти к последнему",
|
||||
|
||||
"session.context.addToContext": "Добавить {{selection}} в контекст",
|
||||
"session.todo.title": "Задачи",
|
||||
"session.todo.collapse": "Свернуть",
|
||||
"session.todo.expand": "Развернуть",
|
||||
"session.question.minimize": "Свернуть вопрос",
|
||||
"session.question.restore": "Восстановить вопрос",
|
||||
"session.question.pending.one": "{{count}} вопрос без ответа",
|
||||
|
|
@ -956,8 +953,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Получение контента по URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
|
|
@ -1023,7 +1018,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "Отслеживайте, просматривайте и отменяйте изменения в этом проекте",
|
||||
"session.review.noVcs.createGit.actionLoading": "Создание репозитория Git...",
|
||||
"session.review.noVcs.createGit.action": "Создать репозиторий Git",
|
||||
"session.todo.progress": "Выполнено {{done}} из {{total}} задач",
|
||||
"session.question.progress": "{{current}} из {{total}} вопросов",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Проводник",
|
||||
|
|
|
|||
|
|
@ -632,9 +632,6 @@ export const dict = {
|
|||
"session.messages.jumpToLatest": "ไปที่ล่าสุด",
|
||||
|
||||
"session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท",
|
||||
"session.todo.title": "สิ่งที่ต้องทำ",
|
||||
"session.todo.collapse": "ย่อ",
|
||||
"session.todo.expand": "ขยาย",
|
||||
"session.question.minimize": "ย่อคำถาม",
|
||||
"session.question.restore": "คืนค่าคำถาม",
|
||||
"session.question.pending.one": "คำถามที่รอดำเนินการ {{count}} ข้อ",
|
||||
|
|
@ -942,8 +939,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
|
||||
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
|
||||
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
|
||||
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
|
||||
"settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL",
|
||||
"settings.permissions.tool.websearch.title": "ค้นหาเว็บ",
|
||||
|
|
@ -1008,7 +1003,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้",
|
||||
"session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git รีโพซิทอรี...",
|
||||
"session.review.noVcs.createGit.action": "สร้าง Git รีโพซิทอรี",
|
||||
"session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ",
|
||||
"session.question.progress": "{{current}} จาก {{total}} คำถาม",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "File Explorer",
|
||||
|
|
|
|||
|
|
@ -642,9 +642,6 @@ export const dict = {
|
|||
"session.messages.jumpToLatest": "En sona atla",
|
||||
|
||||
"session.context.addToContext": "{{selection}} bağlama ekle",
|
||||
"session.todo.title": "Görevler",
|
||||
"session.todo.collapse": "Daralt",
|
||||
"session.todo.expand": "Genişlet",
|
||||
"session.question.minimize": "Soruyu küçült",
|
||||
"session.question.restore": "Soruyu geri yükle",
|
||||
"session.question.pending.one": "{{count}} bekleyen soru",
|
||||
|
|
@ -962,8 +959,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
|
||||
"settings.permissions.tool.todowrite.title": "Görev Yaz",
|
||||
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
|
||||
"settings.permissions.tool.webfetch.title": "Web Getir",
|
||||
"settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir",
|
||||
"settings.permissions.tool.websearch.title": "Web Ara",
|
||||
|
|
@ -1028,7 +1023,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "Bu projedeki değişiklikleri takip et, incele ve geri al",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git deposu oluşturuluyor...",
|
||||
"session.review.noVcs.createGit.action": "Git deposu oluştur",
|
||||
"session.todo.progress": "{{total}} görevin {{done}} tanesi tamamlandı",
|
||||
"session.question.progress": "{{total}} sorunun {{current}} tanesi",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "Dosya Gezgini",
|
||||
|
|
|
|||
|
|
@ -663,10 +663,6 @@ export const dict = {
|
|||
"session.messages.jumpToLatest": "Перейти до останніх",
|
||||
|
||||
"session.context.addToContext": "Додати {{selection}} до контексту",
|
||||
"session.todo.title": "Завдання",
|
||||
"session.todo.collapse": "Згорнути",
|
||||
"session.todo.expand": "Розгорнути",
|
||||
"session.todo.progress": "Виконано {{done}} з {{total}} завдань",
|
||||
"session.question.progress": "{{current}} з {{total}} запитань",
|
||||
"session.question.minimize": "Згорнути запитання",
|
||||
"session.question.restore": "Відновити запитання",
|
||||
|
|
@ -1047,8 +1043,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "Завантаження навички за назвою",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Виконання запитів мовного сервера",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Оновлення списку завдань",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "Отримання вмісту з URL",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
|
|
|
|||
|
|
@ -630,9 +630,6 @@ export const dict = {
|
|||
"session.messages.loading": "正在加载消息...",
|
||||
"session.messages.jumpToLatest": "跳转到最新",
|
||||
"session.context.addToContext": "将 {{selection}} 添加到上下文",
|
||||
"session.todo.title": "待办事项",
|
||||
"session.todo.collapse": "折叠",
|
||||
"session.todo.expand": "展开",
|
||||
"session.question.minimize": "最小化问题",
|
||||
"session.question.restore": "恢复问题",
|
||||
"session.question.pending.one": "{{count}} 个待处理问题",
|
||||
|
|
@ -935,8 +932,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "按名称加载技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
|
||||
"settings.permissions.tool.todowrite.title": "更新待办",
|
||||
"settings.permissions.tool.todowrite.description": "更新待办列表",
|
||||
"settings.permissions.tool.webfetch.title": "网页获取",
|
||||
"settings.permissions.tool.webfetch.description": "从 URL 获取内容",
|
||||
"settings.permissions.tool.websearch.title": "网页搜索",
|
||||
|
|
@ -1001,7 +996,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "在此项目中跟踪、审查和撤消更改",
|
||||
"session.review.noVcs.createGit.actionLoading": "正在创建 Git 仓库...",
|
||||
"session.review.noVcs.createGit.action": "创建 Git 仓库",
|
||||
"session.todo.progress": "已完成 {{done}} 个任务(共 {{total}} 个)",
|
||||
"session.question.progress": "{{current}}/{{total}} 个问题",
|
||||
"session.header.open.finder": "访达",
|
||||
"session.header.open.fileExplorer": "文件资源管理器",
|
||||
|
|
|
|||
|
|
@ -626,9 +626,6 @@ export const dict = {
|
|||
|
||||
"session.messages.jumpToLatest": "跳到最新",
|
||||
"session.context.addToContext": "將 {{selection}} 新增到上下文",
|
||||
"session.todo.title": "待辦事項",
|
||||
"session.todo.collapse": "折疊",
|
||||
"session.todo.expand": "展開",
|
||||
"session.question.minimize": "最小化問題",
|
||||
"session.question.restore": "還原問題",
|
||||
"session.question.pending.one": "{{count}} 個待處理問題",
|
||||
|
|
@ -931,8 +928,6 @@ export const dict = {
|
|||
"settings.permissions.tool.skill.description": "按名稱載入技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
|
||||
"settings.permissions.tool.todowrite.title": "更新待辦",
|
||||
"settings.permissions.tool.todowrite.description": "更新待辦清單",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
"settings.permissions.tool.webfetch.description": "從 URL 取得內容",
|
||||
"settings.permissions.tool.websearch.title": "Web Search",
|
||||
|
|
@ -997,7 +992,6 @@ export const dict = {
|
|||
"session.review.noVcs.createGit.description": "追蹤、檢閱及復原此專案中的變更",
|
||||
"session.review.noVcs.createGit.actionLoading": "正在建立 Git 儲存庫...",
|
||||
"session.review.noVcs.createGit.action": "建立 Git 儲存庫",
|
||||
"session.todo.progress": "已完成 {{done}} 個待辦事項(共 {{total}} 個)",
|
||||
"session.question.progress": "{{current}}/{{total}} 個問題",
|
||||
"session.header.open.finder": "Finder",
|
||||
"session.header.open.fileExplorer": "檔案總管",
|
||||
|
|
|
|||
|
|
@ -565,8 +565,6 @@ export default function Page() {
|
|||
})
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
let todoFrame: number | undefined
|
||||
let todoTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
|
||||
|
|
@ -773,41 +771,6 @@ export default function Page() {
|
|||
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
const id = params.id
|
||||
return [
|
||||
sdk().directory,
|
||||
id,
|
||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? composer.blocked() : false,
|
||||
] as const
|
||||
},
|
||||
([dir, id, status, blocked]) => {
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
todoFrame = undefined
|
||||
todoTimer = undefined
|
||||
if (!id) return
|
||||
if (status === "idle" && !blocked) return
|
||||
const cached = untrack(() => sync().data.todo[id] !== undefined)
|
||||
|
||||
todoFrame = requestAnimationFrame(() => {
|
||||
todoFrame = undefined
|
||||
todoTimer = window.setTimeout(() => {
|
||||
todoTimer = undefined
|
||||
if (sdk().directory !== dir || params.id !== id) return
|
||||
untrack(() => {
|
||||
void sync().session.todo(id, cached ? { force: true } : undefined)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => visibleUserMessages().at(-1)?.id,
|
||||
|
|
@ -1882,8 +1845,6 @@ export default function Page() {
|
|||
|
||||
onCleanup(() => {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
|
|
@ -1898,12 +1859,7 @@ export default function Page() {
|
|||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
prompt,
|
||||
ready: () => !store.deferRender && messagesReady(),
|
||||
centered,
|
||||
todo: {
|
||||
collapsed: () => view().todoCollapsed.get(),
|
||||
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
|
||||
},
|
||||
followup: () =>
|
||||
params.id && !isChildSession()
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
|
||||
import type { PromptInputState } from "@/components/prompt-input"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
|
@ -26,12 +23,7 @@ export function createSessionComposerRegionController(input: {
|
|||
sessionKey: Accessor<string>
|
||||
sessionID: Accessor<string | undefined>
|
||||
prompt: PromptInputState
|
||||
ready: Accessor<boolean>
|
||||
centered: Accessor<boolean>
|
||||
todo: {
|
||||
collapsed: Accessor<boolean>
|
||||
onToggle: () => void
|
||||
}
|
||||
followup: Accessor<SessionComposerFollowupDock | undefined>
|
||||
revert: Accessor<SessionComposerRevertDock | undefined>
|
||||
onResponseSubmit: () => void
|
||||
|
|
@ -40,41 +32,6 @@ export function createSessionComposerRegionController(input: {
|
|||
setDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const sync = useSync()
|
||||
const [store, setStore] = createStore({
|
||||
ready: input.ready() || input.state.dock(),
|
||||
height: 320,
|
||||
body: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
timer = undefined
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
input.sessionKey()
|
||||
const ready = input.ready()
|
||||
const dock = input.state.dock()
|
||||
|
||||
clear()
|
||||
if (store.ready || (!ready && !dock)) return
|
||||
if (dock) {
|
||||
setStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setStore("ready", true)
|
||||
timer = undefined
|
||||
}, 140)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.prompt.ready()) return
|
||||
|
|
@ -92,27 +49,10 @@ export function createSessionComposerRegionController(input: {
|
|||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const el = store.body
|
||||
if (!el) return
|
||||
const update = () => setStore("height", el.getBoundingClientRect().height)
|
||||
createResizeObserver(el, update)
|
||||
update()
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const parentID = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? sync().session.get(id)?.parentID : undefined
|
||||
})
|
||||
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
{ visualDuration: 0.3, bounce: 0 },
|
||||
() => `${input.sessionKey()}\0${store.ready}`,
|
||||
)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const ready = Promise.resolve()
|
||||
const [promptReady] = createResource(
|
||||
() => input.prompt.ready.promise ?? ready,
|
||||
|
|
@ -122,7 +62,6 @@ export function createSessionComposerRegionController(input: {
|
|||
return {
|
||||
state: input.state,
|
||||
centered: input.centered,
|
||||
todo: input.todo,
|
||||
followup: input.followup,
|
||||
revert: input.revert,
|
||||
onResponseSubmit: input.onResponseSubmit,
|
||||
|
|
@ -134,11 +73,7 @@ export function createSessionComposerRegionController(input: {
|
|||
showComposer: () => !input.state.blocked() || !!parentID(),
|
||||
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
|
||||
promptReady: () => input.prompt.ready() || promptReady(),
|
||||
dock: () => (store.ready && input.state.dock()) || value() > 0.001,
|
||||
dockProgress: value,
|
||||
dockHeight: () => Math.max(78, store.height),
|
||||
lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
|
||||
setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
|
||||
lift: () => (input.revert()?.items.length ? 18 : 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { SessionPermissionDock } from "@/pages/session/composer/session-permissi
|
|||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import type { SessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
|
|
@ -60,28 +59,6 @@ export function SessionComposerRegion(props: {
|
|||
</Show>
|
||||
|
||||
<Show when={controller.showComposer()}>
|
||||
<Show when={controller.dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": controller.dockProgress() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={controller.setDockBodyRef}>
|
||||
<SessionTodoDock
|
||||
todos={controller.state.todos()}
|
||||
collapsed={controller.todo.collapsed()}
|
||||
onToggle={controller.todo.onToggle}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={controller.dockProgress()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={controller.promptReady()}
|
||||
fallback={
|
||||
|
|
@ -98,10 +75,7 @@ export function SessionComposerRegion(props: {
|
|||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
|
||||
style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
|
||||
>
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{controller.handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -109,11 +83,7 @@ export function SessionComposerRegion(props: {
|
|||
>
|
||||
<Show when={rolled()} keyed>
|
||||
{(revert) => (
|
||||
<div
|
||||
style={{
|
||||
"margin-top": `${-36 * controller.dockProgress()}px`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<SessionRevertDock
|
||||
items={revert.items}
|
||||
restoring={revert.restoring}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { todoDockAtBoundary, todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
|
|
@ -104,35 +103,3 @@ describe("sessionQuestionRequest", () => {
|
|||
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todoState", () => {
|
||||
test("hides when there are no todos", () => {
|
||||
expect(todoState({ count: 0, done: false, live: true })).toBe("hide")
|
||||
})
|
||||
|
||||
test("opens while the session is still working", () => {
|
||||
expect(todoState({ count: 2, done: false, live: true })).toBe("open")
|
||||
})
|
||||
|
||||
test("closes completed todos after a running turn", () => {
|
||||
expect(todoState({ count: 2, done: true, live: true })).toBe("close")
|
||||
})
|
||||
|
||||
test("clears stale todos when the turn ends", () => {
|
||||
expect(todoState({ count: 2, done: false, live: false })).toBe("clear")
|
||||
})
|
||||
|
||||
test("clears completed todos when the session is no longer live", () => {
|
||||
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todoDockAtBoundary", () => {
|
||||
test("shows active todos when entering a session", () => {
|
||||
expect(todoDockAtBoundary("open")).toBe(true)
|
||||
})
|
||||
|
||||
test("hides completed todos when entering a session", () => {
|
||||
expect(todoDockAtBoundary("close")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,35 +1,20 @@
|
|||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
export const todoState = (input: {
|
||||
count: number
|
||||
done: boolean
|
||||
live: boolean
|
||||
}): "hide" | "clear" | "open" | "close" => {
|
||||
if (input.count === 0) return "hide"
|
||||
if (!input.live) return "clear"
|
||||
if (!input.done) return "open"
|
||||
return "close"
|
||||
}
|
||||
|
||||
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
|
||||
export function createSessionComposerController() {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
|
||||
|
|
@ -49,24 +34,8 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||
return !!permissionRequest() || !!questionRequest()
|
||||
})
|
||||
|
||||
const todos = createMemo((): Todo[] => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return serverSync().session.data.todo[id] ?? []
|
||||
})
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
sessionID: params.id,
|
||||
responding: undefined as string | undefined,
|
||||
dock: todos().length > 0 && !done() && live(),
|
||||
closing: false,
|
||||
opening: false,
|
||||
})
|
||||
|
||||
const permissionResponding = createMemo(() => {
|
||||
|
|
@ -92,112 +61,12 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||
})
|
||||
}
|
||||
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
const closeMs = () => {
|
||||
const value = options?.closeMs
|
||||
if (typeof value === "function") return Math.max(0, value())
|
||||
if (typeof value === "number") return Math.max(0, value)
|
||||
return 400
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setStore({ dock: false, closing: false })
|
||||
timer = undefined
|
||||
}, closeMs())
|
||||
}
|
||||
|
||||
// Keep stale turn todos from reopening if the model never clears them.
|
||||
const clear = () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
sync().set("todo", id, [])
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.id, todos().length, done(), live()] as const,
|
||||
([id, count, complete, active], previous) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
|
||||
const next = todoState({
|
||||
count,
|
||||
done: complete,
|
||||
live: active,
|
||||
})
|
||||
|
||||
if (!previous || previous[0] !== id) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
|
||||
if (next === "clear") clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "hide") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ dock: false, closing: false, opening: false })
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "clear") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "open") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
const hidden = !store.dock || store.closing
|
||||
setStore({ dock: true, closing: false })
|
||||
if (hidden) {
|
||||
setStore("opening", true)
|
||||
raf = requestAnimationFrame(() => {
|
||||
setStore("opening", false)
|
||||
raf = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore("opening", false)
|
||||
return
|
||||
}
|
||||
|
||||
setStore({ dock: true, opening: false, closing: true })
|
||||
if (!timer) scheduleClose()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!raf) return
|
||||
cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
return {
|
||||
blocked,
|
||||
questionRequest,
|
||||
permissionRequest,
|
||||
permissionResponding,
|
||||
decide,
|
||||
todos,
|
||||
dock: () =>
|
||||
store.sessionID === params.id
|
||||
? store.dock
|
||||
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
|
||||
closing: () => store.sessionID === params.id && store.closing,
|
||||
opening: () => store.sessionID === params.id && store.opening,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,256 +0,0 @@
|
|||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
|
||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Index, createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
const doneToken = "\u0000done\u0000"
|
||||
const totalToken = "\u0000total\u0000"
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
if (status !== "in_progress") return undefined
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="block"
|
||||
>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="6"
|
||||
r="3"
|
||||
style={{
|
||||
animation: "var(--animate-pulse-scale)",
|
||||
"transform-origin": "center",
|
||||
"transform-box": "fill-box",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTodoDock(props: {
|
||||
todos: Todo[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
height: 78,
|
||||
})
|
||||
|
||||
const total = createMemo(() => props.todos.length)
|
||||
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
|
||||
const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() }))
|
||||
const progress = createMemo(() =>
|
||||
language
|
||||
.t("session.todo.progress", { done: doneToken, total: totalToken })
|
||||
.split(/(\u0000done\u0000|\u0000total\u0000)/),
|
||||
)
|
||||
|
||||
const active = createMemo(
|
||||
() =>
|
||||
props.todos.find((todo) => todo.status === "in_progress") ??
|
||||
props.todos.find((todo) => todo.status === "pending") ??
|
||||
props.todos.filter((todo) => todo.status === "completed").at(-1) ??
|
||||
props.todos[0],
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||
const full = createMemo(() => Math.max(78, store.height))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setStore("height", (height) => Math.max(height, el.scrollHeight))
|
||||
}
|
||||
update()
|
||||
createResizeObserver(el, update)
|
||||
})
|
||||
|
||||
return (
|
||||
<DockTray
|
||||
data-component="session-todo-dock"
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={props.onToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
props.onToggle()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 overflow-visible"
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": "600ms",
|
||||
"--tool-motion-mask": "18%",
|
||||
"--tool-motion-mask-height": "0px",
|
||||
"--tool-motion-spring-ms": "560ms",
|
||||
"white-space": "pre",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
}}
|
||||
>
|
||||
<Index each={progress()}>
|
||||
{(item) =>
|
||||
item() === doneToken ? (
|
||||
<AnimatedNumber value={done()} />
|
||||
) : item() === totalToken ? (
|
||||
<AnimatedNumber value={total()} />
|
||||
) : (
|
||||
<span>{item()}</span>
|
||||
)
|
||||
}
|
||||
</Index>
|
||||
</span>
|
||||
<div
|
||||
data-slot="session-todo-preview"
|
||||
class="ml-1 min-w-0 overflow-hidden"
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
"max-width": "100%",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={props.collapsed ? preview() : undefined}
|
||||
duration={600}
|
||||
travel={25}
|
||||
edge={17}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
truncate
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
data-collapsed={props.collapsed ? "true" : "false"}
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${turn() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onToggle()
|
||||
}}
|
||||
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
aria-hidden={props.collapsed || off()}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
style={{
|
||||
visibility: off() ? "hidden" : "visible",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} />
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoList(props: { todos: Todo[] }) {
|
||||
const [store, setStore] = createStore({
|
||||
stuck: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
|
||||
style={{ "overflow-anchor": "none" }}
|
||||
onScroll={(e) => {
|
||||
setStore("stuck", e.currentTarget.scrollTop > 0)
|
||||
}}
|
||||
>
|
||||
<Index each={props.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox
|
||||
readOnly
|
||||
checked={todo().status === "completed"}
|
||||
indeterminate={todo().status === "in_progress"}
|
||||
data-in-progress={todo().status === "in_progress" ? "" : undefined}
|
||||
data-state={todo().status}
|
||||
icon={dot(todo().status)}
|
||||
style={{
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
opacity: todo().status === "pending" ? "0.94" : "1",
|
||||
}}
|
||||
>
|
||||
<TextStrikethrough
|
||||
active={todo().status === "completed" || todo().status === "cancelled"}
|
||||
text={todo().content}
|
||||
class="text-14-regular min-w-0 break-words"
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
transition:
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
opacity: todo().status === "pending" ? "0.92" : "1",
|
||||
}}
|
||||
/>
|
||||
</Checkbox>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, var(--background-base), transparent)",
|
||||
opacity: store.stuck ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,623 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useServerSync } from "@/context/global-sync"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import {
|
||||
SessionComposerRegion,
|
||||
createSessionComposerController,
|
||||
createSessionComposerRegionController,
|
||||
} from "@/pages/session/composer"
|
||||
|
||||
export default {
|
||||
title: "UI/Todo Panel Motion",
|
||||
id: "components-todo-panel-motion",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
This playground renders the real session composer region from app code.
|
||||
|
||||
### Source path
|
||||
- \`packages/app/src/pages/session/composer/session-composer-region.tsx\`
|
||||
|
||||
### Includes
|
||||
- \`SessionTodoDock\` (real)
|
||||
- \`PromptInput\` (real)
|
||||
|
||||
No visual reimplementation layer is used for the dock/input stack.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const pool = [
|
||||
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)",
|
||||
"Remove inline measure nodes/CSS hooks and keep width morph behavior intact",
|
||||
"Run typechecks/tests and report what changed",
|
||||
"Verify reduced-motion behavior in timeline",
|
||||
"Review diff for animation edge cases",
|
||||
"Document rollout notes in PR description",
|
||||
"Check keyboard and screen reader semantics",
|
||||
"Add storybook controls for iteration speed",
|
||||
]
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const controls = {
|
||||
agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
newLayoutDesigns: true,
|
||||
}
|
||||
|
||||
const css = `
|
||||
[data-component="todo-stage"] {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
[data-component="todo-preview"] {
|
||||
height: 560px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-component="todo-session-root"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
[data-component="todo-session-frame"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-component="todo-session-panel"] {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-content"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-scroll"] {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-spacer"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"] {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-weak);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"][data-strong="true"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
`
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const global = useServerSync()
|
||||
const prompt = usePrompt()
|
||||
const [cfg, setCfg] = createStore({
|
||||
open: true,
|
||||
collapsed: false,
|
||||
step: 1,
|
||||
dockOpenDuration: 0.3,
|
||||
dockOpenBounce: 0,
|
||||
dockCloseDuration: 0.3,
|
||||
dockCloseBounce: 0,
|
||||
drawerExpandDuration: 0.3,
|
||||
drawerExpandBounce: 0,
|
||||
drawerCollapseDuration: 0.3,
|
||||
drawerCollapseBounce: 0,
|
||||
subtitleDuration: 600,
|
||||
subtitleAuto: true,
|
||||
subtitleTravel: 25,
|
||||
subtitleEdge: 17,
|
||||
countDuration: 600,
|
||||
countMask: 18,
|
||||
countMaskHeight: 0,
|
||||
countWidthDuration: 560,
|
||||
})
|
||||
const open = () => cfg.open
|
||||
const step = () => cfg.step
|
||||
const dockOpenDuration = () => cfg.dockOpenDuration
|
||||
const dockOpenBounce = () => cfg.dockOpenBounce
|
||||
const dockCloseDuration = () => cfg.dockCloseDuration
|
||||
const dockCloseBounce = () => cfg.dockCloseBounce
|
||||
const drawerExpandDuration = () => cfg.drawerExpandDuration
|
||||
const drawerExpandBounce = () => cfg.drawerExpandBounce
|
||||
const drawerCollapseDuration = () => cfg.drawerCollapseDuration
|
||||
const drawerCollapseBounce = () => cfg.drawerCollapseBounce
|
||||
const subtitleDuration = () => cfg.subtitleDuration
|
||||
const subtitleAuto = () => cfg.subtitleAuto
|
||||
const subtitleTravel = () => cfg.subtitleTravel
|
||||
const subtitleEdge = () => cfg.subtitleEdge
|
||||
const countDuration = () => cfg.countDuration
|
||||
const countMask = () => cfg.countMask
|
||||
const countMaskHeight = () => cfg.countMaskHeight
|
||||
const countWidthDuration = () => cfg.countWidthDuration
|
||||
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||
let frame
|
||||
let scrollRef
|
||||
|
||||
const todos = createMemo<Todo[]>(() => {
|
||||
const done = Math.max(0, Math.min(3, step()))
|
||||
return pool.slice(0, 3).map((content, i) => ({
|
||||
id: `todo-${i + 1}`,
|
||||
content,
|
||||
status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending",
|
||||
}))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
global.todo.set("story-session", todos())
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
if (!scrollRef) return
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
|
||||
const collapsed = () => cfg.collapsed
|
||||
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
|
||||
const openDock = () => {
|
||||
clear()
|
||||
setCfg("open", true)
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
frame = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const closeDock = () => {
|
||||
clear()
|
||||
setCfg("open", false)
|
||||
}
|
||||
|
||||
const dockOpen = () => open()
|
||||
|
||||
const toggleDock = () => {
|
||||
if (dockOpen()) {
|
||||
closeDock()
|
||||
return
|
||||
}
|
||||
openDock()
|
||||
}
|
||||
|
||||
const toggleDrawer = () => {
|
||||
if (!dockOpen()) {
|
||||
openDock()
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
setCollapsed(true)
|
||||
frame = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setCollapsed(!collapsed())
|
||||
}
|
||||
|
||||
const cycle = () => {
|
||||
setCfg("step", (value) => (value + 1) % 4)
|
||||
}
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
return (
|
||||
<div data-component="todo-stage">
|
||||
<style>{css}</style>
|
||||
|
||||
<div data-component="todo-preview">
|
||||
<div data-component="todo-session-root">
|
||||
<div data-component="todo-session-frame">
|
||||
<div data-component="todo-session-panel">
|
||||
<div data-slot="todo-preview-content">
|
||||
<div data-slot="todo-preview-scroll" class="scroll-view__viewport" ref={scrollRef}>
|
||||
<div data-slot="todo-preview-spacer" />
|
||||
<div data-slot="todo-preview-msg" data-strong="true">
|
||||
Thinking Checking type safety
|
||||
</div>
|
||||
<div data-slot="todo-preview-msg">Shell Prints five topic blocks between timed commands</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SessionComposerRegion
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) },
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: pin,
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={controls}
|
||||
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={toggleDock} style={btn(dockOpen())}>
|
||||
{dockOpen() ? "Animate close" : "Animate open"}
|
||||
</button>
|
||||
<button onClick={toggleDrawer} style={btn(dockOpen() && collapsed())}>
|
||||
{dockOpen() && collapsed() ? "Expand todo dock" : "Collapse todo dock"}
|
||||
</button>
|
||||
<button onClick={cycle} style={btn(step() > 0)}>
|
||||
Cycle progress ({step()}/3 done)
|
||||
</button>
|
||||
{[0, 1, 2, 3].map((value) => (
|
||||
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
|
||||
{value} done
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)" }}>Dock open</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenDuration()}
|
||||
onInput={(event) => setCfg("dockOpenDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockOpenDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenBounce()}
|
||||
onInput={(event) => setCfg("dockOpenBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockOpenBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Dock close
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseDuration()}
|
||||
onInput={(event) => setCfg("dockCloseDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockCloseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseBounce()}
|
||||
onInput={(event) => setCfg("dockCloseBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockCloseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer expand
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandDuration()}
|
||||
onInput={(event) => setCfg("drawerExpandDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerExpandDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandBounce()}
|
||||
onInput={(event) => setCfg("drawerExpandBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerExpandBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer collapse
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseDuration()}
|
||||
onInput={(event) => setCfg("drawerCollapseDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerCollapseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseBounce()}
|
||||
onInput={(event) => setCfg("drawerCollapseBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerCollapseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Subtitle odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={subtitleDuration()}
|
||||
onInput={(event) => setCfg("subtitleDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(subtitleDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
auto fit
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitleAuto()}
|
||||
onInput={(event) => setCfg("subtitleAuto", event.currentTarget.checked)}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{subtitleAuto() ? "on" : "off"}
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
travel
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleTravel()}
|
||||
onInput={(event) => setCfg("subtitleTravel", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleTravel()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
edge
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleEdge()}
|
||||
onInput={(event) => setCfg("subtitleEdge", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleEdge()}%</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Count odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={countDuration()}
|
||||
onInput={(event) => setCfg("countDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="40"
|
||||
step="1"
|
||||
value={countMask()}
|
||||
onInput={(event) => setCfg("countMask", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMask()}%</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask height
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="14"
|
||||
step="1"
|
||||
value={countMaskHeight()}
|
||||
onInput={(event) => setCfg("countMaskHeight", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMaskHeight()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
width spring
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1200"
|
||||
step="10"
|
||||
value={countWidthDuration()}
|
||||
onInput={(event) => setCfg("countWidthDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countWidthDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -46,10 +46,12 @@
|
|||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2"
|
||||
"strip-ansi": "7.1.2",
|
||||
"uqr": "0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -3,27 +3,13 @@ import { Spec } from "../framework/spec"
|
|||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
const MiniParams = {
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
const ServerParams = {
|
||||
standalone: Flag.boolean("standalone").pipe(
|
||||
Flag.withDescription("Run with a private server instead of the background service"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session when continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
replay: Flag.boolean("replay").pipe(
|
||||
Flag.withDescription("Replay session history on resume and after resize"),
|
||||
Flag.withDefault(true),
|
||||
),
|
||||
replayLimit: Flag.integer("replay-limit").pipe(
|
||||
Flag.withDescription("Cap visible replay to the newest N messages"),
|
||||
server: Flag.string("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
}
|
||||
|
|
@ -31,18 +17,11 @@ const MiniParams = {
|
|||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
params: {
|
||||
...ServerParams,
|
||||
directory: Argument.string("directory").pipe(
|
||||
Argument.withDescription("Directory to start OpenCode in"),
|
||||
Argument.optional,
|
||||
),
|
||||
standalone: Flag.boolean("standalone").pipe(
|
||||
Flag.withDescription("Run with a private server instead of the background service"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
server: Flag.string("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
|
|
@ -58,6 +37,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
params: {
|
||||
...ServerParams,
|
||||
request: Argument.string("operation | method path").pipe(
|
||||
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
|
||||
Argument.variadic({ min: 1, max: 2 }),
|
||||
|
|
@ -75,6 +55,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
}),
|
||||
Spec.make("console", {
|
||||
description: "Manage OpenCode Console access",
|
||||
commands: [
|
||||
Spec.make("login", {
|
||||
description: "Log in to OpenCode Console",
|
||||
params: {
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("mcp", {
|
||||
description: "Manage MCP (Model Context Protocol) servers",
|
||||
commands: [
|
||||
|
|
@ -116,10 +107,28 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
...MiniParams,
|
||||
project: Argument.string("project").pipe(
|
||||
Argument.withDescription("Path to start OpenCode in"),
|
||||
Argument.optional,
|
||||
...ServerParams,
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session when continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
replay: Flag.boolean("replay").pipe(
|
||||
Flag.withDescription("Replay session history on resume and after resize"),
|
||||
Flag.withDefault(true),
|
||||
),
|
||||
replayLimit: Flag.integer("replay-limit").pipe(
|
||||
Flag.withDescription("Cap visible replay to the newest N messages"),
|
||||
Flag.optional,
|
||||
),
|
||||
model: Flag.string("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
|
|
@ -128,16 +137,13 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
),
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
server: Flag.string("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
},
|
||||
}),
|
||||
Spec.make("run", {
|
||||
description: "Run OpenCode with a message",
|
||||
params: {
|
||||
...ServerParams,
|
||||
message: Argument.string("message").pipe(
|
||||
Argument.withDescription("Message to send"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
|
|
@ -158,7 +164,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
),
|
||||
model: Flag.string("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Model to use in the format provider/model"),
|
||||
Flag.withDescription("Model to use in the format provider/model#variant"),
|
||||
Flag.optional,
|
||||
),
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
|
|
@ -172,12 +178,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
Flag.atMost(100),
|
||||
),
|
||||
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
server: Flag.string("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional),
|
||||
variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional),
|
||||
thinking: Flag.boolean("thinking").pipe(
|
||||
Flag.withDescription("Show thinking blocks"),
|
||||
Flag.withDefault(false),
|
||||
|
|
@ -187,10 +187,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
Flag.withDefault(false),
|
||||
),
|
||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
|
||||
Flag.withDefault(false),
|
||||
Flag.withHidden,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("service", {
|
||||
|
|
@ -214,6 +210,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("pair", { description: "Show server pairing information" }),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API server",
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
|
|||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ServiceConfig } from "../../services/service-config"
|
||||
import { Server } from "../../services/server"
|
||||
|
||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||
|
||||
|
|
@ -18,12 +18,15 @@ type OpenApi = {
|
|||
export default Runtime.handler(
|
||||
Commands.commands.api,
|
||||
Effect.fn("cli.api")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const server = yield* Server.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
mismatch: "ignore",
|
||||
})
|
||||
const endpoint = server.endpoint
|
||||
const params = Option.getOrElse(input.param, () => ({}))
|
||||
const request = yield* resolveRequest(transport, input.request, params)
|
||||
const headers = new Headers(transport.headers)
|
||||
const request = yield* resolveRequest(endpoint, input.request, params)
|
||||
const headers = new Headers(Service.headers(endpoint))
|
||||
for (const header of input.header) {
|
||||
const index = header.indexOf(":")
|
||||
if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`))
|
||||
|
|
@ -33,7 +36,7 @@ export default Runtime.handler(
|
|||
if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
|
||||
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL(request.path, transport.url), {
|
||||
fetch(new URL(request.path, endpoint.url), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
|
|
@ -60,7 +63,7 @@ export function rawRequest(input: readonly string[]) {
|
|||
}
|
||||
|
||||
function resolveRequest(
|
||||
transport: Service.Transport,
|
||||
endpoint: Service.Endpoint,
|
||||
input: readonly string[],
|
||||
params: Record<string, string>,
|
||||
) {
|
||||
|
|
@ -68,7 +71,7 @@ function resolveRequest(
|
|||
if (raw) return Effect.succeed(raw)
|
||||
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
|
||||
return Effect.tryPromise(async () => {
|
||||
const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers })
|
||||
const response = await fetch(new URL("/openapi.json", endpoint.url), { headers: Service.headers(endpoint) })
|
||||
if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
|
||||
return resolveOperation((await response.json()) as OpenApi, input[0], params)
|
||||
})
|
||||
|
|
|
|||
117
packages/cli/src/commands/handlers/console/login.ts
Normal file
117
packages/cli/src/commands/handlers/console/login.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { Cause, Effect, Exit, Option } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
|
||||
|
||||
const integrationID = "opencode"
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.console.commands.login,
|
||||
Effect.fn("cli.console.login")(function* (input) {
|
||||
const timeline = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => createTimelineHost()),
|
||||
(value) => request(() => value.close()).pipe(Effect.ignore),
|
||||
)
|
||||
const exit = yield* login(timeline, Option.getOrUndefined(input.url)).pipe(
|
||||
Effect.raceFirst(AppProcess.waitForAbort(timeline.signal)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(exit)) return
|
||||
|
||||
const cancelled = timeline.signal.aborted
|
||||
yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe(
|
||||
Effect.ignore,
|
||||
)
|
||||
process.exitCode = cancelled ? 130 : 1
|
||||
}),
|
||||
)
|
||||
|
||||
const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHost, server?: string) {
|
||||
yield* request(() => timeline.intro("Log in"))
|
||||
yield* request(() => timeline.pending("Connecting to OpenCode..."))
|
||||
|
||||
const endpoint = yield* Service.start(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal }))
|
||||
const integration = yield* required(found.data, "OpenCode Console integration is unavailable")
|
||||
const method = yield* required(
|
||||
integration.methods.find((candidate) => candidate.type === "oauth"),
|
||||
"OpenCode Console login is unavailable",
|
||||
)
|
||||
|
||||
yield* request(() => timeline.pending("Starting authorization..."))
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.connect.oauth(
|
||||
{
|
||||
integrationID,
|
||||
methodID: method.id,
|
||||
inputs: server ? { server } : {},
|
||||
location,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
const attempt = started.data
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.attempt.cancel(
|
||||
{ attemptID: attempt.attemptID, location },
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
if (attempt.mode !== "auto") yield* Effect.fail(new Error("OpenCode Console requires a device login"))
|
||||
|
||||
yield* request(() => timeline.item(`Go to: ${attempt.url}`))
|
||||
yield* request(() => timeline.item(attempt.instructions))
|
||||
yield* request(async () => {
|
||||
const { default: open } = await import("open")
|
||||
await open(attempt.url)
|
||||
}).pipe(Effect.ignore)
|
||||
yield* request(() => timeline.pending("Waiting for authorization..."))
|
||||
|
||||
const status = yield* waitForConsoleLogin(client, attempt.attemptID)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
if (status.status === "expired") yield* Effect.fail(new Error("Device code expired"))
|
||||
|
||||
yield* request(() => timeline.success("Connected to OpenCode Console"))
|
||||
yield* request(() => timeline.outro("Done"))
|
||||
})
|
||||
|
||||
const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
attemptID: string,
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.attempt.status({ attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
yield* Effect.sleep(500)
|
||||
}
|
||||
})
|
||||
|
||||
function request<A>(task: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({
|
||||
try: task,
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function required<A>(value: A | null | undefined, message: string) {
|
||||
return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
|
||||
}
|
||||
|
||||
function errorMessage(cause: Cause.Cause<unknown>) {
|
||||
const error = Cause.squash(cause)
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@ export default Runtime.handler(
|
|||
Effect.fn("cli.debug.agents")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
|
|
|
|||
|
|
@ -1,89 +1,50 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { run } from "@opencode-ai/tui"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Effect, Option, Redacted } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Env } from "../../env"
|
||||
import { ServiceConfig } from "../../services/service-config"
|
||||
import { Standalone } from "../../services/standalone"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Server } from "../../services/server"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = Option.getOrUndefined(input.directory)
|
||||
if (directory !== undefined) process.chdir(directory)
|
||||
const requestedDirectory = Option.getOrUndefined(input.directory)
|
||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
const server = Option.getOrUndefined(input.server)
|
||||
if (server !== undefined && input.standalone)
|
||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||
const transport = yield* Effect.gen(function* () {
|
||||
if (server !== undefined) {
|
||||
const password = yield* Env.password
|
||||
const explicit = {
|
||||
url: server,
|
||||
headers: password
|
||||
? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) }
|
||||
: undefined,
|
||||
} satisfies Service.Transport
|
||||
// Fail loudly before entering the TUI: an explicit server that is
|
||||
// unreachable or rejects auth should not present as reconnect churn.
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
|
||||
if (response.status === 401)
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
password
|
||||
? `Server at ${server} rejected the password`
|
||||
: `Server at ${server} requires a password; set OPENCODE_PASSWORD`,
|
||||
),
|
||||
)
|
||||
if (!response.ok)
|
||||
return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
|
||||
return explicit
|
||||
}
|
||||
if (input.standalone) return yield* Standalone.transport()
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
return found ?? (yield* Service.start(options))
|
||||
const server = yield* Server.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||
// The TUI re-runs discover whenever its event stream drops. For an explicit
|
||||
// --server or a standalone child the transport is fixed, so reconnects
|
||||
// retry the same address; for the managed service discovery re-reads the
|
||||
// registration and may start a replacement.
|
||||
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined
|
||||
// Only startup enforces the CLI version. A reconnect must accept a server
|
||||
// replaced by another client or the two clients will restart it forever.
|
||||
const reconnectOptions = serviceOptions ? { ...serviceOptions, version: undefined } : undefined
|
||||
const discover = reconnectOptions
|
||||
? () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const found = yield* Service.discover(reconnectOptions)
|
||||
return found ?? (yield* Service.start(reconnectOptions))
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
: () => Promise.resolve(transport)
|
||||
// Restart the managed service in place; start() resolves once the
|
||||
// replacement is healthy and the reconnect loop reattaches on its own.
|
||||
// Only meaningful in service mode: --server is not ours to restart and a
|
||||
// standalone child cannot be respawned.
|
||||
const reload = serviceOptions
|
||||
? () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* Service.stop(serviceOptions)
|
||||
yield* Service.start(serviceOptions)
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
: undefined
|
||||
yield* runTui(
|
||||
transport,
|
||||
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
discover,
|
||||
reload,
|
||||
)
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
let disposeSlots: (() => void) | undefined
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
yield* run({
|
||||
server,
|
||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
config,
|
||||
log: (level, message, tags) => {
|
||||
const effect =
|
||||
level === "debug"
|
||||
? Effect.logDebug(message, tags)
|
||||
: level === "warn"
|
||||
? Effect.logWarning(message, tags)
|
||||
: level === "error"
|
||||
? Effect.logError(message, tags)
|
||||
: Effect.logInfo(message, tags)
|
||||
runFork(effect)
|
||||
},
|
||||
pluginHost: {
|
||||
async start(pluginInput) {
|
||||
disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime)
|
||||
},
|
||||
async dispose() {
|
||||
disposeSlots?.()
|
||||
},
|
||||
},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ export default Runtime.handler(
|
|||
Effect.fn("cli.mcp.auth")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ export default Runtime.handler(
|
|||
Effect.fn("cli.mcp.list")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (servers.length === 0) {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ export default Runtime.handler(
|
|||
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const transport = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration) {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,17 @@
|
|||
import { Effect, Option, Redacted } from "effect"
|
||||
import path from "node:path"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Env } from "../../env"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Server } from "../../services/server"
|
||||
|
||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const { runMini } = yield* Effect.promise(() => import("../../mini"))
|
||||
const project = Option.getOrUndefined(input.project)
|
||||
const server = Option.getOrUndefined(input.server)
|
||||
const password = yield* Env.password
|
||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||
yield* Effect.promise(async () => validateMiniTerminal())
|
||||
const serverURL = Option.getOrUndefined(input.server)
|
||||
const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone })
|
||||
yield* Effect.promise(() =>
|
||||
runMini({
|
||||
attach: server,
|
||||
password: password ? Redacted.value(password) : undefined,
|
||||
directory:
|
||||
server !== undefined
|
||||
? project
|
||||
: project === undefined
|
||||
? process.cwd()
|
||||
: path.resolve(process.env.PWD ?? process.cwd(), project),
|
||||
server,
|
||||
continue: input.continue,
|
||||
session: Option.getOrUndefined(input.session),
|
||||
fork: input.fork,
|
||||
|
|
|
|||
43
packages/cli/src/commands/handlers/pair.ts
Normal file
43
packages/cli/src/commands/handlers/pair.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServiceConfig } from "../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.pair,
|
||||
Effect.fn("cli.pair")(function* () {
|
||||
const endpoint = yield* Service.start(yield* ServiceConfig.options())
|
||||
const password = yield* ServiceConfig.password()
|
||||
const server = yield* Effect.tryPromise(() =>
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(),
|
||||
)
|
||||
const info = { urls: server.urls, username: "opencode", password }
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
` URLs ${info.urls[0] ?? "(none)"}`,
|
||||
...info.urls.slice(1).map((url) => ` ${url}`),
|
||||
` Username ${info.username}`,
|
||||
` Password ${info.password}`,
|
||||
"",
|
||||
" Scan to pair",
|
||||
"",
|
||||
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
|
||||
.split(EOL)
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
"",
|
||||
].join(EOL) + EOL,
|
||||
)
|
||||
|
||||
const hostname = new URL(endpoint.url).hostname
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
|
||||
process.stderr.write(
|
||||
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
import { Effect, Option, Redacted } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Env } from "../../env"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Server } from "../../services/server"
|
||||
|
||||
export default Runtime.handler(Commands.commands.run, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
||||
const password = yield* Env.password
|
||||
const separator = process.argv.indexOf("--", 2)
|
||||
const server = yield* Server.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
runNonInteractive({
|
||||
server,
|
||||
message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
|
||||
continue: input.continue,
|
||||
session: Option.getOrUndefined(input.session),
|
||||
|
|
@ -19,12 +23,8 @@ export default Runtime.handler(Commands.commands.run, (input) =>
|
|||
format: input.format,
|
||||
file: [...input.file],
|
||||
title: Option.getOrUndefined(input.title),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
password: password ? Redacted.value(password) : undefined,
|
||||
directory: Option.getOrUndefined(input.dir),
|
||||
variant: Option.getOrUndefined(input.variant),
|
||||
thinking: input.thinking,
|
||||
dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
||||
auto: input.auto || input.yolo,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Effect } from "effect"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
|
||||
export type SharedOptions = {
|
||||
readonly mode: "shared"
|
||||
readonly command?: ReadonlyArray<string>
|
||||
}
|
||||
export type AttachOptions = {
|
||||
readonly mode: "attach"
|
||||
readonly url: string
|
||||
readonly username?: string
|
||||
readonly password?: string
|
||||
}
|
||||
export type Options = SharedOptions | AttachOptions
|
||||
|
||||
const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) {
|
||||
const transport = {
|
||||
url: options.url,
|
||||
headers:
|
||||
options.password === undefined
|
||||
? undefined
|
||||
: ServerAuth.headers({ password: options.password, username: options.username }),
|
||||
} satisfies Service.Transport
|
||||
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
|
||||
const health = yield* Effect.tryPromise({
|
||||
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||
catch: (cause) => attachError(options, cause),
|
||||
})
|
||||
if (health.version !== InstallationVersion)
|
||||
process.stderr.write(
|
||||
`Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
||||
)
|
||||
return transport
|
||||
})
|
||||
|
||||
const shared = Effect.fn("cli.daemon.shared")(function* (options: SharedOptions) {
|
||||
const config = yield* ServiceConfig.options()
|
||||
const service = options.command === undefined ? config : { ...config, command: options.command }
|
||||
const found = yield* Service.discover(service)
|
||||
if (found) return found
|
||||
return yield* Service.start(service)
|
||||
})
|
||||
|
||||
export function transport(options: AttachOptions): ReturnType<typeof attach>
|
||||
export function transport(options: SharedOptions): ReturnType<typeof shared>
|
||||
export function transport(options: Options): ReturnType<typeof attach> | ReturnType<typeof shared>
|
||||
export function transport(options: Options) {
|
||||
if (options.mode === "attach") return attach(options)
|
||||
return shared(options)
|
||||
}
|
||||
|
||||
function attachError(options: AttachOptions, cause: unknown) {
|
||||
if (isUnauthorizedError(cause)) {
|
||||
return new Error(
|
||||
options.password === undefined
|
||||
? `Server at ${options.url} requires authentication; provide a password`
|
||||
: `Server at ${options.url} rejected the supplied credentials`,
|
||||
{ cause },
|
||||
)
|
||||
}
|
||||
if (cause instanceof ClientError && cause.reason === "Transport")
|
||||
return new Error(`Could not reach server at ${options.url}`, { cause })
|
||||
return new Error(`Server at ${options.url} did not provide a compatible V2 health response`, { cause })
|
||||
}
|
||||
|
||||
export * as Daemon from "./daemon"
|
||||
|
|
@ -24,6 +24,9 @@ const Handlers = Runtime.handlers(Commands, {
|
|||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
console: {
|
||||
login: () => import("./commands/handlers/console/login"),
|
||||
},
|
||||
mcp: {
|
||||
list: () => import("./commands/handlers/mcp/list"),
|
||||
add: () => import("./commands/handlers/mcp/add"),
|
||||
|
|
@ -33,6 +36,7 @@ const Handlers = Runtime.handlers(Commands, {
|
|||
migrate: () => import("./commands/handlers/migrate"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
pair: () => import("./commands/handlers/pair"),
|
||||
service: {
|
||||
start: () => import("./commands/handlers/service/start"),
|
||||
restart: () => import("./commands/handlers/service/restart"),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
// /permission [kind] → triggers a permission request variant
|
||||
// /question [kind] → triggers a question request variant
|
||||
// /fmt <kind> → emits a specific tool/text type (text, reasoning, bash,
|
||||
// write, edit, patch, task, todo, question, error, mix)
|
||||
// write, edit, patch, task, question, error, mix)
|
||||
//
|
||||
// Demo mode also handles permission and question replies locally, completing
|
||||
// or failing the synthetic tool parts as appropriate.
|
||||
|
|
@ -30,7 +30,6 @@ const KINDS = [
|
|||
"edit",
|
||||
"patch",
|
||||
"task",
|
||||
"todo",
|
||||
"question",
|
||||
"error",
|
||||
"mix",
|
||||
|
|
@ -733,30 +732,6 @@ function emitTask(state: State): void {
|
|||
})
|
||||
}
|
||||
|
||||
function emitTodo(state: State): void {
|
||||
const ref = make(state, "todowrite", {
|
||||
todos: [
|
||||
{
|
||||
content: "Trigger permission UI",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
content: "Trigger question UI",
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
content: "Tune tool formatting",
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
})
|
||||
doneTool(state, ref, {
|
||||
title: "todowrite",
|
||||
output: "",
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
function emitQuestionTool(state: State): void {
|
||||
const ref = make(state, "question", {
|
||||
questions: [
|
||||
|
|
@ -952,7 +927,6 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
|
|||
options: [
|
||||
{ label: "Diff", description: "Show an edit diff in the footer" },
|
||||
{ label: "Task", description: "Show a structured task summary" },
|
||||
{ label: "Todo", description: "Show a todo snapshot" },
|
||||
{ label: "Error", description: "Show an error transcript row" },
|
||||
],
|
||||
multiple: true,
|
||||
|
|
@ -992,7 +966,6 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
|
|||
options: [
|
||||
{ label: "Diff", description: "Emit edit diff" },
|
||||
{ label: "Task", description: "Emit task card" },
|
||||
{ label: "Todo", description: "Emit todo card" },
|
||||
],
|
||||
multiple: true,
|
||||
custom: true,
|
||||
|
|
@ -1066,11 +1039,6 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
|
|||
return true
|
||||
}
|
||||
|
||||
if (kind === "todo") {
|
||||
emitTodo(state)
|
||||
return true
|
||||
}
|
||||
|
||||
if (kind === "question") {
|
||||
emitQuestionTool(state)
|
||||
return true
|
||||
|
|
@ -1091,7 +1059,6 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
|
|||
emitEdit(state)
|
||||
emitPatch(state)
|
||||
emitTask(state)
|
||||
emitTodo(state)
|
||||
emitQuestionTool(state)
|
||||
emitError(state, "demo mixed scenario error")
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
||||
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
||||
export {
|
||||
runNonInteractive,
|
||||
mergeInput as mergeNonInteractiveInput,
|
||||
pickRunModel,
|
||||
parseRunModel,
|
||||
type RunCommandInput,
|
||||
} from "./run"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,12 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { Daemon } from "../daemon"
|
||||
import { Server } from "../services/server"
|
||||
import { waitForCatalogReady } from "./catalog.shared"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
||||
import type { RunInput, RunTuiConfig } from "./types"
|
||||
|
||||
export type MiniCommandInput = {
|
||||
directory?: string
|
||||
attach?: string
|
||||
password?: string
|
||||
username?: string
|
||||
server: Server.Resolved
|
||||
continue?: boolean
|
||||
session?: string
|
||||
fork?: boolean
|
||||
|
|
@ -22,58 +16,46 @@ export type MiniCommandInput = {
|
|||
replay?: boolean
|
||||
replayLimit?: number
|
||||
demo?: boolean
|
||||
serverCommand?: ReadonlyArray<string>
|
||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
||||
}
|
||||
|
||||
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
||||
type Transport = { readonly url: string; readonly headers?: HeadersInit }
|
||||
|
||||
export async function runMini(input: MiniCommandInput) {
|
||||
validate(input)
|
||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
|
||||
const runtimeTask = import("./runtime")
|
||||
const directory = input.attach ? input.directory : localDirectory(input.directory)
|
||||
const transportTask = startTransport(input)
|
||||
void transportTask.catch(() => {})
|
||||
const directory = localDirectory()
|
||||
|
||||
try {
|
||||
if (input.attach) await transportTask
|
||||
const sdk = OpenCode.make({
|
||||
baseUrl: "http://opencode.pending",
|
||||
fetch: deferredFetch(transportTask),
|
||||
baseUrl: input.server.endpoint.url,
|
||||
headers: Service.headers(input.server.endpoint),
|
||||
})
|
||||
const attachedSession =
|
||||
input.attach && input.session && !input.directory
|
||||
? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found"))
|
||||
: undefined
|
||||
const resolvedDirectory =
|
||||
directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk))
|
||||
const model = parseModel(input.model)
|
||||
let agentTask: Promise<string | undefined> | undefined
|
||||
const resolveAgent = () => {
|
||||
agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach)
|
||||
agentTask ??= validateAgent(sdk, directory, input.agent)
|
||||
return agentTask
|
||||
}
|
||||
const resolveSession = async () => {
|
||||
const [agent, selected] = await Promise.all([
|
||||
resolveAgent(),
|
||||
selectSession(sdk, resolvedDirectory, input, attachedSession),
|
||||
selectSession(sdk, directory, input),
|
||||
])
|
||||
const readyModel =
|
||||
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
||||
if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel })
|
||||
const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model))
|
||||
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
|
||||
const session = selected ?? (await createSession(sdk, directory, agent, model))
|
||||
return { id: session.id, title: session.title, resume: selected !== undefined }
|
||||
}
|
||||
const create = (
|
||||
_sdk: OpenCodeClient,
|
||||
next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
|
||||
) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant)
|
||||
) => createSession(sdk, directory, next.agent, next.model, next.variant)
|
||||
const runtime = await runtimeTask
|
||||
await runtime.runInteractiveDeferredMode({
|
||||
sdk,
|
||||
directory: resolvedDirectory,
|
||||
directory,
|
||||
resolveAgent,
|
||||
session: resolveSession,
|
||||
createSession: create,
|
||||
|
|
@ -94,6 +76,10 @@ export async function runMini(input: MiniCommandInput) {
|
|||
}
|
||||
}
|
||||
|
||||
export function validateMiniTerminal() {
|
||||
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
|
||||
if (!prompt) return piped || undefined
|
||||
|
|
@ -102,7 +88,7 @@ export function mergeInput(piped: string | undefined, prompt: string | undefined
|
|||
}
|
||||
|
||||
function validate(input: MiniCommandInput) {
|
||||
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
|
||||
validateMiniTerminal()
|
||||
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
|
||||
fail("--replay-limit must be a positive integer")
|
||||
}
|
||||
|
|
@ -110,56 +96,16 @@ function validate(input: MiniCommandInput) {
|
|||
resolveInteractiveStdin().cleanup?.()
|
||||
}
|
||||
|
||||
function localDirectory(directory?: string): string {
|
||||
function localDirectory(): string {
|
||||
const root = process.env.PWD ?? process.cwd()
|
||||
try {
|
||||
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
|
||||
process.chdir(root)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
fail(`Failed to change directory to ${directory}`)
|
||||
fail(`Failed to change directory to ${root}`)
|
||||
}
|
||||
}
|
||||
|
||||
function startTransport(input: MiniCommandInput): Promise<Transport> {
|
||||
if (input.attach) {
|
||||
return Effect.runPromise(
|
||||
Daemon.transport({
|
||||
mode: "attach",
|
||||
url: input.attach,
|
||||
password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
|
||||
username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.runPromise(
|
||||
Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
Effect.provide(Global.layerWith({})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch {
|
||||
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const transport = await transportTask
|
||||
const request = new Request(input, init)
|
||||
const source = new URL(request.url)
|
||||
const headers = new Headers(request.headers)
|
||||
for (const [key, value] of new Headers(transport.headers)) headers.set(key, value)
|
||||
return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers })
|
||||
}
|
||||
return fetch as typeof globalThis.fetch
|
||||
}
|
||||
|
||||
async function remoteDirectory(
|
||||
transport: { url: string; headers?: HeadersInit },
|
||||
sdk: OpenCodeClient,
|
||||
): Promise<string> {
|
||||
const location = await sdk.location.get()
|
||||
if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`)
|
||||
return location.directory
|
||||
}
|
||||
|
||||
function parseModel(value?: string): RunInput["model"] {
|
||||
if (!value) return
|
||||
const [providerID, ...rest] = value.split("/")
|
||||
|
|
@ -168,7 +114,7 @@ function parseModel(value?: string): RunInput["model"] {
|
|||
return { providerID, modelID }
|
||||
}
|
||||
|
||||
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) {
|
||||
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
|
||||
if (!name) return
|
||||
const deadline = Date.now() + 5_000
|
||||
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
||||
|
|
@ -183,7 +129,7 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
|
|||
await Bun.sleep(25)
|
||||
}
|
||||
if (!agents) {
|
||||
warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`)
|
||||
warning("failed to list agents. Falling back to default agent")
|
||||
return
|
||||
}
|
||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ type Input = {
|
|||
variant?: string
|
||||
thinking: boolean
|
||||
format: "default" | "json"
|
||||
dangerouslySkipPermissions: boolean
|
||||
auto: boolean
|
||||
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
||||
attached: boolean
|
||||
renderTool: (part: ToolPart) => Promise<void>
|
||||
|
|
@ -91,7 +91,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
}
|
||||
|
||||
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
|
||||
if (!input.dangerouslySkipPermissions) {
|
||||
if (!input.auto) {
|
||||
permissionRejected = true
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
|
|
@ -103,10 +103,10 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: request.id,
|
||||
reply: input.dangerouslySkipPermissions ? "once" : "reject",
|
||||
reply: input.auto ? "once" : "reject",
|
||||
})
|
||||
.catch(() => {})
|
||||
if (!input.dangerouslySkipPermissions) {
|
||||
if (!input.auto) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
|
@ -153,7 +153,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
||||
const time = toMillis("created" in event ? event.created : undefined)
|
||||
|
||||
if (event.type === "session.prompt.promoted") {
|
||||
if (event.type === "session.input.promoted") {
|
||||
if (event.data.inputID === messageID) {
|
||||
promoted = true
|
||||
continue
|
||||
|
|
@ -409,10 +409,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
prompt: {
|
||||
text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
|
||||
files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
|
||||
},
|
||||
text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
|
||||
files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal: admission.signal },
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Daemon } from "../daemon"
|
||||
import { Standalone } from "../services/standalone"
|
||||
import { Server } from "../services/server"
|
||||
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
||||
import { runNonInteractivePrompt } from "./noninteractive"
|
||||
import { toolInlineInfo } from "./tool"
|
||||
import { UI } from "./ui"
|
||||
|
||||
export type RunCommandInput = {
|
||||
server: Server.Resolved
|
||||
message: string[]
|
||||
continue?: boolean
|
||||
session?: string
|
||||
|
|
@ -23,14 +22,8 @@ export type RunCommandInput = {
|
|||
format: "default" | "json"
|
||||
file: string[]
|
||||
title?: string
|
||||
server?: string
|
||||
password?: string
|
||||
username?: string
|
||||
directory?: string
|
||||
variant?: string
|
||||
thinking?: boolean
|
||||
dangerouslySkipPermissions?: boolean
|
||||
standaloneCommand?: ReadonlyArray<string>
|
||||
auto?: boolean
|
||||
}
|
||||
|
||||
type FilePart = {
|
||||
|
|
@ -39,8 +32,6 @@ type FilePart = {
|
|||
mime: string
|
||||
}
|
||||
|
||||
type Transport = { readonly url: string; readonly headers?: HeadersInit }
|
||||
|
||||
type Prepared = {
|
||||
directory?: string
|
||||
message: string
|
||||
|
|
@ -56,34 +47,24 @@ export function runNonInteractive(input: RunCommandInput) {
|
|||
async function run(input: RunCommandInput) {
|
||||
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||
const root = process.env.PWD ?? process.cwd()
|
||||
const directory = input.server ? input.directory : localDirectory(input.directory, root)
|
||||
const directory = localDirectory(root)
|
||||
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
|
||||
if (!message?.trim()) fail("You must provide a message")
|
||||
const files = await Promise.all(
|
||||
input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)),
|
||||
)
|
||||
const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
|
||||
const prepared = { directory, message, files }
|
||||
if (input.standaloneCommand)
|
||||
return Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* Standalone.transport({ command: input.standaloneCommand })
|
||||
yield* Effect.promise(() => execute(input, prepared, transport))
|
||||
}),
|
||||
),
|
||||
)
|
||||
const transport = await startTransport(input)
|
||||
return execute(input, prepared, transport)
|
||||
return execute(input, prepared, input.server.endpoint)
|
||||
}
|
||||
|
||||
async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) {
|
||||
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
|
||||
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) {
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
||||
if (!requestedDirectory) fail("Failed to resolve server directory")
|
||||
const session = await selectSession(client, requestedDirectory, input)
|
||||
const cwd = session?.location.directory ?? requestedDirectory
|
||||
const workspace = session?.location.workspaceID
|
||||
const explicitModel = parseModel(input.model)
|
||||
const explicit = parseRunModel(input.model)
|
||||
const explicitModel = explicit?.model
|
||||
const variant = explicit?.variant
|
||||
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
|
||||
const defaultModel =
|
||||
!explicitModel && !sessionModel
|
||||
|
|
@ -91,8 +72,8 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
|
|||
.default({ location: { directory: cwd, workspace } })
|
||||
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
||||
: undefined
|
||||
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
|
||||
if (input.variant && !model)
|
||||
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
|
||||
if (variant && !model)
|
||||
return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||
if (model) {
|
||||
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
||||
|
|
@ -100,12 +81,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
|
|||
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
|
||||
return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
||||
}
|
||||
const agent = await validateAgent(client, cwd, input.agent, input.server)
|
||||
const agent = await validateAgent(client, cwd, input.agent)
|
||||
const selected =
|
||||
session ??
|
||||
(await client.session.create({
|
||||
agent,
|
||||
model: model ? { providerID: model.providerID, id: model.modelID, variant: input.variant } : undefined,
|
||||
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
|
||||
location: { directory: cwd },
|
||||
}))
|
||||
if (!session && input.title !== undefined) {
|
||||
|
|
@ -122,11 +103,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
|
|||
files: prepared.files,
|
||||
agent,
|
||||
model,
|
||||
variant: input.variant,
|
||||
variant,
|
||||
thinking: input.thinking ?? false,
|
||||
format: input.format,
|
||||
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
|
||||
attached: !input.standaloneCommand,
|
||||
auto: input.auto ?? false,
|
||||
attached: true,
|
||||
renderTool,
|
||||
renderToolError,
|
||||
}).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
|
||||
|
|
@ -154,47 +135,29 @@ function formatMessage(message: string[]) {
|
|||
return value || undefined
|
||||
}
|
||||
|
||||
function localDirectory(directory: string | undefined, root: string) {
|
||||
function localDirectory(root: string) {
|
||||
try {
|
||||
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
|
||||
process.chdir(root)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
fail(`Failed to change directory to ${directory}`)
|
||||
fail(`Failed to change directory to ${root}`)
|
||||
}
|
||||
}
|
||||
|
||||
function startTransport(input: RunCommandInput) {
|
||||
if (input.server) {
|
||||
return Effect.runPromise(
|
||||
Daemon.transport({
|
||||
mode: "attach",
|
||||
url: input.server,
|
||||
password: input.password,
|
||||
username: input.username,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.runPromise(
|
||||
Daemon.transport({ mode: "shared" }).pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
Effect.provide(Global.layerWith({})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function parseModel(value?: string) {
|
||||
export function parseRunModel(value?: string) {
|
||||
if (!value) return
|
||||
const [providerID, ...rest] = value.split("/")
|
||||
const modelID = rest.join("/")
|
||||
if (!providerID || !modelID) fail("--model must use the format provider/model")
|
||||
return { providerID, modelID }
|
||||
const ref = Model.Ref.parse(value)
|
||||
return {
|
||||
model: { providerID: ref.providerID, modelID: ref.id },
|
||||
variant: ref.variant,
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) {
|
||||
async function validateAgent(client: OpenCodeClient, directory: string, name?: string) {
|
||||
if (!name) return
|
||||
const agents = await loadRunAgents(client, directory).catch(() => undefined)
|
||||
if (!agents) {
|
||||
warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
|
||||
warning("failed to list agents. Falling back to default agent")
|
||||
return
|
||||
}
|
||||
const agent = agents.find((item) => item.id === name)
|
||||
|
|
@ -222,12 +185,11 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R
|
|||
return client.session.fork({ sessionID: selected.id })
|
||||
}
|
||||
|
||||
async function prepareFile(input: string, directory: string, remote: boolean): Promise<FilePart> {
|
||||
async function prepareFile(input: string, directory: string): Promise<FilePart> {
|
||||
const file = path.resolve(directory, input)
|
||||
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
||||
try {
|
||||
const stat = await handle.stat()
|
||||
if (remote && stat.isDirectory()) fail(`Cannot attach local directory without a shared filesystem: ${input}`)
|
||||
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
|
||||
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
|
||||
const content = Buffer.alloc(Number(stat.size))
|
||||
|
|
|
|||
|
|
@ -7,26 +7,6 @@ import { toolFiletype, toolStructuredFinal } from "./tool"
|
|||
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
||||
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
|
||||
|
||||
function todoText(item: { status: string; content: string }): string {
|
||||
if (item.status === "completed") {
|
||||
return `[✓] ${item.content}`
|
||||
}
|
||||
|
||||
if (item.status === "cancelled") {
|
||||
return `~[ ] ${item.content}~`
|
||||
}
|
||||
|
||||
if (item.status === "in_progress") {
|
||||
return `[•] ${item.content}`
|
||||
}
|
||||
|
||||
return `[ ] ${item.content}`
|
||||
}
|
||||
|
||||
function todoColor(theme: RunTheme, status: string) {
|
||||
return status === "in_progress" ? theme.block.warning : theme.block.muted
|
||||
}
|
||||
|
||||
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||
if (!commit.partID) {
|
||||
return undefined
|
||||
|
|
@ -137,10 +117,6 @@ export function RunEntryContent(props: {
|
|||
const next = structured()
|
||||
return next?.kind === "task" ? next : undefined
|
||||
})
|
||||
const todo_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "todo" ? next : undefined
|
||||
})
|
||||
const question_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "question" ? next : undefined
|
||||
|
|
@ -242,25 +218,6 @@ export function RunEntryContent(props: {
|
|||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={todo_snapshot()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
# Todos
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
{todo_snapshot()!.items.map((item) => (
|
||||
<text width="100%" wrapMode="word" fg={todoColor(theme(), item.status)}>
|
||||
{todoText(item)}
|
||||
</text>
|
||||
))}
|
||||
{todo_snapshot()!.tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{todo_snapshot()!.tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={question_snapshot()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
|
|
|
|||
|
|
@ -457,12 +457,13 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
}
|
||||
|
||||
const reduce = (child: ChildState, event: V2Event) => {
|
||||
if (event.type === "session.prompt.admitted") {
|
||||
child.prompts.set(event.data.inputID, event.data.prompt.text)
|
||||
if (event.type === "session.input.admitted") {
|
||||
if (event.data.input.type === "user") child.prompts.set(event.data.inputID, event.data.input.data.text)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.prompt.promoted") {
|
||||
const prompt = child.prompts.get(event.data.inputID) ?? ""
|
||||
if (event.type === "session.input.promoted") {
|
||||
const prompt = child.prompts.get(event.data.inputID)
|
||||
if (prompt === undefined) return
|
||||
child.prompts.delete(event.data.inputID)
|
||||
if (userFrame(child, event.data.inputID, prompt)) {
|
||||
touch(child, event.created)
|
||||
|
|
|
|||
|
|
@ -562,7 +562,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
input.trace?.write("recv.event", event)
|
||||
subagents.main(event)
|
||||
if (event.type === "session.prompt.promoted") {
|
||||
if (event.type === "session.input.promoted") {
|
||||
if (state.wait?.messageID === event.data.inputID) state.wait.promoted = true
|
||||
state.messageIDs.add(event.data.inputID)
|
||||
write([], { phase: "running", status: "waiting for assistant" })
|
||||
|
|
@ -1052,11 +1052,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
prompt: {
|
||||
text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
|
||||
files: attachments.length ? attachments : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
},
|
||||
text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
|
||||
files: attachments.length ? attachments : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal: next.signal },
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ type ToolInput = ToolDict & {
|
|||
content?: string
|
||||
command?: string
|
||||
workdir?: string
|
||||
todos?: Array<{ status?: string; content?: string }>
|
||||
questions?: Array<{ question?: string }>
|
||||
diff?: string
|
||||
}
|
||||
|
|
@ -122,7 +121,6 @@ type ToolName =
|
|||
| "patch"
|
||||
| "batch"
|
||||
| "task"
|
||||
| "todowrite"
|
||||
| "question"
|
||||
| "read"
|
||||
| "glob"
|
||||
|
|
@ -398,25 +396,6 @@ function runTask(p: ToolProps): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runTodo(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "#",
|
||||
title: "Todos",
|
||||
mode: "block",
|
||||
body: list<{ status?: string; content?: string }>(p.frame.input.todos)
|
||||
.flatMap((item) => {
|
||||
const body = typeof item?.content === "string" ? item.content : ""
|
||||
if (!body) {
|
||||
return []
|
||||
}
|
||||
|
||||
const mark = item.status === "completed" ? "[✓]" : item.status === "in_progress" ? "[•]" : "[ ]"
|
||||
return [`${mark} ${body}`]
|
||||
})
|
||||
.join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
function runSkill(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "→",
|
||||
|
|
@ -604,28 +583,6 @@ function snapTask(p: ToolProps): ToolSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
function snapTodo(p: ToolProps): ToolSnapshot {
|
||||
const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => {
|
||||
const content = typeof item?.content === "string" ? item.content : ""
|
||||
if (!content) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
status: typeof item.status === "string" ? item.status : "",
|
||||
content,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
kind: "todo",
|
||||
items,
|
||||
tail: "",
|
||||
}
|
||||
}
|
||||
|
||||
function snapQuestion(p: ToolProps): ToolSnapshot {
|
||||
const answers = list<unknown[]>(p.frame.meta.answers)
|
||||
const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
|
||||
|
|
@ -814,42 +771,6 @@ function scrollTaskFinal(p: ToolProps): string {
|
|||
return `# ${kind} Task\n${row}`
|
||||
}
|
||||
|
||||
function scrollTodoStart(_: ToolProps): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
function scrollTodoFinal(p: ToolProps): string {
|
||||
const items = list<{ status?: string }>(p.input.todos)
|
||||
const time = span(p.frame.state)
|
||||
if (items.length === 0) {
|
||||
if (!time) {
|
||||
return "0 todos"
|
||||
}
|
||||
|
||||
return `0 todos · ${time}`
|
||||
}
|
||||
|
||||
const doneN = items.filter((item) => item.status === "completed").length
|
||||
const runN = items.filter((item) => item.status === "in_progress").length
|
||||
const left = items.length - doneN - runN
|
||||
const tail = [`${items.length} total`]
|
||||
if (doneN > 0) {
|
||||
tail.push(`${doneN} done`)
|
||||
}
|
||||
if (runN > 0) {
|
||||
tail.push(`${runN} active`)
|
||||
}
|
||||
if (left > 0) {
|
||||
tail.push(`${left} pending`)
|
||||
}
|
||||
|
||||
if (time) {
|
||||
tail.push(time)
|
||||
}
|
||||
|
||||
return tail.join(" · ")
|
||||
}
|
||||
|
||||
function scrollQuestionStart(_: ToolProps): string {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1131,19 +1052,6 @@ const TOOL_RULES = {
|
|||
},
|
||||
permission: permTask,
|
||||
},
|
||||
todowrite: {
|
||||
view: {
|
||||
output: false,
|
||||
final: true,
|
||||
snap: "structured",
|
||||
},
|
||||
run: runTodo,
|
||||
snap: snapTodo,
|
||||
scroll: {
|
||||
start: scrollTodoStart,
|
||||
final: scrollTodoFinal,
|
||||
},
|
||||
},
|
||||
question: {
|
||||
view: {
|
||||
output: false,
|
||||
|
|
|
|||
|
|
@ -199,15 +199,6 @@ export type ToolTaskSnapshot = {
|
|||
tail: string
|
||||
}
|
||||
|
||||
export type ToolTodoSnapshot = {
|
||||
kind: "todo"
|
||||
items: Array<{
|
||||
status: string
|
||||
content: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolQuestionSnapshot = {
|
||||
kind: "question"
|
||||
items: Array<{
|
||||
|
|
@ -217,12 +208,7 @@ export type ToolQuestionSnapshot = {
|
|||
tail: string
|
||||
}
|
||||
|
||||
export type ToolSnapshot =
|
||||
| ToolCodeSnapshot
|
||||
| ToolDiffSnapshot
|
||||
| ToolTaskSnapshot
|
||||
| ToolTodoSnapshot
|
||||
| ToolQuestionSnapshot
|
||||
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
|
||||
|
||||
export type EntryLayout = "inline" | "block"
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
|||
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
|
||||
port: Option.fromNullishOr(options.port ?? config.port),
|
||||
password,
|
||||
restartContinuity: options.mode === "service",
|
||||
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
|
||||
if (lockScope !== undefined) {
|
||||
yield* register(address, password)
|
||||
|
|
|
|||
100
packages/cli/src/services/server.ts
Normal file
100
packages/cli/src/services/server.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Env } from "../env"
|
||||
import { ServiceConfig } from "./service-config"
|
||||
import { Standalone } from "./standalone"
|
||||
|
||||
export type Args = {
|
||||
readonly server?: string
|
||||
readonly standalone?: boolean
|
||||
readonly mismatch?: "replace" | "ignore" | "error"
|
||||
}
|
||||
|
||||
export type Resolved = {
|
||||
readonly endpoint: Service.Endpoint
|
||||
readonly reconnect?: (attempt: number) => Promise<Service.Endpoint>
|
||||
readonly reload?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
|
||||
if (args.server !== undefined && args.standalone)
|
||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||
if (args.server !== undefined) {
|
||||
const password = yield* Env.password
|
||||
const endpoint = {
|
||||
url: args.server,
|
||||
auth: password
|
||||
? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
|
||||
: undefined,
|
||||
} satisfies Service.Endpoint
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const health = yield* Effect.tryPromise({
|
||||
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||
catch: (cause) => connectError(endpoint, cause),
|
||||
})
|
||||
if (health.version !== InstallationVersion)
|
||||
process.stderr.write(
|
||||
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
||||
)
|
||||
return { endpoint } satisfies Resolved
|
||||
}
|
||||
if (args.standalone) {
|
||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||
}
|
||||
|
||||
const options = yield* ServiceConfig.options()
|
||||
const endpoint = yield* resolveManaged(options, args.mismatch ?? "replace")
|
||||
const reconnectOptions = { ...options, version: undefined }
|
||||
return {
|
||||
endpoint,
|
||||
reconnect: (attempt) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
if (attempt > 3) return yield* Service.start(reconnectOptions)
|
||||
const endpoint = yield* Service.discover(reconnectOptions)
|
||||
if (endpoint !== undefined) return endpoint
|
||||
return yield* Effect.fail(new Error("Background server is unavailable"))
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
),
|
||||
reload: () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* Service.stop(options)
|
||||
yield* Service.start(options)
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
),
|
||||
} satisfies Resolved
|
||||
})
|
||||
|
||||
const resolveManaged = Effect.fnUntraced(function* (
|
||||
options: Service.Options,
|
||||
mismatch: NonNullable<Args["mismatch"]>,
|
||||
) {
|
||||
if (mismatch === "replace") return yield* Service.start(options)
|
||||
if (mismatch === "ignore") return yield* Service.start({ ...options, version: undefined })
|
||||
|
||||
const compatible = yield* Service.discover(options)
|
||||
if (compatible !== undefined) return compatible
|
||||
const existing = yield* Service.discover({ ...options, version: undefined })
|
||||
if (existing !== undefined) return yield* Effect.fail(new Error("Background server version does not match this client"))
|
||||
return yield* Service.start(options)
|
||||
})
|
||||
|
||||
function connectError(endpoint: Service.Endpoint, cause: unknown) {
|
||||
if (isUnauthorizedError(cause)) {
|
||||
return new Error(
|
||||
endpoint.auth === undefined
|
||||
? `Server at ${endpoint.url} requires a password; set OPENCODE_PASSWORD`
|
||||
: `Server at ${endpoint.url} rejected the password`,
|
||||
{ cause },
|
||||
)
|
||||
}
|
||||
if (cause instanceof ClientError && cause.reason === "Transport")
|
||||
return new Error(`Could not reach server at ${endpoint.url}`, { cause })
|
||||
return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause })
|
||||
}
|
||||
|
||||
export * as Server from "./server"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
|
|
@ -34,7 +34,7 @@ function command(password: string, options: Options) {
|
|||
})
|
||||
}
|
||||
|
||||
const makeTransport = Effect.fn("cli.standalone.transport")(
|
||||
const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
||||
function* (options: Options) {
|
||||
const password = randomBytes(32).toString("base64url")
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
|
@ -42,13 +42,17 @@ const makeTransport = Effect.fn("cli.standalone.transport")(
|
|||
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
|
||||
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
|
||||
const ready = yield* Effect.tryPromise(() => decodeReady(output))
|
||||
return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid }
|
||||
return {
|
||||
url: ready.url,
|
||||
auth: { type: "basic" as const, username: "opencode", password },
|
||||
pid: proc.pid,
|
||||
} satisfies Service.Endpoint & { readonly pid: number }
|
||||
},
|
||||
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
||||
)
|
||||
|
||||
export function transport(options: Options = {}) {
|
||||
return makeTransport(options)
|
||||
export function start(options: Options = {}) {
|
||||
return makeEndpoint(options)
|
||||
}
|
||||
|
||||
export * as Standalone from "./standalone"
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import { run } from "@opencode-ai/tui"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import type { Service } from "@opencode-ai/client/effect"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Args } from "@opencode-ai/tui/context/args"
|
||||
|
||||
export function runTui(
|
||||
transport: Service.Transport,
|
||||
args: Args,
|
||||
discover?: () => Promise<Service.Transport>,
|
||||
reload?: () => Promise<void>,
|
||||
) {
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
let disposeSlots: (() => void) | undefined
|
||||
return Effect.gen(function* () {
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const options = { baseUrl: transport.url, headers: transport.headers }
|
||||
const api = OpenCode.make(options)
|
||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location.directory),
|
||||
Effect.catch(() =>
|
||||
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
|
||||
),
|
||||
)
|
||||
return yield* run({
|
||||
client: createOpencodeClient({ ...options, directory }),
|
||||
api,
|
||||
discover: discover
|
||||
? async () => {
|
||||
const next = await discover()
|
||||
return {
|
||||
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
|
||||
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
reload,
|
||||
args,
|
||||
config,
|
||||
log: (level, message, tags) => {
|
||||
const effect =
|
||||
level === "debug"
|
||||
? Effect.logDebug(message, tags)
|
||||
: level === "warn"
|
||||
? Effect.logWarning(message, tags)
|
||||
: level === "error"
|
||||
? Effect.logError(message, tags)
|
||||
: Effect.logInfo(message, tags)
|
||||
runFork(effect)
|
||||
},
|
||||
pluginHost: {
|
||||
async start(input) {
|
||||
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
|
||||
},
|
||||
async dispose() {
|
||||
disposeSlots?.()
|
||||
},
|
||||
},
|
||||
})
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
||||
}
|
||||
242
packages/cli/src/ui/timeline.tsx
Normal file
242
packages/cli/src/ui/timeline.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { createCliRenderer, RGBA, type CliRenderer, type ColorInput, type ScrollbackWriter } from "@opentui/core"
|
||||
import { createScrollbackWriter, render, useKeyboard } from "@opentui/solid"
|
||||
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||
import { Show, createSignal } from "solid-js"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export type TimelineHost = {
|
||||
readonly signal: AbortSignal
|
||||
intro(text: string): Promise<void>
|
||||
item(text: string): Promise<void>
|
||||
pending(text: string): Promise<void>
|
||||
success(text: string): Promise<void>
|
||||
failure(text: string): Promise<void>
|
||||
outro(text: string): Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
type RowKind = "intro" | "item" | "success" | "failure" | "outro"
|
||||
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
const IDLE_TIMEOUT = 1_000
|
||||
const COLORS = {
|
||||
accent: RGBA.fromIndex(6),
|
||||
error: RGBA.fromIndex(1),
|
||||
foreground: RGBA.defaultForeground(),
|
||||
muted: RGBA.fromIndex(8),
|
||||
success: RGBA.fromIndex(2),
|
||||
}
|
||||
const ROWS: Record<RowKind, { marker: string; color: ColorInput; connector: boolean }> = {
|
||||
intro: { marker: "┌", color: COLORS.muted, connector: true },
|
||||
item: { marker: "●", color: COLORS.accent, connector: true },
|
||||
success: { marker: "◇", color: COLORS.success, connector: true },
|
||||
failure: { marker: "■", color: COLORS.error, connector: false },
|
||||
outro: { marker: "└", color: COLORS.muted, connector: false },
|
||||
}
|
||||
|
||||
function row(kind: RowKind, value: string): ScrollbackWriter {
|
||||
const style = ROWS[kind]
|
||||
return createScrollbackWriter(
|
||||
() => (
|
||||
<box width="100%" minHeight={1} flexDirection="column">
|
||||
<box width="100%" minHeight={1} flexDirection="row" gap={1}>
|
||||
<text fg={style.color} flexShrink={0}>
|
||||
{style.marker}
|
||||
</text>
|
||||
<text fg={COLORS.foreground} wrapMode="word">
|
||||
{value}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={style.connector}>
|
||||
<text fg={COLORS.muted}>│</text>
|
||||
</Show>
|
||||
</box>
|
||||
),
|
||||
{ startOnNewLine: true, trailingNewline: !style.connector },
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineFooter(props: { pending: () => string | undefined; cancel: () => void }) {
|
||||
useKeyboard((event) => {
|
||||
if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return
|
||||
event.preventDefault()
|
||||
props.cancel()
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height={1} flexDirection="row" gap={1}>
|
||||
<Show when={props.pending()}>
|
||||
{(text) => (
|
||||
<>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={COLORS.accent} />
|
||||
<text fg={COLORS.foreground} wrapMode="none" truncate>
|
||||
{text()}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function bounded(task: Promise<unknown>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, IDLE_TIMEOUT)
|
||||
timer.unref()
|
||||
const finish = () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
void task.then(finish, finish)
|
||||
})
|
||||
}
|
||||
|
||||
async function shutdown(renderer: CliRenderer): Promise<void> {
|
||||
await bounded(renderer.idle())
|
||||
try {
|
||||
renderer.externalOutputMode = "passthrough"
|
||||
} finally {
|
||||
try {
|
||||
renderer.screenMode = "main-screen"
|
||||
} finally {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTimelineHost(): Promise<TimelineHost> {
|
||||
const stdout = process.stdout
|
||||
const controller = new AbortController()
|
||||
const signals: NodeJS.Signals[] = ["SIGINT", "SIGHUP", "SIGQUIT"]
|
||||
const cancel = () => {
|
||||
if (!controller.signal.aborted) controller.abort()
|
||||
}
|
||||
signals.forEach((signal) => process.on(signal, cancel))
|
||||
|
||||
if (!stdout.isTTY || !process.stdin.isTTY) {
|
||||
let closed = false
|
||||
let writing = false
|
||||
let active: Promise<void> | undefined
|
||||
let closeTask: Promise<void> | undefined
|
||||
const write = async (kind: RowKind | "pending", text: string) => {
|
||||
if (closed) throw new Error("timeline closed")
|
||||
if (writing) throw new Error("timeline write already in progress")
|
||||
writing = true
|
||||
try {
|
||||
const style = kind === "pending" ? undefined : ROWS[kind]
|
||||
const marker = kind === "pending" ? "." : ROWS[kind].marker
|
||||
const connector = style?.connector ? "│\n" : ""
|
||||
active = new Promise<void>((resolve, reject) => {
|
||||
stdout.write(`${marker} ${text}\n${connector}`, (error) => (error ? reject(error) : resolve()))
|
||||
})
|
||||
await active
|
||||
} finally {
|
||||
writing = false
|
||||
active = undefined
|
||||
}
|
||||
}
|
||||
const close = () => {
|
||||
if (closeTask) return closeTask
|
||||
closed = true
|
||||
closeTask = (async () => {
|
||||
await active?.catch(() => { })
|
||||
signals.forEach((signal) => process.off(signal, cancel))
|
||||
})()
|
||||
return closeTask
|
||||
}
|
||||
return {
|
||||
signal: controller.signal,
|
||||
intro: (text) => write("intro", text),
|
||||
item: (text) => write("item", text),
|
||||
pending: (text) => write("pending", text),
|
||||
success: (text) => write("success", text),
|
||||
failure: (text) => write("failure", text),
|
||||
outro: (text) => write("outro", text),
|
||||
close,
|
||||
}
|
||||
}
|
||||
|
||||
let renderer: CliRenderer | undefined
|
||||
|
||||
try {
|
||||
// Start on a fresh row so delayed SSH cursor reports cannot make
|
||||
// split-footer overwrite the shell command.
|
||||
process.stdout.write("\n")
|
||||
renderer = await createCliRenderer({
|
||||
stdin: process.stdin,
|
||||
useMouse: false,
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
exitSignals: [],
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 1,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const activeRenderer = renderer
|
||||
const [pending, setPending] = createSignal<string>()
|
||||
const renderTask = render(() => <TimelineFooter pending={pending} cancel={cancel} />, activeRenderer)
|
||||
void renderTask.catch(cancel)
|
||||
await bounded(activeRenderer.idle())
|
||||
|
||||
let closed = false
|
||||
let writing = false
|
||||
let active: Promise<void> | undefined
|
||||
let closeTask: Promise<void> | undefined
|
||||
const write = (kind: RowKind | "pending", text: string) => {
|
||||
if (closed) return Promise.reject(new Error("timeline closed"))
|
||||
if (writing) return Promise.reject(new Error("timeline write already in progress"))
|
||||
writing = true
|
||||
active = (async () => {
|
||||
if (kind === "pending") {
|
||||
setPending(text)
|
||||
activeRenderer.requestRender()
|
||||
} else {
|
||||
if (kind === "success" || kind === "failure" || kind === "outro") setPending(undefined)
|
||||
activeRenderer.writeToScrollback(row(kind, text))
|
||||
activeRenderer.requestRender()
|
||||
}
|
||||
await bounded(activeRenderer.idle())
|
||||
})().finally(() => {
|
||||
writing = false
|
||||
active = undefined
|
||||
})
|
||||
return active
|
||||
}
|
||||
const close = () => {
|
||||
if (closeTask) return closeTask
|
||||
closed = true
|
||||
closeTask = (async () => {
|
||||
await active?.catch(() => { })
|
||||
try {
|
||||
await shutdown(activeRenderer)
|
||||
await bounded(renderTask)
|
||||
} finally {
|
||||
signals.forEach((signal) => process.off(signal, cancel))
|
||||
}
|
||||
})()
|
||||
return closeTask
|
||||
}
|
||||
return {
|
||||
signal: controller.signal,
|
||||
intro: (text) => write("intro", text),
|
||||
item: (text) => write("item", text),
|
||||
pending: (text) => write("pending", text),
|
||||
success: (text) => write("success", text),
|
||||
failure: (text) => write("failure", text),
|
||||
outro: (text) => write("outro", text),
|
||||
close,
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
if (renderer) await shutdown(renderer)
|
||||
} finally {
|
||||
signals.forEach((signal) => process.off(signal, cancel))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import path from "node:path"
|
||||
import { Standalone } from "../../src/services/standalone"
|
||||
|
||||
|
|
@ -7,9 +8,11 @@ process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
|
|||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* Standalone.transport()
|
||||
const response = yield* Effect.promise(() => fetch(new URL("/api/health", transport.url), { headers: transport.headers }))
|
||||
console.log(`${transport.pid} ${transport.url} ${response.status}`)
|
||||
const endpoint = yield* Standalone.start()
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }),
|
||||
)
|
||||
console.log(`${endpoint.pid} ${endpoint.url} ${response.status}`)
|
||||
return yield* Effect.never
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "node:path"
|
||||
import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini"
|
||||
import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini"
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
|
|
@ -39,6 +39,12 @@ describe("mini command", () => {
|
|||
).toEqual({ providerID: "session-provider", modelID: "session-model" })
|
||||
})
|
||||
|
||||
test("parses model variants from the model reference", () => {
|
||||
expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
|
||||
JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
|
||||
)
|
||||
})
|
||||
|
||||
test("is registered in the preview CLI", async () => {
|
||||
const result = await cli(["--help"])
|
||||
|
||||
|
|
@ -52,6 +58,7 @@ describe("mini command", () => {
|
|||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("--server string")
|
||||
expect(result.stdout).not.toContain("--variant")
|
||||
expect(result.stdout).not.toContain("--attach")
|
||||
expect(result.stdout).not.toContain("--command")
|
||||
})
|
||||
|
|
@ -71,6 +78,8 @@ describe("mini command", () => {
|
|||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health")
|
||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
if (url.pathname === "/api/model") {
|
||||
modelRequests++
|
||||
return Response.json({
|
||||
|
|
@ -87,8 +96,6 @@ describe("mini command", () => {
|
|||
"run",
|
||||
"--server",
|
||||
server.url.toString(),
|
||||
"--dir",
|
||||
process.cwd(),
|
||||
"--model",
|
||||
"definitely/missing",
|
||||
"hi",
|
||||
|
|
@ -104,7 +111,8 @@ describe("mini command", () => {
|
|||
test("reports pre-admission errors as JSON", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
fetch(request) {
|
||||
if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
|
||||
return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
|
||||
},
|
||||
})
|
||||
|
|
@ -116,7 +124,7 @@ describe("mini command", () => {
|
|||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
type: "error",
|
||||
sessionID: "",
|
||||
error: { type: "unknown", message: "Failed to resolve server directory" },
|
||||
error: { type: "unknown", message: "UnexpectedStatus" },
|
||||
})
|
||||
} finally {
|
||||
server.stop(true)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schedule, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
|
@ -28,16 +37,42 @@ test("local channel stores service config with the local service filename", asyn
|
|||
|
||||
test("concurrent service processes elect one server", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
|
||||
const database = path.join(root, "opencode.db")
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
OPENCODE_DB: path.join(root, "opencode.db"),
|
||||
OPENCODE_DB: database,
|
||||
OPENCODE_TEST_HOME: root,
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
}
|
||||
const sessionID = SessionV2.ID.make("ses_service_recovery")
|
||||
await withDatabase(
|
||||
database,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make(root), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "recovery",
|
||||
directory: root,
|
||||
title: "recovery",
|
||||
version: "test",
|
||||
time_suspended: Date.now(),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
|
||||
const first = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
const second = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
|
|
@ -51,6 +86,20 @@ test("concurrent service processes elect one server", async () => {
|
|||
|
||||
expect(exited).toBe(true)
|
||||
expect(winner.exitCode).toBe(null)
|
||||
expect(
|
||||
await withDatabase(
|
||||
database,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
return yield* db
|
||||
.select({ timeSuspended: SessionTable.time_suspended })
|
||||
.from(SessionTable)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
),
|
||||
).toEqual({ timeSuspended: null })
|
||||
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
|
||||
} finally {
|
||||
first.kill("SIGTERM")
|
||||
second.kill("SIGTERM")
|
||||
|
|
@ -59,6 +108,40 @@ test("concurrent service processes elect one server", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped))
|
||||
}
|
||||
|
||||
function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
|
||||
return withDatabase(
|
||||
file,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
return yield* db
|
||||
.select({ id: EventTable.id, sessionID: EventTable.aggregate_id, type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) =>
|
||||
rows.filter(
|
||||
(row) =>
|
||||
row.sessionID === sessionID &&
|
||||
row.type ===
|
||||
EventV2.versionedType(
|
||||
SessionEvent.Execution.Started.type,
|
||||
SessionEvent.Execution.Started.durable.version,
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.filterOrFail((rows) => rows.length > 0),
|
||||
Effect.map((rows) => rows.length),
|
||||
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(200)))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForInfo(file: string) {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
const value = await Bun.file(file)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -11,9 +11,13 @@ import { join } from "node:path"
|
|||
// is all a client needs to connect. The daemon's own configuration (port,
|
||||
// persisted password) is CLI-owned and never read here.
|
||||
|
||||
export type Transport = {
|
||||
export type Endpoint = {
|
||||
readonly url: string
|
||||
readonly headers?: RequestInit["headers"]
|
||||
readonly auth?: {
|
||||
readonly type: "basic"
|
||||
readonly username: string
|
||||
readonly password: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Options = {
|
||||
|
|
@ -31,7 +35,7 @@ export type Options = {
|
|||
// Read-only lookup: registration file plus health check and version gate.
|
||||
// Never spawns; escalation to start() is the caller's policy.
|
||||
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
|
||||
return (yield* discoverLocal(options))?.transport
|
||||
return (yield* discoverLocal(options))?.endpoint
|
||||
})
|
||||
|
||||
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
||||
|
|
@ -72,7 +76,7 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
|
|||
child.kill("SIGTERM")
|
||||
}),
|
||||
),
|
||||
Effect.map((found) => found.transport),
|
||||
Effect.map((found) => found.endpoint),
|
||||
Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
|
||||
Effect.mapError(() => new Error("Failed to start server")),
|
||||
)
|
||||
|
|
@ -90,8 +94,9 @@ function fallback() {
|
|||
return join(state, "opencode", "service.json")
|
||||
}
|
||||
|
||||
function auth(password: string): RequestInit["headers"] {
|
||||
return { authorization: "Basic " + btoa("opencode:" + password) }
|
||||
export function headers(endpoint: Endpoint): RequestInit["headers"] {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
|
|
@ -120,14 +125,20 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
|||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly transport: Transport
|
||||
readonly endpoint: Endpoint
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
||||
const headers = info.password === undefined ? undefined : auth(info.password)
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/health", info.url), {
|
||||
headers,
|
||||
headers: headers(endpoint),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
|
|
@ -138,7 +149,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
|
|||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
if (version !== undefined && health.value.version !== version) return undefined
|
||||
return { info, transport: { url: info.url, headers } } satisfies LocalService
|
||||
return { info, endpoint } satisfies LocalService
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
|
|
@ -146,7 +157,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
|
|||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||
)
|
||||
return undefined
|
||||
return { info, transport: { url: info.url, headers } } satisfies LocalService
|
||||
return { info, endpoint } satisfies LocalService
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
HealthGetOutput,
|
||||
ServerGetOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
AgentListInput,
|
||||
|
|
@ -328,6 +329,13 @@ export function make(options: ClientOptions) {
|
|||
requestOptions,
|
||||
),
|
||||
},
|
||||
server: {
|
||||
get: (requestOptions?: RequestOptions) =>
|
||||
request<ServerGetOutput>(
|
||||
{ method: "GET", path: `/api/server`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
location: {
|
||||
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
||||
request<LocationGetOutput>(
|
||||
|
|
@ -508,7 +516,15 @@ export function make(options: ClientOptions) {
|
|||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
|
||||
body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||
body: {
|
||||
id: input["id"],
|
||||
text: input["text"],
|
||||
files: input["files"],
|
||||
agents: input["agents"],
|
||||
metadata: input["metadata"],
|
||||
delivery: input["delivery"],
|
||||
resume: input["resume"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 400, 404, 401],
|
||||
empty: false,
|
||||
|
|
@ -550,22 +566,24 @@ export function make(options: ClientOptions) {
|
|||
requestOptions,
|
||||
),
|
||||
synthetic: (input: SessionSyntheticInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSyntheticOutput>(
|
||||
request<{ readonly data: SessionSyntheticOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`,
|
||||
body: {
|
||||
id: input["id"],
|
||||
text: input["text"],
|
||||
description: input["description"],
|
||||
metadata: input["metadata"],
|
||||
delivery: input["delivery"],
|
||||
resume: input["resume"],
|
||||
},
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
).then((value) => value.data),
|
||||
shell: (input: SessionShellInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionShellOutput>(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -159,6 +159,8 @@ export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
|
|||
|
||||
export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||
|
||||
export type ServerGetOutput = { readonly urls: ReadonlyArray<string> }
|
||||
|
||||
export type LocationGetInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
|
@ -541,73 +543,120 @@ export type SessionPromptInput = {
|
|||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
readonly id?: string | null
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["id"]
|
||||
readonly prompt: {
|
||||
readonly text: {
|
||||
readonly id?: string | null
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["prompt"]
|
||||
}["text"]
|
||||
readonly files?: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["files"]
|
||||
readonly agents?: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["agents"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["metadata"]
|
||||
readonly delivery?: {
|
||||
readonly id?: string | null
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["delivery"]
|
||||
readonly resume?: {
|
||||
readonly id?: string | null
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["resume"]
|
||||
|
|
@ -618,7 +667,10 @@ export type SessionPromptOutput = {
|
|||
readonly admittedSeq: number
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly prompt: {
|
||||
readonly timeCreated: number
|
||||
readonly promotedSeq?: number
|
||||
readonly type: "user"
|
||||
readonly data: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
|
|
@ -632,10 +684,9 @@ export type SessionPromptOutput = {
|
|||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
readonly timeCreated: number
|
||||
readonly promotedSeq?: number
|
||||
}
|
||||
}["data"]
|
||||
|
||||
|
|
@ -819,7 +870,10 @@ export type SessionCommandOutput = {
|
|||
readonly admittedSeq: number
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly prompt: {
|
||||
readonly timeCreated: number
|
||||
readonly promotedSeq?: number
|
||||
readonly type: "user"
|
||||
readonly data: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
|
|
@ -833,10 +887,9 @@ export type SessionCommandOutput = {
|
|||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
readonly timeCreated: number
|
||||
readonly promotedSeq?: number
|
||||
}
|
||||
}["data"]
|
||||
|
||||
|
|
@ -863,33 +916,72 @@ export type SessionSkillOutput = void
|
|||
|
||||
export type SessionSyntheticInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly text: {
|
||||
readonly id?: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["id"]
|
||||
readonly text: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["text"]
|
||||
readonly description?: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["description"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["metadata"]
|
||||
readonly resume?: {
|
||||
readonly delivery?: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["delivery"]
|
||||
readonly resume?: {
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["resume"]
|
||||
}
|
||||
|
||||
export type SessionSyntheticOutput = void
|
||||
export type SessionSyntheticOutput = {
|
||||
readonly data: {
|
||||
readonly admittedSeq: number
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly timeCreated: number
|
||||
readonly promotedSeq?: number
|
||||
readonly type: "synthetic"
|
||||
readonly data: {
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type SessionShellInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
|
|
@ -906,11 +998,11 @@ export type SessionCompactInput = {
|
|||
|
||||
export type SessionCompactOutput = {
|
||||
readonly data: {
|
||||
readonly type: "compaction"
|
||||
readonly admittedSeq: number
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly timeCreated: number
|
||||
readonly type: "compaction"
|
||||
readonly handledSeq?: number
|
||||
}
|
||||
}["data"]
|
||||
|
|
@ -1227,7 +1319,7 @@ export type SessionLogOutput =
|
|||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.prompt.promoted"
|
||||
readonly type: "session.input.promoted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly inputID: string }
|
||||
|
|
@ -1236,28 +1328,42 @@ export type SessionLogOutput =
|
|||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.prompt.admitted"
|
||||
readonly type: "session.input.admitted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly inputID: string
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
readonly input:
|
||||
| {
|
||||
readonly type: "user"
|
||||
readonly data: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
| {
|
||||
readonly type: "synthetic"
|
||||
readonly data: {
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
|
@ -2598,7 +2704,7 @@ export type FormRequestListOutput = {
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form"
|
||||
readonly fields: ReadonlyArray<
|
||||
|
|
@ -2695,7 +2801,7 @@ export type FormRequestListOutput = {
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
|
|
@ -2710,7 +2816,7 @@ export type FormListOutput = {
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form"
|
||||
readonly fields: ReadonlyArray<
|
||||
|
|
@ -2807,7 +2913,7 @@ export type FormListOutput = {
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
|
|
@ -2819,7 +2925,7 @@ export type FormCreateInput = {
|
|||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form" | "url"
|
||||
readonly fields?: ReadonlyArray<
|
||||
|
|
@ -2914,9 +3020,9 @@ export type FormCreateInput = {
|
|||
> | null
|
||||
readonly url?: string | null
|
||||
}["id"]
|
||||
readonly title?: {
|
||||
readonly title: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form" | "url"
|
||||
readonly fields?: ReadonlyArray<
|
||||
|
|
@ -3013,7 +3119,7 @@ export type FormCreateInput = {
|
|||
}["title"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form" | "url"
|
||||
readonly fields?: ReadonlyArray<
|
||||
|
|
@ -3110,7 +3216,7 @@ export type FormCreateInput = {
|
|||
}["metadata"]
|
||||
readonly mode: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form" | "url"
|
||||
readonly fields?: ReadonlyArray<
|
||||
|
|
@ -3207,7 +3313,7 @@ export type FormCreateInput = {
|
|||
}["mode"]
|
||||
readonly fields?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form" | "url"
|
||||
readonly fields?: ReadonlyArray<
|
||||
|
|
@ -3304,7 +3410,7 @@ export type FormCreateInput = {
|
|||
}["fields"]
|
||||
readonly url?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form" | "url"
|
||||
readonly fields?: ReadonlyArray<
|
||||
|
|
@ -3406,7 +3512,7 @@ export type FormCreateOutput = {
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form"
|
||||
readonly fields: ReadonlyArray<
|
||||
|
|
@ -3503,7 +3609,7 @@ export type FormCreateOutput = {
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
|
|
@ -3520,7 +3626,7 @@ export type FormGetOutput = {
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "form"
|
||||
readonly fields: ReadonlyArray<
|
||||
|
|
@ -3617,7 +3723,7 @@ export type FormGetOutput = {
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
|
|
@ -4586,7 +4692,7 @@ export type EventSubscribeOutput =
|
|||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.prompt.promoted"
|
||||
readonly type: "session.input.promoted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly inputID: string }
|
||||
|
|
@ -4595,28 +4701,42 @@ export type EventSubscribeOutput =
|
|||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.prompt.admitted"
|
||||
readonly type: "session.input.admitted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly inputID: string
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
readonly input:
|
||||
| {
|
||||
readonly type: "user"
|
||||
readonly data: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
| {
|
||||
readonly type: "synthetic"
|
||||
readonly data: {
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
|
@ -5339,7 +5459,7 @@ export type EventSubscribeOutput =
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly mode: "form"
|
||||
readonly fields: ReadonlyArray<
|
||||
|
|
@ -5436,7 +5556,7 @@ export type EventSubscribeOutput =
|
|||
| {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly title: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly mode: "url"
|
||||
readonly url: string
|
||||
|
|
@ -5463,17 +5583,6 @@ export type EventSubscribeOutput =
|
|||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly id: string; readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "todo.updated"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly todos: ReadonlyArray<{ readonly content: string; readonly status: string; readonly priority: string }>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
|||
expect(ProjectV2.Current).toBe(Project.Current)
|
||||
expect(ProjectV2.Directory).toBe(Project.Directory)
|
||||
expect(ProjectV2.Directories).toBe(Project.Directories)
|
||||
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
|
||||
expect(CoreSessionInput.Message).toBe(SessionInput.Message)
|
||||
expect(CoreSessionInput.User).toBe(SessionInput.User)
|
||||
expect(CoreSessionInput.Synthetic).toBe(SessionInput.Synthetic)
|
||||
expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
|
||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||
})
|
||||
const admitted = yield* client.session.prompt({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: Prompt.make({ text: "Hello" }),
|
||||
text: "Hello",
|
||||
resume: false,
|
||||
})
|
||||
yield* client.session.compact({ sessionID: Session.ID.make("ses_test") })
|
||||
|
|
@ -201,7 +201,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||
expect(result.created.id).toBe("ses_test")
|
||||
expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.admitted.data)).toBe(Object.prototype)
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
expect(result.context).toEqual([])
|
||||
expect(logQueries[0]).toEqual({ after: "0" })
|
||||
|
|
@ -259,7 +259,8 @@ const admission = {
|
|||
admittedSeq: 0,
|
||||
id: "msg_test",
|
||||
sessionID: "ses_test",
|
||||
prompt: { text: "Hello" },
|
||||
type: "user",
|
||||
data: { text: "Hello" },
|
||||
delivery: "steer",
|
||||
timeCreated: 1_717_171_717_000,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ test("exposes every standard HTTP API group", () => {
|
|||
|
||||
expect(Object.keys(client)).toEqual([
|
||||
"health",
|
||||
"server",
|
||||
"location",
|
||||
"agent",
|
||||
"plugin",
|
||||
|
|
@ -45,6 +46,21 @@ test("exposes every standard HTTP API group", () => {
|
|||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("server.get uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return Response.json({ urls: ["http://192.168.1.10:4096"] })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.server.get()).toEqual({ urls: ["http://192.168.1.10:4096"] })
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/server")
|
||||
})
|
||||
|
||||
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
|
@ -258,6 +274,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||
})
|
||||
}
|
||||
if (url.includes("/prompt")) return Response.json(admission)
|
||||
if (url.includes("/synthetic")) return Response.json(syntheticAdmission)
|
||||
if (url.endsWith("/compact")) return Response.json(compactionAdmission)
|
||||
if (url.includes("/context")) return Response.json({ data: [] })
|
||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||
|
|
@ -278,7 +295,13 @@ test("session methods use the public HTTP contract", async () => {
|
|||
})
|
||||
const admitted = await client.session.prompt({
|
||||
sessionID: "ses_test",
|
||||
prompt: { text: "Hello" },
|
||||
text: "Hello",
|
||||
resume: false,
|
||||
})
|
||||
const synthetic = await client.session.synthetic({
|
||||
sessionID: "ses_test",
|
||||
text: "Completed",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
await client.session.compact({ sessionID: "ses_test" })
|
||||
|
|
@ -293,6 +316,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
|
||||
expect(context).toEqual([])
|
||||
expect(log).toEqual([modelSwitchedEvent, synced])
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
|
|
@ -303,6 +327,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/model"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/synthetic"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||
|
|
@ -313,7 +338,14 @@ test("session methods use the public HTTP contract", async () => {
|
|||
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
|
||||
if (typeof body !== "string") throw new Error("Expected JSON request body")
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
prompt: { text: "Hello" },
|
||||
text: "Hello",
|
||||
resume: false,
|
||||
})
|
||||
const syntheticBody = requests.find((request) => request.url.endsWith("/synthetic"))?.init?.body
|
||||
if (typeof syntheticBody !== "string") throw new Error("Expected JSON synthetic request body")
|
||||
expect(JSON.parse(syntheticBody)).toEqual({
|
||||
text: "Completed",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
})
|
||||
|
|
@ -376,12 +408,25 @@ const admission = {
|
|||
admittedSeq: 0,
|
||||
id: "msg_test",
|
||||
sessionID: "ses_test",
|
||||
prompt: { text: "Hello" },
|
||||
type: "user",
|
||||
data: { text: "Hello" },
|
||||
delivery: "steer",
|
||||
timeCreated: 1_717_171_717_000,
|
||||
},
|
||||
}
|
||||
|
||||
const syntheticAdmission = {
|
||||
data: {
|
||||
admittedSeq: 1,
|
||||
id: "msg_synthetic",
|
||||
sessionID: "ses_test",
|
||||
type: "synthetic",
|
||||
data: { text: "Completed" },
|
||||
delivery: "queue",
|
||||
timeCreated: 1_717_171_717_000,
|
||||
},
|
||||
}
|
||||
|
||||
const compactionAdmission = {
|
||||
data: {
|
||||
type: "compaction",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
|
||||
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
|
||||
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
|
||||
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. Update `codemode.md` when the package design, integration status, or rationale changes.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
|
|
|
|||
|
|
@ -235,23 +235,21 @@ A host cannot define its own `$codemode` top-level namespace.
|
|||
|
||||
## Supported Programs
|
||||
|
||||
CodeMode executes a deliberately bounded JavaScript subset. It supports:
|
||||
CodeMode executes a deliberately bounded JavaScript subset. See the
|
||||
[interpreter support checklist](./interpreter-support.md) for the complete, checkable language and standard-library
|
||||
matrix, known semantic gaps, and intentional exclusions.
|
||||
|
||||
- Plain data literals, property access, assignment, destructuring, and sequence expressions (the comma operator, evaluated left to right with the final value returned).
|
||||
- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets, including assignment-form destructuring such as `for ([key, value] of entries)`), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`.
|
||||
- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring.
|
||||
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
|
||||
- Common array, string, number, `Object`, `Math`, and `JSON` operations, including primitive-number `valueOf`, the standard non-finite `Number` constants, and host-backed `Math.random`. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
|
||||
- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones).
|
||||
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout.
|
||||
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
|
||||
- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention.
|
||||
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
|
||||
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
|
||||
At a high level, it supports:
|
||||
|
||||
Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
|
||||
- Plain data, property access and assignment, destructuring, functions, conditionals, loops, spread, optional chaining,
|
||||
and structured error handling.
|
||||
- Allowlisted Array, String, Number, Object, Math, JSON, console, Date, RegExp, Map, Set, URL, and URLSearchParams APIs.
|
||||
- Eager supervised tool promises, direct `await`, and the supported `Promise` combinators for concurrent work.
|
||||
- Live standard-library values inside the sandbox and predictable JSON-like serialization at tool/result boundaries.
|
||||
- Actionable diagnostics for unsupported syntax, invalid data, tool failures, limits, and execution failures.
|
||||
|
||||
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.
|
||||
It does not expose ambient host authority or arbitrary JavaScript execution. Unsupported syntax returns an
|
||||
`UnsupportedSyntax` diagnostic with a source location when available.
|
||||
|
||||
CodeMode is an orchestration language, not a general JavaScript runtime.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ It records current behavior, intentional boundaries, durable rationale, and mate
|
|||
Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove
|
||||
completed work instead of preserving checked-off chronology.
|
||||
|
||||
Detailed package API documentation lives in [README.md](./README.md). OpenAPI-specific follow-ups live in
|
||||
Detailed package API documentation lives in [README.md](./README.md), and the checkable language/runtime matrix lives
|
||||
in [interpreter-support.md](./interpreter-support.md). OpenAPI-specific follow-ups live in
|
||||
[src/openapi/TODO.md](./src/openapi/TODO.md).
|
||||
|
||||
## How CodeMode Works
|
||||
|
|
@ -140,31 +141,5 @@ represent accurately rather than guessing semantics.
|
|||
|
||||
## Remaining Work
|
||||
|
||||
Keep only material unresolved work here. Small isolated defects should be GitHub issues; adapter-only work belongs in
|
||||
the adapter TODO. Delete entries when completed.
|
||||
|
||||
### DSL expansion
|
||||
|
||||
The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are
|
||||
current omissions to implement, not intentional product boundaries.
|
||||
|
||||
- [ ] Design proper multi-stage promise pipelines. Supporting `.then`, `.catch`, and `.finally` should preserve promise
|
||||
assimilation, cancellation, failure handling, and concurrent per-item pipelines rather than adding syntax-only
|
||||
shims. Consider `Promise.any` in the same pass.
|
||||
- [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and
|
||||
collection values, then extend it to bounded host streams when a stream boundary exists.
|
||||
- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to
|
||||
`Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed.
|
||||
- [ ] Add `Object.is` after runtime method and tool references have stable identity semantics.
|
||||
- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set
|
||||
composition methods, and `Array.prototype.toSpliced`.
|
||||
- [ ] Decide whether iterable `Math.sumPrecise` belongs in the runtime.
|
||||
- [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter
|
||||
defects are distinguishable without leaking private causes.
|
||||
|
||||
### Tool and result contracts
|
||||
|
||||
- [ ] Design explicit tagged representations and size rules before allowing Blob, File, ArrayBuffer, typed arrays, or
|
||||
host streams to cross the sandbox boundary.
|
||||
- [ ] Define one consistent policy for tool path segments named `__proto__`, `constructor`, or `prototype`. They must
|
||||
either be safely callable, rejected before catalog generation, or use one documented escaping rule.
|
||||
The [interpreter support checklist](./interpreter-support.md) owns concrete DSL, standard-library, semantic-correctness,
|
||||
diagnostic, and data-boundary work. OpenAPI adapter work remains in [src/openapi/TODO.md](./src/openapi/TODO.md).
|
||||
|
|
|
|||
296
packages/codemode/interpreter-support.md
Normal file
296
packages/codemode/interpreter-support.md
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
# CodeMode Interpreter Support
|
||||
|
||||
This is the checkable support matrix for CodeMode's confined JavaScript interpreter. It tracks the language and
|
||||
standard-library surface that programs can use today, plus concrete gaps that may be implemented later.
|
||||
|
||||
- `[x]` means the feature is implemented at the scope described here.
|
||||
- `[ ]` means the feature is unavailable, incomplete, or intentionally divergent as described.
|
||||
- A checked item does not promise complete ECMAScript edge-case parity. Known differences are listed next to the
|
||||
supported surface or under [Known semantic gaps](#known-semantic-gaps).
|
||||
- [Intentional exclusions](#intentional-exclusions) are boundaries, not backlog.
|
||||
|
||||
When behavior changes, update this file and the tests in the same change. The implementation and tests remain the
|
||||
ultimate source of truth.
|
||||
|
||||
## Source and execution model
|
||||
|
||||
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
|
||||
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
|
||||
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
|
||||
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
||||
|
||||
## 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, and URLSearchParams.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and object spread.
|
||||
- [x] Template literals with interpolation.
|
||||
- [x] Regular-expression literals.
|
||||
- [x] `NaN` and `Infinity` globals.
|
||||
- [ ] BigInt literals and values.
|
||||
- [ ] Symbols.
|
||||
- [ ] Tagged template literals.
|
||||
- [ ] Getters and setters in object literals.
|
||||
|
||||
## Bindings and destructuring
|
||||
|
||||
- [x] `const`, `let`, and accepted `var` declarations.
|
||||
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
|
||||
- [x] Nested patterns, defaults, elisions, and rest elements.
|
||||
- [x] Assignment to identifiers, object fields, array indexes, and writable URL fields.
|
||||
- [x] Function declarations are hoisted within their interpreted scope.
|
||||
- [x] Parameter defaults observe a temporal dead zone for later parameters.
|
||||
- [ ] JavaScript-correct `var` function scope, hoisting, and redeclaration. Accepted `var` currently behaves like a
|
||||
lexical declaration; prefer `let` or `const`.
|
||||
- [ ] Complete `let`/`const` temporal-dead-zone and declaration-hoisting semantics.
|
||||
- [ ] Computed object destructuring keys such as `const { [field]: value } = record`.
|
||||
- [ ] Object destructuring from arrays, such as `const { length } = values`.
|
||||
- [ ] Iterable array destructuring from Map, Set, string, or URLSearchParams values.
|
||||
- [ ] Dynamic property deletion with `delete object[key]`.
|
||||
|
||||
## Statements and control flow
|
||||
|
||||
- [x] Blocks and empty statements.
|
||||
- [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, and URLSearchParams.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, and tool references.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
- [x] `throw` with arbitrary values.
|
||||
- [ ] Labeled statements, labeled `break`, and labeled `continue`.
|
||||
- [ ] `for await...of` and async iteration.
|
||||
- [ ] `with` and `debugger` statements.
|
||||
|
||||
## Functions and callbacks
|
||||
|
||||
- [x] Function declarations, function expressions, and arrow functions.
|
||||
- [x] Synchronous and `async` functions.
|
||||
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
|
||||
- [x] Expression and block function bodies.
|
||||
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, and string-replacement APIs.
|
||||
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks where applicable.
|
||||
- [x] Async string replacement callbacks; replacements are evaluated sequentially.
|
||||
- [ ] `this`, `super`, constructor functions, or function prototype methods such as `call`, `apply`, and `bind`.
|
||||
- [ ] Classes and private fields.
|
||||
- [ ] Generator functions and `yield`.
|
||||
- [ ] Async predicates, reducers, and comparators with automatic awaiting. Async mapping can be joined explicitly with
|
||||
`Promise.all`, but a promise is not a meaningful predicate or sort result.
|
||||
- [ ] General built-in callable references as callbacks, such as `values.map(Math.abs)` or
|
||||
`records.map(JSON.stringify)`.
|
||||
|
||||
## Expressions and operators
|
||||
|
||||
- [x] Property access with dot or computed bracket syntax.
|
||||
- [x] Optional property access and optional calls.
|
||||
- [x] Function/tool calls and spread arguments.
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for sandbox promises; awaiting a plain value is a no-op.
|
||||
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, and URLSearchParams.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
- [x] Logical operators: `&&`, `||`, `??`, and `!`, with short-circuiting.
|
||||
- [x] Unary `+`, unary `-`, `typeof`, `instanceof`, and own-property-only `in`.
|
||||
- [x] Prefix and postfix `++` and `--`.
|
||||
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
|
||||
- [ ] Unary `void` and `delete`.
|
||||
- [ ] Arbitrary constructors and `new Promise(...)`.
|
||||
|
||||
## Promises and tools
|
||||
|
||||
- [x] Tool calls start eagerly and return supervised, run-once sandbox promises.
|
||||
- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
|
||||
- [x] `Promise.resolve` and `Promise.reject`.
|
||||
- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain
|
||||
values.
|
||||
- [x] `Promise.all` preserves result order and rejects on the first observed failure.
|
||||
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
|
||||
- [x] `Promise.race` interrupts losing in-flight tool calls.
|
||||
- [x] Un-awaited calls are drained before execution ends; unhandled failures become diagnostics.
|
||||
- [x] `try`/`catch` can handle awaited tool and promise failures.
|
||||
- [ ] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`. These calls currently settle
|
||||
before returning, so separately constructed combinator batches do not overlap as normal JavaScript promises do.
|
||||
- [ ] `Promise.any`.
|
||||
- [ ] Promise chaining with `.then`, `.catch`, and `.finally`.
|
||||
- [ ] Custom promise construction with `new Promise(...)`.
|
||||
- [ ] Async iterables, host streams, and stream consumption.
|
||||
|
||||
## Objects and properties
|
||||
|
||||
- [x] Own-field reads and writes on plain data objects.
|
||||
- [x] Computed property names and object spread.
|
||||
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
|
||||
- [x] `Object.keys` over arrays and tool references.
|
||||
- [x] Object identity is preserved by in-sandbox Object helpers.
|
||||
- [x] Blocked access to `__proto__`, `constructor`, and `prototype`.
|
||||
- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first.
|
||||
- [ ] `Object.groupBy`.
|
||||
- [ ] Object creation, descriptors, freezing/sealing, prototype APIs, and reflection APIs.
|
||||
- [ ] A final policy for legal data/tool keys named `__proto__`, `constructor`, or `prototype`.
|
||||
|
||||
## Arrays
|
||||
|
||||
- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`.
|
||||
- [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`.
|
||||
- [x] Searching/tests: `find`, `findIndex`, `findLast`, `findLastIndex`, `some`, `every`, `includes`, `indexOf`, and
|
||||
`lastIndexOf`.
|
||||
- [x] Aggregation: `reduce` and `reduceRight`.
|
||||
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
|
||||
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
|
||||
- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
|
||||
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
|
||||
- [ ] The mapper and `thisArg` forms of `Array.from`.
|
||||
- [ ] `Array.prototype.toSpliced`.
|
||||
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
|
||||
- [ ] Complete sparse-array parity.
|
||||
- [ ] Correct `findLast` return behavior when its predicate mutates the examined element.
|
||||
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Trimming: `trim`, `trimStart`, `trimEnd`, `trimLeft`, and `trimRight`.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
||||
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||
- [ ] Locale/options-aware `localeCompare` and locale formatting APIs.
|
||||
- [ ] Exact native coercion across every string method; CodeMode often requires explicit strings/numbers.
|
||||
- [ ] Native no-argument parity for `match()` and `search()`.
|
||||
|
||||
## Numbers and Math
|
||||
|
||||
- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`.
|
||||
- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`,
|
||||
`Number.parseInt`, and `Number.parseFloat`.
|
||||
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`.
|
||||
- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`,
|
||||
`POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`.
|
||||
- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`.
|
||||
- [x] Math methods: `random`, `max`, `min`, `abs`, `acos`, `acosh`, `asin`, `asinh`, `atan`, `atan2`, `atanh`,
|
||||
`floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`,
|
||||
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
|
||||
- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`.
|
||||
- [ ] Safe interpreter coercion for `++` and `--` rather than host `Number(...)` coercion.
|
||||
- [ ] Reliable feature detection for unknown static members.
|
||||
- [ ] `Math.sumPrecise`.
|
||||
- [ ] Global coercing `isFinite` and `isNaN`.
|
||||
|
||||
## JSON and console
|
||||
|
||||
- [x] `JSON.parse` and `JSON.stringify`.
|
||||
- [x] Numeric/string indentation for `JSON.stringify`.
|
||||
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
|
||||
- [x] Captured `console.dir` and `console.table`.
|
||||
- [ ] `JSON.parse` reviver callbacks.
|
||||
- [ ] `JSON.stringify` function/array replacers.
|
||||
- [ ] Other console methods, timers, counters, groups, and host console access.
|
||||
|
||||
## Date
|
||||
|
||||
- [x] `Date.now`, `Date.parse`, and `Date.UTC`.
|
||||
- [x] `new Date()` from the current time, epoch milliseconds, a date string, another Date, or local components.
|
||||
- [x] `getTime`, `valueOf`, `toISOString`, `toJSON`, and deterministic ISO `toString`.
|
||||
- [x] Local getters: `getFullYear`, `getMonth`, `getDate`, `getDay`, `getHours`, `getMinutes`, `getSeconds`, and
|
||||
`getMilliseconds`.
|
||||
- [x] UTC getters: `getUTCFullYear`, `getUTCMonth`, `getUTCDate`, `getUTCDay`, `getUTCHours`, `getUTCMinutes`,
|
||||
`getUTCSeconds`, and `getUTCMilliseconds`.
|
||||
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
|
||||
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
|
||||
- [ ] Date setters.
|
||||
- [ ] `toUTCString`, locale methods, and other Date formatting methods.
|
||||
- [ ] Exact native constructor coercion, local-time, and loose-equality semantics.
|
||||
- [ ] Native `RangeError` branding for invalid `toISOString()` calls.
|
||||
- [ ] Temporal and Intl date/time APIs.
|
||||
|
||||
## Regular expressions
|
||||
|
||||
- [x] Literal and `new RegExp(pattern, flags)` construction.
|
||||
- [x] `test`, `exec`, and `toString`.
|
||||
- [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`.
|
||||
- [x] Captures, named groups, match indexes, and stateful global matching.
|
||||
- [x] Integration with supported String methods, including async function replacers.
|
||||
- [ ] Writable `lastIndex`.
|
||||
- [ ] Exposed metadata for the `d` and `v` flags.
|
||||
- [ ] `RegExp.escape`.
|
||||
- [ ] Protection from pathological host-regex backtracking beyond the cooperative execution timeout.
|
||||
|
||||
## Map and Set
|
||||
|
||||
- [x] `new Map()` from entry arrays or another Map.
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] `new Set()` from arrays, strings, or another Set.
|
||||
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
|
||||
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
|
||||
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
|
||||
- [ ] Set composition methods such as `union`, `intersection`, `difference`, and relation predicates.
|
||||
- [ ] WeakMap and WeakSet.
|
||||
- [ ] Native iterator objects and custom iterators.
|
||||
|
||||
## URL and URI helpers
|
||||
|
||||
- [x] `encodeURI`, `encodeURIComponent`, `decodeURI`, and `decodeURIComponent`.
|
||||
- [x] `new URL(input, base)`, `URL.canParse`, and `URL.parse`.
|
||||
- [x] URL `toString`, `toJSON`, and linked `searchParams`.
|
||||
- [x] Readable URL fields: `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`,
|
||||
`pathname`, `search`, and `hash`.
|
||||
- [x] Writable URL fields except `origin`.
|
||||
- [x] `new URLSearchParams()` from query strings, data objects, pairs, Maps, and URLSearchParams.
|
||||
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
|
||||
`entries`, `toString`, and `size`.
|
||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||
|
||||
## Errors and diagnostics
|
||||
|
||||
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
|
||||
or without `new`.
|
||||
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
|
||||
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
|
||||
- [x] Catchable interpreter failures and awaited tool failures.
|
||||
- [x] Source locations on unsupported-syntax diagnostics when available.
|
||||
- [x] Sanitized model-visible diagnostics and explicit safe `ToolError` messages.
|
||||
- [ ] Distinct public categories for user throws, tool refusal, tool internal failure, invalid returned data, compile
|
||||
failures, and genuine interpreter defects.
|
||||
- [ ] Preservation of detailed recoverable failure categories inside `catch` and `Promise.allSettled`.
|
||||
|
||||
## Known semantic gaps
|
||||
|
||||
These are actionable implementation items. Check them off only when behavior and direct tests land.
|
||||
|
||||
- [ ] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
|
||||
- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency.
|
||||
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
|
||||
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
|
||||
`null` in render-only or OpenAPI tool calls.
|
||||
- [ ] Make regular-expression execution genuinely timeout-safe, or narrow the timeout guarantee explicitly.
|
||||
- [ ] Complete lexical declaration and destructuring semantics listed above.
|
||||
- [ ] Make callback acceptance and async callback behavior consistent across built-ins.
|
||||
- [ ] Reject every unsupported callback argument explicitly rather than silently ignoring it.
|
||||
- [ ] Resolve the built-in correctness gaps listed in the Array, String, Number, Date, and RegExp sections.
|
||||
- [ ] Make tool search tokenization Unicode-aware.
|
||||
- [ ] Design explicit tagged representations and size limits before adding binary values or streams.
|
||||
|
||||
## Intentional exclusions
|
||||
|
||||
These constraints preserve CodeMode's confinement and host-neutral scope. They are not TODO items.
|
||||
|
||||
- Ambient filesystem, process, environment, credential, network, or application access.
|
||||
- `fetch`, timers, crypto, or other host globals unless a future host explicitly supplies a bounded capability.
|
||||
- Static imports, dynamic imports, modules, npm packages, and module loading.
|
||||
- `eval`, `Function(...)`, arbitrary host execution, and prototype mutation.
|
||||
- Generic permission prompts, authorization policy, persistence, replay, or exactly-once side effects.
|
||||
- Arbitrary method dispatch outside the documented allowlists.
|
||||
- Automatic parsing of text tool results as JSON.
|
||||
- Full browser, Node.js, Bun, or ECMAScript runtime compatibility.
|
||||
325
packages/codemode/test/array-callbacks-test262.test.ts
Normal file
325
packages/codemode/test/array-callbacks-test262.test.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/*
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/built-ins/Array/prototype/map/15.4.4.19-8-1.js
|
||||
* - test/built-ins/Array/prototype/map/15.4.4.19-8-2.js
|
||||
* - test/built-ins/Array/prototype/map/15.4.4.19-8-b-1.js
|
||||
* - test/built-ins/Array/prototype/filter/15.4.4.20-9-1.js
|
||||
* - test/built-ins/Array/prototype/filter/15.4.4.20-9-2.js
|
||||
* - test/built-ins/Array/prototype/filter/15.4.4.20-9-b-1.js
|
||||
* - test/built-ins/Array/prototype/find/predicate-call-parameters.js
|
||||
* - test/built-ins/Array/prototype/find/predicate-not-called-on-empty-array.js
|
||||
* - test/built-ins/Array/prototype/find/return-found-value-predicate-result-is-true.js
|
||||
* - test/built-ins/Array/prototype/find/return-undefined-if-predicate-returns-false-value.js
|
||||
* - test/built-ins/Array/prototype/findIndex/predicate-call-parameters.js
|
||||
* - test/built-ins/Array/prototype/findIndex/return-index-predicate-result-is-true.js
|
||||
* - test/built-ins/Array/prototype/findIndex/return-negative-one-if-predicate-returns-false-value.js
|
||||
* - test/built-ins/Array/prototype/findLast/predicate-call-parameters.js
|
||||
* - test/built-ins/Array/prototype/findLast/return-found-value-predicate-result-is-true.js
|
||||
* - test/built-ins/Array/prototype/findLast/return-undefined-if-predicate-returns-false-value.js
|
||||
* - test/built-ins/Array/prototype/findLastIndex/predicate-call-parameters.js
|
||||
* - test/built-ins/Array/prototype/findLastIndex/return-index-predicate-result-is-true.js
|
||||
* - test/built-ins/Array/prototype/findLastIndex/return-negative-one-if-predicate-returns-false-value.js
|
||||
* - test/built-ins/Array/prototype/some/15.4.4.17-7-1.js
|
||||
* - test/built-ins/Array/prototype/some/15.4.4.17-8-1.js
|
||||
* - test/built-ins/Array/prototype/every/15.4.4.16-7-1.js
|
||||
* - test/built-ins/Array/prototype/every/15.4.4.16-8-1.js
|
||||
* - test/built-ins/Array/prototype/forEach/15.4.4.18-7-1.js
|
||||
* - test/built-ins/Array/prototype/forEach/15.4.4.18-7-2.js
|
||||
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-5.js
|
||||
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-1.js
|
||||
* - test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js
|
||||
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-5.js
|
||||
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-1.js
|
||||
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js
|
||||
* - test/built-ins/Array/prototype/flatMap/depth-always-one.js
|
||||
* - test/built-ins/Array/prototype/flatMap/mapperfunction-throws.js
|
||||
* - test/built-ins/Array/prototype/sort/S15.4.4.11_A1.1_T1.js
|
||||
* - test/built-ins/Array/prototype/sort/S15.4.4.11_A2.1_T1.js
|
||||
* - test/built-ins/Array/prototype/sort/stability-5-elements.js
|
||||
* - test/built-ins/Array/prototype/toSorted/comparefn-controls-sort.js
|
||||
* - test/built-ins/Array/prototype/toSorted/comparefn-default.js
|
||||
* - test/built-ins/Array/prototype/toSorted/immutable.js
|
||||
* - test/built-ins/Array/prototype/toSorted/zero-or-one-element.js
|
||||
*
|
||||
* Copyright (C) 2015 the V8 project authors. All rights reserved.
|
||||
* Copyright (C) 2018 Mathias Bynens. All rights reserved.
|
||||
* Copyright (C) 2018 Shilpi Jain and Michael Ficarra. All rights reserved.
|
||||
* Copyright (C) 2021 Igalia, S.L. All rights reserved.
|
||||
* Copyright (C) 2021 Microsoft. All rights reserved.
|
||||
* Copyright (C) 2025 Google. All rights reserved.
|
||||
* Copyright (C) 2026 Garham Lee. All rights reserved.
|
||||
* Copyright (c) 2012 Ecma International. All rights reserved.
|
||||
* Copyright 2009 the Sputnik authors. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/map/15.4.4.19-8-1.js",
|
||||
code: `const input = [1, 2]; input[3] = 4; input[4] = 5; const result = input.map((value) => { input[2] = 3; input[5] = 6; return 1 }); return [result.length, result[5] === undefined]`,
|
||||
expected: [5, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/map/15.4.4.19-8-2.js",
|
||||
code: `const input = [1, 2, 3, 4, 5]; const result = input.map((value) => { input[4] = -1; return value > 0 ? 1 : 0 }); return [result.length, result[4]]`,
|
||||
expected: [5, 0],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/map/15.4.4.19-8-b-1.js",
|
||||
code: `const input = []; input[10] = 0; input.pop(); input[1] = undefined; let calls = 0; const result = input.map(() => { calls += 1; return 1 }); return [result.length, calls, 0 in result, 1 in result]`,
|
||||
expected: [10, 1, false, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/filter/15.4.4.20-9-1.js",
|
||||
code: `const input = [1, 2]; input[3] = 4; input[4] = 5; const result = input.filter(() => { input[2] = 3; input[5] = 6; return true }); return result`,
|
||||
expected: [1, 2, 3, 4, 5],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/filter/15.4.4.20-9-2.js",
|
||||
code: `const input = [1, 2, 3, 4, 5]; return input.filter((value) => { input[2] = -1; input[4] = -1; return value > 0 })`,
|
||||
expected: [1, 2, 4],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/filter/15.4.4.20-9-b-1.js",
|
||||
code: `const input = []; input[9] = 0; input.pop(); input[1] = undefined; let calls = 0; const result = input.filter(() => { calls += 1; return false }); return [result, calls]`,
|
||||
expected: [[], 1],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/find/predicate-call-parameters.js",
|
||||
code: `const input = [10, 20]; const seen = []; input.find((value, index, receiver) => { seen.push([value, index, receiver === input]); return false }); return seen`,
|
||||
expected: [
|
||||
[10, 0, true],
|
||||
[20, 1, true],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/find/return-found-value-predicate-result-is-true.js",
|
||||
code: `return [1, 2, 3].find((value) => value > 1)`,
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/find/return-undefined-if-predicate-returns-false-value.js",
|
||||
code: `return [1, 2, 3].find((value) => value > 4) === undefined`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/find/predicate-not-called-on-empty-array.js",
|
||||
code: `let calls = 0; const result = [].find(() => { calls += 1; return true }); return [result === undefined, calls]`,
|
||||
expected: [true, 0],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/findIndex/predicate-call-parameters.js",
|
||||
code: `const input = [10, 20]; const seen = []; input.findIndex((value, index, receiver) => { seen.push([value, index, receiver === input]); return false }); return seen`,
|
||||
expected: [
|
||||
[10, 0, true],
|
||||
[20, 1, true],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/findIndex/return-index-predicate-result-is-true.js",
|
||||
code: `return [1, 2, 3].findIndex((value) => value > 1)`,
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/findIndex/return-negative-one-if-predicate-returns-false-value.js",
|
||||
code: `return [1, 2, 3].findIndex((value) => value > 4)`,
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/findLast/predicate-call-parameters.js",
|
||||
code: `const input = [10, 20]; const seen = []; input.findLast((value, index, receiver) => { seen.push([value, index, receiver === input]); return false }); return seen`,
|
||||
expected: [
|
||||
[20, 1, true],
|
||||
[10, 0, true],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/findLast/return-found-value-predicate-result-is-true.js",
|
||||
code: `return [1, 2, 3].findLast((value) => value < 3)`,
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/findLast/return-undefined-if-predicate-returns-false-value.js",
|
||||
code: `return [1, 2, 3].findLast((value) => value > 4) === undefined`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/findLastIndex/predicate-call-parameters.js",
|
||||
code: `const input = [10, 20]; const seen = []; input.findLastIndex((value, index, receiver) => { seen.push([value, index, receiver === input]); return false }); return seen`,
|
||||
expected: [
|
||||
[20, 1, true],
|
||||
[10, 0, true],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/findLastIndex/return-index-predicate-result-is-true.js",
|
||||
code: `return [1, 2, 3].findLastIndex((value) => value < 3)`,
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/findLastIndex/return-negative-one-if-predicate-returns-false-value.js",
|
||||
code: `return [1, 2, 3].findLastIndex((value) => value > 4)`,
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/some/15.4.4.17-7-1.js",
|
||||
code: `const input = [1, 2]; input[3] = 4; input[4] = 5; const seen = []; const result = input.some((value) => { input[2] = 3; seen.push(value); return false }); return [result, seen.includes(3)]`,
|
||||
expected: [false, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/some/15.4.4.17-8-1.js",
|
||||
code: `return [].some(() => true)`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/every/15.4.4.16-7-1.js",
|
||||
code: `const input = [1, 2]; input[3] = 4; input[4] = 5; const seen = []; const result = input.every((value) => { input[2] = 3; seen.push(value); return true }); return [result, seen.includes(3)]`,
|
||||
expected: [true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/every/15.4.4.16-8-1.js",
|
||||
code: `return [].every(() => false)`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/forEach/15.4.4.18-7-1.js",
|
||||
code: `const input = [1, 2]; input[3] = 4; input[4] = 5; let calls = 0; input.forEach(() => { calls += 1; input[2] = 3; input[5] = 6 }); return calls`,
|
||||
expected: 5,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/forEach/15.4.4.18-7-2.js",
|
||||
code: `const input = [1, 2, 3]; const seen = []; input.forEach((value, index) => { seen.push(value); if (index === 0) input.pop() }); return seen`,
|
||||
expected: [1, 2],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduce/15.4.4.21-9-1.js",
|
||||
code: `const input = [1, 2]; input[3] = 4; input[4] = "5"; return input.reduce((accumulator, value) => { input[2] = 3; input[5] = 6; return accumulator + value })`,
|
||||
expected: "105",
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduce/15.4.4.21-9-5.js",
|
||||
code: `let calls = 0; const result = [1].reduce(() => { calls += 1; return 2 }); return [result, calls]`,
|
||||
expected: [1, 0],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js",
|
||||
code: `const input = [1, 2, 3, 4, 5]; input.reduce(() => 1); return input`,
|
||||
expected: [1, 2, 3, 4, 5],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-1.js",
|
||||
code: `const input = ["1", 2]; input[3] = 4; input[4] = "5"; return input.reduceRight((accumulator, value) => { input[2] = 3; input[5] = 6; return accumulator + value })`,
|
||||
expected: "54321",
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-5.js",
|
||||
code: `let calls = 0; const result = [1].reduceRight(() => { calls += 1; return 2 }); return [result, calls]`,
|
||||
expected: [1, 0],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js",
|
||||
code: `const input = [1, 2, 3, 4, 5]; input.reduceRight(() => 1); return input`,
|
||||
expected: [1, 2, 3, 4, 5],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/flatMap/depth-always-one.js",
|
||||
code: `return [1, 2, 3].flatMap((value) => [[value * 2]])`,
|
||||
expected: [[2], [4], [6]],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/flatMap/mapperfunction-throws.js",
|
||||
code: `try { [1, 2].flatMap(() => { throw "stop" }) } catch (error) { return error === "stop" } return false`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/sort/S15.4.4.11_A1.1_T1.js",
|
||||
code: `const input = []; input[2] = 0; input.pop(); input.sort(); return [input.length, input[0] === undefined, input[1] === undefined]`,
|
||||
expected: [2, true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/sort/S15.4.4.11_A2.1_T1.js",
|
||||
code: `return ["z", "y", "x", "w", "v", "u", "t", "s", "r", "q", "p", "o", "n", "M", "L", "K", "J", "I", "H", "G", "F", "E", "D", "C", "B", "A"].sort()`,
|
||||
expected: [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"v",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/sort/stability-5-elements.js",
|
||||
code: `const input = [{ n: "A", r: 2 }, { n: "B", r: 3 }, { n: "C", r: 2 }, { n: "D", r: 3 }, { n: "E", r: 3 }]; return input.sort((left, right) => right.r - left.r).map((item) => item.n).join("")`,
|
||||
expected: "BDEAC",
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/toSorted/comparefn-controls-sort.js",
|
||||
code: `const mixed = [333, 33, 3, 222, 22, 2, 111, 11, 1]; return [[1, 2, 3, 4].toSorted((a, b) => a - b), [4, 3, 2, 1].toSorted((a, b) => a - b), mixed.toSorted((a, b) => a - b), [1, 2, 3, 4].toSorted((a, b) => b - a), [4, 3, 2, 1].toSorted((a, b) => b - a), mixed.toSorted((a, b) => b - a)]`,
|
||||
expected: [
|
||||
[1, 2, 3, 4],
|
||||
[1, 2, 3, 4],
|
||||
[1, 2, 3, 11, 22, 33, 111, 222, 333],
|
||||
[4, 3, 2, 1],
|
||||
[4, 3, 2, 1],
|
||||
[333, 222, 111, 33, 22, 11, 3, 2, 1],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/toSorted/comparefn-default.js",
|
||||
code: `return [[1, 2, 3, 4].toSorted(), [4, 3, 2, 1].toSorted(), ["a", 2, 1, "z"].toSorted(), [333, 33, 3, 222, 22, 2, 111, 11, 1].toSorted()]`,
|
||||
expected: [
|
||||
[1, 2, 3, 4],
|
||||
[1, 2, 3, 4],
|
||||
[1, 2, "a", "z"],
|
||||
[1, 11, 111, 2, 22, 222, 3, 33, 333],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/toSorted/immutable.js",
|
||||
code: `const input = [2, 0, 1]; const result = input.toSorted(); return [input, result !== input]`,
|
||||
expected: [[2, 0, 1], true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/toSorted/zero-or-one-element.js",
|
||||
code: `const zero = []; const one = [1]; const zeroResult = zero.toSorted(); const oneResult = one.toSorted(); return [zeroResult, oneResult, zeroResult !== zero, oneResult !== one]`,
|
||||
expected: [[], [1], true, true],
|
||||
},
|
||||
] as const
|
||||
|
||||
describe("Test262 Array callback adaptations", () => {
|
||||
for (const item of cases) {
|
||||
test(item.path, async () => {
|
||||
expect(await value(item.code)).toEqual(item.expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
323
packages/codemode/test/array-core-test262.test.ts
Normal file
323
packages/codemode/test/array-core-test262.test.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
/*
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/built-ins/Array/prototype/includes/samevaluezero.js
|
||||
* - test/built-ins/Array/prototype/includes/using-fromindex.js
|
||||
* - test/built-ins/Array/prototype/join/S15.4.4.5_A3.1_T1.js
|
||||
* - test/built-ins/Array/prototype/join/S15.4.4.5_A3.2_T1.js
|
||||
* - test/built-ins/Array/prototype/slice/S15.4.4.10_A1.2_T2.js
|
||||
* - test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T1.js
|
||||
* - test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T2.js
|
||||
* - test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T3.js
|
||||
* - test/built-ins/Array/prototype/indexOf/fromindex-zero-conversion.js
|
||||
* - test/built-ins/Array/prototype/indexOf/length-zero-returns-minus-one.js
|
||||
* - test/built-ins/Array/prototype/lastIndexOf/fromindex-zero-conversion.js
|
||||
* - test/built-ins/Array/prototype/lastIndexOf/length-zero-returns-minus-one.js
|
||||
* - test/built-ins/Array/prototype/at/returns-item.js
|
||||
* - test/built-ins/Array/prototype/at/returns-item-relative-index.js
|
||||
* - test/built-ins/Array/prototype/at/returns-undefined-for-out-of-range-index.js
|
||||
* - test/built-ins/Array/prototype/flat/null-undefined-elements.js
|
||||
* - test/built-ins/Array/prototype/flat/positive-infinity.js
|
||||
* - test/built-ins/Array/prototype/reverse/S15.4.4.8_A1_T1.js
|
||||
* - test/built-ins/Array/prototype/toReversed/immutable.js
|
||||
* - test/built-ins/Array/prototype/toReversed/zero-or-one-element.js
|
||||
* - test/built-ins/Array/prototype/with/immutable.js
|
||||
* - test/built-ins/Array/prototype/with/index-negative.js
|
||||
* - test/built-ins/Array/prototype/push/S15.4.4.7_A1_T1.js
|
||||
* - test/built-ins/Array/prototype/pop/S15.4.4.6_A1.1_T1.js
|
||||
* - test/built-ins/Array/prototype/shift/S15.4.4.9_A1.1_T1.js
|
||||
* - test/built-ins/Array/prototype/unshift/S15.4.4.13_A1_T1.js
|
||||
* - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.1_T1.js
|
||||
* - test/built-ins/Array/prototype/splice/S15.4.4.12_A1.2_T1.js
|
||||
* - test/built-ins/Array/prototype/splice/called_with_one_argument.js
|
||||
* - test/built-ins/Array/prototype/fill/fill-values.js
|
||||
* - test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js
|
||||
* - test/built-ins/Array/prototype/fill/return-this.js
|
||||
* - test/built-ins/Array/prototype/copyWithin/non-negative-target-start-and-end.js
|
||||
* - test/built-ins/Array/prototype/copyWithin/return-this.js
|
||||
* - test/built-ins/Array/prototype/keys/iteration.js
|
||||
* - test/built-ins/Array/prototype/values/iteration.js
|
||||
* - test/built-ins/Array/prototype/entries/iteration.js
|
||||
* - test/built-ins/Array/isArray/15.4.3.2-0-3.js
|
||||
* - test/built-ins/Array/isArray/15.4.3.2-0-4.js
|
||||
* - test/built-ins/Array/from/from-array.js
|
||||
* - test/built-ins/Array/from/from-string.js
|
||||
* - test/built-ins/Array/from/array-like-has-length-but-no-indexes-with-values.js
|
||||
* - test/built-ins/Array/of/creates-a-new-array-from-arguments.js
|
||||
*
|
||||
* Copyright (C) 2015 André Bargull. All rights reserved.
|
||||
* Copyright (C) 2015 the V8 project authors. All rights reserved.
|
||||
* Copyright (C) 2016 the V8 project authors. All rights reserved.
|
||||
* Copyright (C) 2018 Shilpi Jain and Michael Ficarra. All rights reserved.
|
||||
* Copyright (C) 2020 Alexey Shvayka. All rights reserved.
|
||||
* Copyright (C) 2020 Rick Waldron. All rights reserved.
|
||||
* Copyright (C) 2021 Igalia, S.L. All rights reserved.
|
||||
* Copyright (c) 2012 Ecma International. All rights reserved.
|
||||
* Copyright (c) 2014 Hank Yates. All rights reserved.
|
||||
* Copyright (c) 2015 the V8 project authors. All rights reserved.
|
||||
* Copyright (c) 2021 Rick Waldron. All rights reserved.
|
||||
* Copyright 2009 the Sputnik authors. All rights reserved.
|
||||
* Copyright 2015 Microsoft Corporation. All rights reserved.
|
||||
* Copyright 2016 The V8 project authors. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/includes/samevaluezero.js",
|
||||
code: `const input = [42, 0, 1, NaN]; return [input.includes(42), input.includes("42"), input.includes([42]), input.includes(true), input.includes(NaN), input.includes(0), input.includes(-0), input.includes(null), input.includes("")]`,
|
||||
expected: [true, false, false, false, true, true, true, false, false],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/includes/using-fromindex.js",
|
||||
code: `const input = ["a", "b", "c"]; return [input.includes("a", 0), input.includes("a", 1), input.includes("a", -4), input.includes("a", -3), input.includes("a", -2), input.includes("b", 0), input.includes("b", 1), input.includes("b", 2), input.includes("b", -3), input.includes("b", -2), input.includes("b", -1), input.includes("c", 0), input.includes("c", 2), input.includes("c", 3), input.includes("c", -3), input.includes("c", -1)]`,
|
||||
expected: [true, false, true, true, false, true, true, false, true, true, false, true, true, false, true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/join/S15.4.4.5_A3.1_T1.js",
|
||||
code: `return [[0, 1, 2, 3].join("&"), [0, 1, 2, 3].join("")]`,
|
||||
expected: ["0&1&2&3", "0123"],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/join/S15.4.4.5_A3.2_T1.js",
|
||||
code: `return [
|
||||
["", "", ""].join(""),
|
||||
["&", "&", "&"].join("&"),
|
||||
[true, true, true].join(),
|
||||
[null, null, null].join(),
|
||||
[undefined, undefined, undefined].join(),
|
||||
[Infinity, Infinity, Infinity].join(),
|
||||
[NaN, NaN, NaN].join(),
|
||||
]`,
|
||||
expected: ["", "&&&&&", "true,true,true", ",,", ",,", "Infinity,Infinity,Infinity", "NaN,NaN,NaN"],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/slice/S15.4.4.10_A1.2_T2.js",
|
||||
code: `return [0, 1, 2, 3, 4].slice(-1, 5)`,
|
||||
expected: [4],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T1.js",
|
||||
code: `return [].concat([0, 1], [2, 3, 4])`,
|
||||
expected: [0, 1, 2, 3, 4],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T2.js",
|
||||
code: `const object = { value: 1 }; const result = [0].concat(object, [1, 2], -1, true, "NaN"); return [result, result[1] === object]`,
|
||||
expected: [[0, { value: 1 }, 1, 2, -1, true, "NaN"], true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/concat/S15.4.4.4_A1_T3.js",
|
||||
code: `const input = [0, 1]; const result = input.concat(); return [result, result !== input]`,
|
||||
expected: [[0, 1], true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/indexOf/fromindex-zero-conversion.js",
|
||||
code: `const result = [true].indexOf(true, -0); return [result, 1 / result === Infinity]`,
|
||||
expected: [0, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/indexOf/length-zero-returns-minus-one.js",
|
||||
code: `return [].indexOf(1)`,
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/lastIndexOf/fromindex-zero-conversion.js",
|
||||
code: `const result = [true].lastIndexOf(true, -0); return [result, 1 / result === Infinity]`,
|
||||
expected: [0, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/lastIndexOf/length-zero-returns-minus-one.js",
|
||||
code: `return [].lastIndexOf(1)`,
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/at/returns-item.js",
|
||||
code: `const input = [1, 2, 3, 4, undefined, 5]; return [input.at(0), input.at(1), input.at(2), input.at(3), input.at(4) === undefined, input.at(5)]`,
|
||||
expected: [1, 2, 3, 4, true, 5],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/at/returns-item-relative-index.js",
|
||||
code: `const input = [1, 2, 3, 4, undefined, 5]; return [input.at(0), input.at(-1), input.at(-2) === undefined, input.at(-3), input.at(-4)]`,
|
||||
expected: [1, 5, true, 4, 3],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/at/returns-undefined-for-out-of-range-index.js",
|
||||
code: `const input = []; return [input.at(-2) === undefined, input.at(0) === undefined, input.at(1) === undefined]`,
|
||||
expected: [true, true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/flat/null-undefined-elements.js",
|
||||
code: `const result = [1, [null, [undefined]]].flat(2); return [result.length, result[0], result[1] === null, result[2] === undefined]`,
|
||||
expected: [3, 1, true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/flat/positive-infinity.js",
|
||||
code: `return [1, [2, [3, [4]]]].flat(Infinity)`,
|
||||
expected: [1, 2, 3, 4],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reverse/S15.4.4.8_A1_T1.js",
|
||||
code: `const empty = []; const one = [1]; const input = [1, 2]; const emptyResult = empty.reverse(); const oneResult = one.reverse(); const result = input.reverse(); return [emptyResult === empty, oneResult === one, result === input, input]`,
|
||||
expected: [true, true, true, [2, 1]],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/toReversed/immutable.js",
|
||||
code: `const input = [0, 1, 2]; const result = input.toReversed(); return [input, result !== input]`,
|
||||
expected: [[0, 1, 2], true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/toReversed/zero-or-one-element.js",
|
||||
code: `const zero = []; const one = [1]; const zeroResult = zero.toReversed(); const oneResult = one.toReversed(); return [zeroResult, oneResult, zeroResult !== zero, oneResult !== one]`,
|
||||
expected: [[], [1], true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/with/immutable.js",
|
||||
code: `const input = [0, 1, 2]; const result = input.with(1, 3); return [input, result !== input, input.with(1, 1) !== input]`,
|
||||
expected: [[0, 1, 2], true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/with/index-negative.js",
|
||||
code: `const input = [0, 1, 2]; return [input.with(-1, 4), input.with(-3, 4)]`,
|
||||
expected: [
|
||||
[0, 1, 4],
|
||||
[4, 1, 2],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/push/S15.4.4.7_A1_T1.js",
|
||||
code: `const input = []; return [input.push(1), input.push(), input.push(-1), input]`,
|
||||
expected: [1, 1, 2, [1, -1]],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/pop/S15.4.4.6_A1.1_T1.js",
|
||||
code: `const input = []; return [input.pop() === undefined, input.length]`,
|
||||
expected: [true, 0],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/shift/S15.4.4.9_A1.1_T1.js",
|
||||
code: `const input = []; return [input.shift() === undefined, input.length]`,
|
||||
expected: [true, 0],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/unshift/S15.4.4.13_A1_T1.js",
|
||||
code: `const input = []; return [input.unshift(1), input[0], input.unshift(), input.unshift(-1), input]`,
|
||||
expected: [1, 1, 1, 2, [-1, 1]],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/splice/S15.4.4.12_A1.1_T1.js",
|
||||
code: `const input = [0, 1, 2, 3]; const removed = input.splice(0, 3); return [input, removed]`,
|
||||
expected: [[3], [0, 1, 2]],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/splice/S15.4.4.12_A1.2_T1.js",
|
||||
code: `const input = [0, 1]; const removed = input.splice(-2, -1); return [input, removed]`,
|
||||
expected: [[0, 1], []],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/splice/called_with_one_argument.js",
|
||||
code: `const input = ["first", "second", "third"]; const removed = input.splice(1); return [input, removed]`,
|
||||
expected: [["first"], ["second", "third"]],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/fill/fill-values-custom-start-and-end.js",
|
||||
code: `const input = [0, 0, 0, 0, 0]; input.fill(8, -3, 4); const sparse = []; sparse[4] = 0; sparse.fill(8, 1, 3); return [[0, 0, 0].fill(8, 1, 2), input, [0, 0, 0, 0, 0].fill(8, -2, -1), [0, 0, 0, 0, 0].fill(8, -1, -3), [0 in sparse, sparse[1], sparse[2], 3 in sparse, sparse[4]]]`,
|
||||
expected: [
|
||||
[0, 8, 0],
|
||||
[0, 0, 8, 8, 0],
|
||||
[0, 0, 0, 8, 0],
|
||||
[0, 0, 0, 0, 0],
|
||||
[false, 8, 8, false, 0],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/fill/return-this.js",
|
||||
code: `const input = []; return input.fill(1) === input`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/fill/fill-values.js",
|
||||
code: `const omitted = [0, 0].fill(); return [[].fill(8), omitted.map((value) => value === undefined), [0, 0, 0].fill(8)]`,
|
||||
expected: [[], [true, true], [8, 8, 8]],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/copyWithin/non-negative-target-start-and-end.js",
|
||||
code: `return [[0, 1, 2, 3].copyWithin(0, 0, 0), [0, 1, 2, 3].copyWithin(0, 0, 2), [0, 1, 2, 3].copyWithin(0, 1, 2), [0, 1, 2, 3].copyWithin(1, 0, 2), [0, 1, 2, 3, 4, 5].copyWithin(1, 3, 5)]`,
|
||||
expected: [
|
||||
[0, 1, 2, 3],
|
||||
[0, 1, 2, 3],
|
||||
[1, 1, 2, 3],
|
||||
[0, 0, 1, 3],
|
||||
[0, 3, 4, 3, 4, 5],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/copyWithin/return-this.js",
|
||||
code: `const input = [0, 1, 2, 3]; const result = input.copyWithin(1, 0, 2); return [input, result === input]`,
|
||||
expected: [[0, 0, 1, 3], true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/keys/iteration.js",
|
||||
code: `return ["a", "b", "c"].keys()`,
|
||||
expected: [0, 1, 2],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/values/iteration.js",
|
||||
code: `return ["a", "b", "c"].values()`,
|
||||
expected: ["a", "b", "c"],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/entries/iteration.js",
|
||||
code: `return ["a", "b"].entries()`,
|
||||
expected: [
|
||||
[0, "a"],
|
||||
[1, "b"],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/isArray/15.4.3.2-0-3.js",
|
||||
code: `return [Array.isArray([]), Array.isArray([1]), Array.isArray(Array.of(1))]`,
|
||||
expected: [true, true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/isArray/15.4.3.2-0-4.js",
|
||||
code: `return [Array.isArray(42), Array.isArray({}), Array.isArray(null), Array.isArray("array")]`,
|
||||
expected: [false, false, false, false],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/from/from-array.js",
|
||||
code: `const input = [0, "foo", undefined, Infinity]; const result = Array.from(input); return [result.length, result[0], result[1], result[2] === undefined, result[3] === Infinity, result !== input, result instanceof Array]`,
|
||||
expected: [4, 0, "foo", true, true, true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/from/from-string.js",
|
||||
code: `return Array.from("Test")`,
|
||||
expected: ["T", "e", "s", "t"],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/from/array-like-has-length-but-no-indexes-with-values.js",
|
||||
code: `const result = Array.from({ length: 5 }); const mapped = result.map(() => 1); return [result.length, result.map((value) => value === undefined), mapped.length, mapped]`,
|
||||
expected: [5, [true, true, true, true, true], 5, [1, 1, 1, 1, 1]],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/of/creates-a-new-array-from-arguments.js",
|
||||
code: `const mixed = Array.of(undefined, false, null, undefined); return [Array.of("Mike", "Rick", "Leo"), mixed.length, mixed[0] === undefined, mixed[1], mixed[2], mixed[3] === undefined, Array.of()]`,
|
||||
expected: [["Mike", "Rick", "Leo"], 4, true, false, null, true, []],
|
||||
},
|
||||
] as const
|
||||
|
||||
describe("Test262 Array core adaptations", () => {
|
||||
for (const item of cases) {
|
||||
test(item.path, async () => {
|
||||
expect(await value(item.code)).toEqual(item.expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -75,6 +75,62 @@
|
|||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"operationId": "v2.server.get",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"urls"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Return the URLs that can be used to connect to this server.",
|
||||
"summary": "Get server information"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -18676,6 +18732,7 @@
|
|||
"required": [
|
||||
"id",
|
||||
"sessionID",
|
||||
"title",
|
||||
"mode",
|
||||
"fields"
|
||||
],
|
||||
|
|
@ -18714,6 +18771,7 @@
|
|||
"required": [
|
||||
"id",
|
||||
"sessionID",
|
||||
"title",
|
||||
"mode",
|
||||
"url"
|
||||
],
|
||||
|
|
@ -18791,6 +18849,7 @@
|
|||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"mode"
|
||||
],
|
||||
"additionalProperties": false
|
||||
|
|
@ -24584,6 +24643,7 @@
|
|||
"required": [
|
||||
"id",
|
||||
"sessionID",
|
||||
"title",
|
||||
"mode",
|
||||
"fields"
|
||||
],
|
||||
|
|
@ -24622,6 +24682,7 @@
|
|||
"required": [
|
||||
"id",
|
||||
"sessionID",
|
||||
"title",
|
||||
"mode",
|
||||
"url"
|
||||
],
|
||||
|
|
@ -24844,88 +24905,6 @@
|
|||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Todo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Brief description of the task"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Current status of the task: pending, in_progress, completed, cancelled"
|
||||
},
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"description": "Priority level of the task: high, medium, low"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content",
|
||||
"status",
|
||||
"priority"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"todo.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"todo.updated"
|
||||
]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Todo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionID",
|
||||
"todos"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"created",
|
||||
"type",
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionStatus": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -26475,9 +26454,6 @@
|
|||
{
|
||||
"$ref": "#/components/schemas/form.cancelled"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/session.status"
|
||||
},
|
||||
|
|
@ -27072,6 +27048,9 @@
|
|||
{
|
||||
"name": "health"
|
||||
},
|
||||
{
|
||||
"name": "server"
|
||||
},
|
||||
{
|
||||
"name": "location"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,11 +42,6 @@ describe("H2: string property access reads as undefined (not a throw)", () => {
|
|||
test("unknown property on a number is undefined", async () => {
|
||||
expect(await value(`return (5).foo ?? "n"`)).toBe("n")
|
||||
})
|
||||
|
||||
test("supported string methods still work", async () => {
|
||||
expect(await value(`return "AB".toLowerCase()`)).toBe("ab")
|
||||
expect(await value(`return "hello".length`)).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("H3: array property access reads as undefined (not a throw)", () => {
|
||||
|
|
@ -63,8 +58,7 @@ describe("H3: array property access reads as undefined (not a throw)", () => {
|
|||
expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true)
|
||||
})
|
||||
|
||||
test("supported array methods and indexing still work", async () => {
|
||||
expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4])
|
||||
test("array indexing still works", async () => {
|
||||
expect(await value(`return [1,2,3][9] === undefined`)).toBe(true)
|
||||
expect(await value(`return [1,2,3][9]`)).toBeNull()
|
||||
})
|
||||
|
|
@ -202,9 +196,6 @@ describe("Error values and instanceof", () => {
|
|||
"TypeError",
|
||||
true,
|
||||
])
|
||||
expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual(
|
||||
["RangeError", true],
|
||||
)
|
||||
expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
|
||||
"SyntaxError",
|
||||
true,
|
||||
|
|
@ -263,55 +254,18 @@ describe("Error values and instanceof", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
|
||||
test("sort and reverse mutate and return the receiver", async () => {
|
||||
describe("CodeMode-specific array behavior", () => {
|
||||
test("sort with a comparator mutates and returns the receiver", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const sorted = [3, 1, 2]
|
||||
const sortResult = sorted.sort((a, b) => a - b)
|
||||
const reversed = [1, 2, 3]
|
||||
const reverseResult = reversed.reverse()
|
||||
return { sorted, sameSort: sorted === sortResult, reversed, sameReverse: reversed === reverseResult }
|
||||
const input = [3, 1, 2]
|
||||
const result = input.sort((a, b) => a - b)
|
||||
return { input, same: input === result }
|
||||
`),
|
||||
).toEqual({ sorted: [1, 2, 3], sameSort: true, reversed: [3, 2, 1], sameReverse: true })
|
||||
).toEqual({ input: [1, 2, 3], same: true })
|
||||
})
|
||||
|
||||
test("array callbacks receive the receiver and observe later mutations", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const values = [1, 2, 3]
|
||||
const seen = values.map((value, index, receiver) => {
|
||||
if (index === 0) values[1] = 9
|
||||
return [value, receiver === values]
|
||||
})
|
||||
return seen
|
||||
`),
|
||||
).toEqual([
|
||||
[1, true],
|
||||
[9, true],
|
||||
[3, true],
|
||||
])
|
||||
expect(
|
||||
await value(`
|
||||
const values = [1, 2, 3]
|
||||
const seen = []
|
||||
values.forEach((value, index) => {
|
||||
seen.push(value)
|
||||
if (index === 0) values.pop()
|
||||
})
|
||||
return seen
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("splice removes in place and returns the removed elements", async () => {
|
||||
expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({
|
||||
removed: [2, 3],
|
||||
a: [1, 4],
|
||||
})
|
||||
})
|
||||
|
||||
test("splice inserts new elements at the cut", async () => {
|
||||
test("splice can replace and insert elements", async () => {
|
||||
expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"])
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({
|
||||
removed: [2],
|
||||
|
|
@ -319,32 +273,12 @@ describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("splice with one argument removes to the end; negative start counts back", async () => {
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({
|
||||
removed: [2, 3],
|
||||
a: [1],
|
||||
})
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({
|
||||
removed: [3],
|
||||
a: [1, 2],
|
||||
})
|
||||
})
|
||||
|
||||
test("splice rejects inserting a container into itself", async () => {
|
||||
const err = await error(`const a = [1]; a.splice(0, 0, [a]); return a`)
|
||||
expect(err.kind).toBe("InvalidDataValue")
|
||||
expect(err.message).toContain("circular")
|
||||
})
|
||||
|
||||
test("fill overwrites a range and returns the mutated array", async () => {
|
||||
expect(await value(`const a = [1,2,3,4]; return a.fill(0, 1, 3)`)).toEqual([1, 0, 0, 4])
|
||||
expect(await value(`return [1,2,3].fill("z")`)).toEqual(["z", "z", "z"])
|
||||
})
|
||||
|
||||
test("copyWithin copies a range in place", async () => {
|
||||
expect(await value(`return [1,2,3,4,5].copyWithin(0, 3)`)).toEqual([4, 5, 3, 4, 5])
|
||||
})
|
||||
|
||||
test("keys/values/entries return arrays usable with for...of and spread", async () => {
|
||||
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
|
||||
expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
|
||||
|
|
@ -359,16 +293,9 @@ describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("string methods: localeCompare, normalize, trim aliases", () => {
|
||||
describe("CodeMode-specific string behavior", () => {
|
||||
test("localeCompare orders strings for sorting", async () => {
|
||||
expect(await value(`return ["b","a","c"].sort((x, y) => x.localeCompare(y))`)).toEqual(["a", "b", "c"])
|
||||
expect(await value(`return "a".localeCompare("a")`)).toBe(0)
|
||||
})
|
||||
|
||||
test("normalize applies unicode normalization forms", async () => {
|
||||
expect(await value(`return "\\u0065\\u0301".normalize("NFC").length`)).toBe(1)
|
||||
expect(await value(`return "\\u00e9".normalize("NFD").length`)).toBe(2)
|
||||
expect(await value(`return "x".normalize() === "x"`)).toBe(true)
|
||||
})
|
||||
|
||||
test("an invalid normalize form is a clear catchable error", async () => {
|
||||
|
|
@ -453,11 +380,6 @@ describe("H5: builtin coercion functions work as array callbacks", () => {
|
|||
expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"])
|
||||
})
|
||||
|
||||
test("arrow callbacks still work (no regression)", async () => {
|
||||
expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4])
|
||||
expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6)
|
||||
})
|
||||
|
||||
test("a non-callable callback is still rejected", async () => {
|
||||
const err = await error(`return [1,2,3].map(42)`)
|
||||
expect(err.message).toContain("callback")
|
||||
|
|
|
|||
|
|
@ -154,9 +154,7 @@ describe("RegExp", () => {
|
|||
).toEqual(["1", "22"])
|
||||
})
|
||||
|
||||
test("string match: non-global carries index, global lists all matches", async () => {
|
||||
expect(await value(`const m = "a1b22".match(/\\d+/); return [m[0], m.index]`)).toEqual(["1", 1])
|
||||
expect(await value(`return "a1b22".match(/\\d+/g)`)).toEqual(["1", "22"])
|
||||
test("an unmatched string pattern returns null", async () => {
|
||||
expect(await value(`return "abc".match(/\\d/)`)).toBeNull()
|
||||
})
|
||||
|
||||
|
|
@ -164,13 +162,6 @@ describe("RegExp", () => {
|
|||
expect(await value(`return "a1b22".matchAll(/(\\d+)/g).map((m) => m[1])`)).toEqual(["1", "22"])
|
||||
})
|
||||
|
||||
test("replace and replaceAll with patterns and $1 substitution", async () => {
|
||||
expect(await value(`return "a1b2".replace(/\\d/, "#")`)).toBe("a#b2")
|
||||
expect(await value(`return "a1b2".replace(/\\d/g, "#")`)).toBe("a#b#")
|
||||
expect(await value(`return "a1b2".replaceAll(/\\d/g, "#")`)).toBe("a#b#")
|
||||
expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]")
|
||||
})
|
||||
|
||||
test("function replacers receive captures, offsets, input, and named groups", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
|
|
@ -236,12 +227,6 @@ describe("RegExp", () => {
|
|||
expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught")
|
||||
})
|
||||
|
||||
test("split and search accept patterns", async () => {
|
||||
expect(await value(`return "a1b22c".split(/\\d+/)`)).toEqual(["a", "b", "c"])
|
||||
expect(await value(`return "ab42".search(/\\d/)`)).toBe(2)
|
||||
expect(await value(`return "ab".search(/\\d/)`)).toBe(-1)
|
||||
})
|
||||
|
||||
test("new RegExp constructs from strings; invalid patterns are catchable", async () => {
|
||||
expect(await value(`return new RegExp("a+", "i").test("AAA")`)).toBe(true)
|
||||
expect(await value(`try { new RegExp("("); return "no" } catch { return "caught" }`)).toBe("caught")
|
||||
|
|
@ -650,9 +635,7 @@ describe("stdlib integration", () => {
|
|||
true,
|
||||
)
|
||||
expect(
|
||||
await value(
|
||||
`try { Object.fromEntries(new Map([["fn", Math.max]])); return false } catch { return true }`,
|
||||
),
|
||||
await value(`try { Object.fromEntries(new Map([["fn", Math.max]])); return false } catch { return true }`),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
|
|
|
|||
77
packages/codemode/test/test262-array.md
Normal file
77
packages/codemode/test/test262-array.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Test262 Array Coverage
|
||||
|
||||
The Array tests adapt Test262 at revision `250f204f23a9249ff204be2baec29600faae7b75`. They cover CodeMode's 35
|
||||
exposed instance methods and three static methods using actual arrays, accepted argument types, deterministic behavior,
|
||||
and CodeMode's materialized collection conventions. Each executable case names its exact upstream source path.
|
||||
`LICENSE.test262` contains the upstream BSD terms.
|
||||
|
||||
This is coverage of CodeMode's bounded Array surface, not a claim of ECMAScript or Test262 conformance. One upstream
|
||||
file may contain both adapted and inapplicable assertions, so a cited source means only that the represented assertions
|
||||
were adapted.
|
||||
|
||||
## Inventory
|
||||
|
||||
The 38 relevant upstream API directories contain 2,837 files. The executable suite adapts assertions from 83 distinct
|
||||
sources.
|
||||
|
||||
| API | Upstream files | Adapted sources |
|
||||
| ------------------------------- | -------------: | --------------: |
|
||||
| `Array.prototype.map` | 216 | 3 |
|
||||
| `Array.prototype.filter` | 242 | 3 |
|
||||
| `Array.prototype.find` | 23 | 4 |
|
||||
| `Array.prototype.findIndex` | 23 | 3 |
|
||||
| `Array.prototype.findLast` | 24 | 3 |
|
||||
| `Array.prototype.findLastIndex` | 24 | 3 |
|
||||
| `Array.prototype.some` | 219 | 2 |
|
||||
| `Array.prototype.every` | 218 | 2 |
|
||||
| `Array.prototype.includes` | 30 | 2 |
|
||||
| `Array.prototype.join` | 23 | 2 |
|
||||
| `Array.prototype.reduce` | 260 | 3 |
|
||||
| `Array.prototype.reduceRight` | 260 | 3 |
|
||||
| `Array.prototype.flatMap` | 24 | 2 |
|
||||
| `Array.prototype.forEach` | 190 | 2 |
|
||||
| `Array.prototype.sort` | 54 | 3 |
|
||||
| `Array.prototype.toSorted` | 21 | 4 |
|
||||
| `Array.prototype.slice` | 71 | 1 |
|
||||
| `Array.prototype.concat` | 69 | 3 |
|
||||
| `Array.prototype.indexOf` | 201 | 2 |
|
||||
| `Array.prototype.lastIndexOf` | 198 | 2 |
|
||||
| `Array.prototype.at` | 13 | 3 |
|
||||
| `Array.prototype.flat` | 19 | 2 |
|
||||
| `Array.prototype.reverse` | 18 | 1 |
|
||||
| `Array.prototype.toReversed` | 17 | 2 |
|
||||
| `Array.prototype.with` | 21 | 2 |
|
||||
| `Array.prototype.push` | 24 | 1 |
|
||||
| `Array.prototype.pop` | 23 | 1 |
|
||||
| `Array.prototype.shift` | 20 | 1 |
|
||||
| `Array.prototype.unshift` | 22 | 1 |
|
||||
| `Array.prototype.splice` | 81 | 3 |
|
||||
| `Array.prototype.fill` | 22 | 3 |
|
||||
| `Array.prototype.copyWithin` | 39 | 2 |
|
||||
| `Array.prototype.keys` | 12 | 1 |
|
||||
| `Array.prototype.values` | 12 | 1 |
|
||||
| `Array.prototype.entries` | 12 | 1 |
|
||||
| `Array.from` | 47 | 3 |
|
||||
| `Array.isArray` | 29 | 2 |
|
||||
| `Array.of` | 16 | 1 |
|
||||
|
||||
## Exclusions
|
||||
|
||||
Assertions are not adapted when they test behavior outside CodeMode's documented Array surface:
|
||||
|
||||
- Function metadata, property descriptors, constructibility, prototype mutation, species constructors, or cross-realm
|
||||
identity.
|
||||
- Generic receivers, detached methods, `.call`, `.apply`, boxed values, custom coercion objects, Symbols, BigInts,
|
||||
proxies, accessors, frozen arrays, typed arrays, or ArrayBuffers.
|
||||
- `Array.from` mappers, custom iterables, constructor substitution, and iterator-closing behavior.
|
||||
- Native iterator identity, `.next()`, completion records, or live iterator mutation. CodeMode deliberately materializes
|
||||
`keys`, `values`, and `entries` as arrays.
|
||||
- Sparse-array assertions that depend on literal elisions or inherited indexed properties. CodeMode's confined data
|
||||
model does not preserve those prototype and hole semantics at every boundary.
|
||||
- Argument coercions outside the accepted schema-like surface. Numeric positions must be numbers and `join` separators
|
||||
must be strings.
|
||||
- Exact native error brands where CodeMode exposes a safe runtime error instead.
|
||||
- Async/effectful callbacks, circular-data rejection, sandbox-value identity, diagnostics, and host-boundary behavior.
|
||||
Those remain covered by CodeMode-specific tests.
|
||||
|
||||
Handwritten tests remain where they specify CodeMode behavior rather than ordinary ECMAScript Array semantics.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue