Compare commits

..

7 Commits

Author SHA1 Message Date
Aiden Cline b353795792 fix(tui): prevent session picker crash
Track footer focus by stable command and initialize option state before action bindings can evaluate selection-dependent availability.

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 22:36:28 +00:00
Dax Raad 358d4746a9 refactor(cli): remove legacy sdk dependency 2026-07-13 18:03:16 -04:00
Kit Langton ce2e301e24 feat(tui): style background subagent indicator (#36751) 2026-07-13 17:57:12 -04:00
opencode-agent[bot] fbe7f26e71 fix(core): clamp compaction output budget (#36745)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 17:50:20 -04:00
Aiden Cline ecb5754f4c refactor(codemode): rename Sandbox terminology to CodeMode (#36768) 2026-07-13 16:38:11 -05:00
Dax Raad 36f8cb7054 refactor(tui): remove legacy sdk client 2026-07-13 17:35:40 -04:00
James Long 4c45a5cf23 refactor(tui): extract system theme generation (#36753) 2026-07-13 16:39:06 -04:00
91 changed files with 1282 additions and 2804 deletions
-2
View File
@@ -108,7 +108,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
@@ -1006,7 +1005,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:",
-1
View File
@@ -36,7 +36,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
@@ -1,6 +1,6 @@
import { EOL } from "os"
import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
@@ -12,11 +12,11 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
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() } }))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
JSON.stringify(
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)),
response.data.toSorted((a, b) => a.id.localeCompare(b.id)),
null,
2,
) + EOL,
+11 -11
View File
@@ -1,11 +1,11 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import {
createOpencodeClient,
OpenCode,
type IntegrationAttemptStatus,
type IntegrationOAuthMethod,
type OpencodeClient,
} from "@opencode-ai/sdk/v2/client"
type OpenCodeClient,
} from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
@@ -20,7 +20,7 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
if (!integration)
@@ -32,10 +32,9 @@ export default Runtime.handler(
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
const started = yield* Effect.promise(() =>
client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
client.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
)
const attempt = started.data?.data
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
const attempt = started.data
if (attempt.mode === "code")
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
@@ -52,13 +51,14 @@ export default Runtime.handler(
)
const poll = (
client: OpencodeClient,
client: OpenCodeClient,
attemptID: string,
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location }))
const status = response.data?.data
if (!status || status.status === "pending") {
const status = yield* Effect.promise(() => client.integration.attempt.status({ attemptID, location })).pipe(
Effect.map((result) => result.data),
)
if (status.status === "pending") {
yield* Effect.sleep("1 second")
return yield* poll(client, attemptID)
}
@@ -1,6 +1,6 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client"
import { OpenCode, type McpServer } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
@@ -12,9 +12,9 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
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))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
if (servers.length === 0) {
process.stdout.write("No MCP servers configured" + EOL)
return
@@ -1,6 +1,6 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
@@ -15,7 +15,7 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) {
@@ -31,7 +31,7 @@ export default Runtime.handler(
yield* Effect.forEach(
credentials,
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })),
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
{ discard: true },
)
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
@@ -1,17 +1,18 @@
import { Effect } from "effect"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { OpenCodeClient } from "@opencode-ai/client"
// Resolve through the MCP-owned integrationID rather than matching integration names: the shared
// integration registry also holds provider/plugin integrations, whose names could collide with a server.
// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local
// or anonymous server), leaving that case for the caller to interpret.
export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) =>
export const resolveIntegration = (client: OpenCodeClient, name: string, location: { directory: string }) =>
Effect.gen(function* () {
const servers = yield* Effect.promise(() => client.v2.mcp.list({ location }))
const server = (servers.data?.data ?? []).find((entry) => entry.name === name)
const servers = yield* Effect.promise(() => client.mcp.list({ location }))
const server = servers.data.find((entry) => entry.name === name)
if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
const integrationID = server.integrationID
if (!integrationID) return undefined
const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location }))
return found.data?.data
return yield* Effect.promise(() => client.integration.get({ integrationID, location })).pipe(
Effect.map((result) => result.data ?? undefined),
)
})
+121 -298
View File
@@ -1,7 +1,7 @@
// Demo mode for testing direct interactive mode without a real SDK.
//
// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic
// SDK events that feed through the real reducer and footer pipeline. This
// Enabled with `--demo`. Intercepts prompt submissions and drives the same
// presentation commits and footer actions as the live transport. This
// lets you test scrollback formatting, permission UI, question UI, and tool
// snapshots without making actual model calls. Pass a demo slash command as
// the initial interactive message to trigger a preview immediately.
@@ -15,10 +15,18 @@
// Demo mode also handles permission and question replies locally, completing
// or failing the synthetic tool parts as appropriate.
import path from "path"
import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
import { writeSessionOutput } from "./stream"
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
import { toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
MiniToolPart,
PermissionReply,
QuestionReject,
QuestionReply,
RunPrompt,
StreamCommit,
} from "./types"
const KINDS = [
"markdown",
@@ -124,7 +132,7 @@ type Permit = {
ref: Ref
permission: string
patterns: string[]
metadata?: Record<string, unknown>
metadata?: PermissionV2Request["metadata"]
always: string[]
done: Perm["done"]
}
@@ -132,9 +140,7 @@ type Permit = {
type State = {
id: string
thinking: boolean
data: SessionData
footer: FooterApi
limits: () => Record<string, number>
msg: number
part: number
call: number
@@ -142,12 +148,12 @@ type State = {
ask: number
perms: Map<string, Perm>
asks: Map<string, Ask>
started: Set<string>
}
type Input = {
sessionID: string
thinking: boolean
limits: () => Record<string, number>
footer: FooterApi
}
@@ -255,185 +261,69 @@ function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefi
return `demo_${prefix}_${state[key]}`
}
function feed(state: State, event: Event): void {
const out = reduceSessionData({
data: state.data,
event,
sessionID: state.id,
thinking: state.thinking,
limits: state.limits(),
})
state.data = out.data
function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void {
writeSessionOutput(
{ footer: state.footer },
{
footer: state.footer,
commits,
footer: view
? {
view: "action" in view ? { type: "permission", request: view } : { type: "question", request: view },
patch: { status: "action" in view ? "awaiting permission" : "awaiting answer" },
}
: undefined,
},
out,
)
}
function clearBlocker(state: State): void {
writeSessionOutput(
{ footer: state.footer },
{ commits: [], footer: { view: { type: "prompt" }, patch: { status: "" } } },
)
}
function open(state: State): string {
const id = take(state, "msg", "msg")
feed(state, {
type: "message.updated",
properties: {
sessionID: state.id,
info: {
id,
sessionID: state.id,
role: "assistant",
time: {
created: Date.now(),
},
parentID: `user_${id}`,
modelID: "demo",
providerID: "demo",
mode: "demo",
agent: "demo",
path: {
cwd: process.cwd(),
root: process.cwd(),
},
cost: 0.001,
tokens: {
input: 120,
output: 320,
reasoning: 80,
cache: {
read: 0,
write: 0,
},
},
},
},
} as Event)
return id
return take(state, "msg", "msg")
}
async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state)
const part = take(state, "part", "part")
const start = Date.now()
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "text",
text: "",
time: {
start,
},
},
},
} as Event)
let next = ""
for (const item of split(body)) {
if (signal?.aborted) {
return
}
next += item
feed(state, {
type: "message.part.delta",
properties: {
sessionID: state.id,
messageID: msg,
partID: part,
field: "text",
delta: item,
},
} as Event)
present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }])
await wait(45, signal)
}
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "text",
text: next,
time: {
start,
end: Date.now(),
},
},
},
} as Event)
}
async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state)
const part = take(state, "part", "part")
const start = Date.now()
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "reasoning",
text: "",
time: {
start,
},
},
},
} as Event)
let next = ""
let first = true
for (const item of split(body)) {
if (signal?.aborted) {
return
}
next += item
feed(state, {
type: "message.part.delta",
properties: {
sessionID: state.id,
messageID: msg,
partID: part,
field: "text",
delta: item,
},
} as Event)
if (state.thinking) {
present(state, [
{
kind: "reasoning",
source: "reasoning",
text: first ? `Thinking: ${item.replace(/\[REDACTED\]/g, "")}` : item.replace(/\[REDACTED\]/g, ""),
phase: "progress",
messageID: msg,
partID: part,
},
])
first = false
}
await wait(45, signal)
}
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "reasoning",
text: next,
time: {
start,
end: Date.now(),
},
},
},
} as Event)
}
function make(state: State, tool: string, input: Record<string, unknown>): Ref {
@@ -448,29 +338,23 @@ function make(state: State, tool: string, input: Record<string, unknown>): Ref {
}
function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
type: "tool",
callID: ref.call,
tool: ref.tool,
state: {
status: "running",
input: ref.input,
metadata,
time: {
start: ref.start,
},
state.started.add(ref.part)
present(
state,
[
toolCommit(
{
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
callID: ref.call,
tool: ref.tool,
state: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
},
},
},
} as Event)
"start",
),
],
)
}
function askPermission(state: State, item: Permit): void {
@@ -482,21 +366,15 @@ function askPermission(state: State, item: Permit): void {
done: item.done,
})
feed(state, {
type: "permission.asked",
properties: {
id,
sessionID: state.id,
permission: item.permission,
patterns: item.patterns,
metadata: item.metadata ?? {},
always: item.always,
tool: {
messageID: item.ref.msg,
callID: item.ref.call,
},
},
} as Event)
present(state, [], {
id,
sessionID: state.id,
action: item.permission,
resources: item.patterns,
metadata: item.metadata ?? {},
save: item.always,
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
})
}
function doneTool(
@@ -508,77 +386,53 @@ function doneTool(
metadata?: Record<string, unknown>
},
): void {
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
type: "tool",
callID: ref.call,
tool: ref.tool,
state: {
status: "completed",
input: ref.input,
output: output.output,
title: output.title,
metadata: output.metadata ?? {},
time: {
start: ref.start,
end: Date.now(),
},
},
},
if (!state.started.has(ref.part)) startTool(state, ref)
const part: MiniToolPart = {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
callID: ref.call,
tool: ref.tool,
state: {
status: "completed",
input: ref.input,
output: output.output,
title: output.title,
metadata: output.metadata ?? {},
time: { start: ref.start, end: Date.now() },
},
} as Event)
}
present(state, [toolCommit(part, output.output ? "progress" : "final")])
}
function failTool(state: State, ref: Ref, error: string): void {
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
type: "tool",
callID: ref.call,
tool: ref.tool,
state: {
status: "error",
input: ref.input,
error,
metadata: {},
time: {
start: ref.start,
end: Date.now(),
if (!state.started.has(ref.part)) startTool(state, ref)
present(
state,
[
toolCommit(
{
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
callID: ref.call,
tool: ref.tool,
state: {
status: "error",
input: ref.input,
error,
metadata: {},
time: { start: ref.start, end: Date.now() },
},
},
},
},
} as Event)
"final",
),
],
)
}
function emitError(state: State, text: string): void {
const event = {
id: `session.error:${state.id}:${Date.now()}`,
type: "session.error",
properties: {
sessionID: state.id,
error: {
name: "UnknownError",
data: {
message: text,
},
},
},
} satisfies Event
feed(state, event)
present(state, [{ kind: "error", source: "system", text, phase: "start" }])
}
async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
@@ -685,7 +539,7 @@ function emitTask(state: State): void {
start: Date.now(),
},
},
} satisfies ToolPart
} satisfies MiniToolPart
showSubagent(state, {
sessionID: "sub_demo_1",
partID: ref.part,
@@ -979,18 +833,12 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
const id = take(state, "ask", "ask")
state.asks.set(id, { ref })
feed(state, {
type: "question.asked",
properties: {
id,
sessionID: state.id,
questions,
tool: {
messageID: ref.msg,
callID: ref.call,
},
},
} as Event)
present(state, [], {
id,
sessionID: state.id,
questions,
tool: { messageID: ref.msg, callID: ref.call },
})
}
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
@@ -1089,9 +937,7 @@ export function createRunDemo(input: Input) {
const state: State = {
id: input.sessionID,
thinking: input.thinking,
data: createSessionData(),
footer: input.footer,
limits: input.limits,
msg: 0,
part: 0,
call: 0,
@@ -1099,6 +945,7 @@ export function createRunDemo(input: Input) {
ask: 0,
perms: new Map(),
asks: new Map(),
started: new Set(),
}
const start = async (): Promise<void> => {
@@ -1166,16 +1013,7 @@ export function createRunDemo(input: Input) {
}
state.perms.delete(input.requestID)
const event = {
id: `permission.replied:${input.requestID}:${Date.now()}`,
type: "permission.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
reply: input.reply,
},
} satisfies Event
feed(state, event)
clearBlocker(state)
if (input.reply === "reject") {
failTool(state, item.ref, input.message || "permission rejected")
@@ -1193,16 +1031,7 @@ export function createRunDemo(input: Input) {
}
state.asks.delete(input.requestID)
const event = {
id: `question.replied:${input.requestID}:${Date.now()}`,
type: "question.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
answers: input.answers,
},
} satisfies Event
feed(state, event)
clearBlocker(state)
doneTool(state, ask.ref, {
title: "question",
output: "",
@@ -1220,13 +1049,7 @@ export function createRunDemo(input: Input) {
}
state.asks.delete(input.requestID)
feed(state, {
type: "question.rejected",
properties: {
sessionID: state.id,
requestID: input.requestID,
},
} as Event)
clearBlocker(state)
failTool(state, ask.ref, "question rejected")
return true
}
+3 -3
View File
@@ -14,7 +14,7 @@
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import type { PermissionV2Request } from "@opencode-ai/client/promise"
import {
createPermissionBodyState,
permissionAlwaysLines,
@@ -130,7 +130,7 @@ export function RejectField(props: {
}
export function RunPermissionBody(props: {
request: PermissionRequest
request: PermissionV2Request
theme: RunFooterTheme
block: RunBlockTheme
diffStyle?: RunDiffStyle
@@ -142,7 +142,7 @@ export function RunPermissionBody(props: {
const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const opts = createMemo(() =>
permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0),
permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
)
const busy = createMemo(() => state().submitting)
const title = createMemo(() => {
+2 -2
View File
@@ -16,7 +16,7 @@
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionV2Request } from "@opencode-ai/client/promise"
import {
createQuestionBodyState,
questionConfirm,
@@ -45,7 +45,7 @@ import type { RunFooterTheme } from "./theme"
import type { QuestionReject, QuestionReply } from "./types"
export function RunQuestionBody(props: {
request: QuestionRequest
request: QuestionV2Request
theme: RunFooterTheme
onReply: (input: QuestionReply) => void | Promise<void>
onReject: (input: QuestionReject) => void | Promise<void>
+10 -10
View File
@@ -1,8 +1,8 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { UI } from "./ui"
import type { MiniToolPart } from "./types"
type Model = {
providerID: string
@@ -28,8 +28,8 @@ type Input = {
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>
renderToolError: (part: ToolPart) => Promise<void>
renderTool: (part: MiniToolPart) => Promise<void>
renderToolError: (part: MiniToolPart) => Promise<void>
}
type StartedPart = {
@@ -77,7 +77,7 @@ export async function runNonInteractivePrompt(input: Input) {
return true
}
const writeText = (part: TextPart, timestamp: number) => {
const writeText = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
if (emit("text", timestamp, { part })) return
const text = part.text.trim()
if (!text) return
@@ -169,7 +169,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!promoted) continue
if (event.type === "session.step.started") {
const part: StepStartPart = {
const part = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -191,7 +191,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.text.ended") {
const started = starts.get("text")
starts.delete("text")
const part: TextPart = {
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -210,7 +210,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get("reasoning")
starts.delete("reasoning")
const part: ReasoningPart = {
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -263,7 +263,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = {
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -296,7 +296,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message
const part: ToolPart = {
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
@@ -325,7 +325,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.step.ended") {
const part: StepFinishPart = {
const part = {
id: partID(event.id),
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
+15 -14
View File
@@ -13,7 +13,7 @@
//
// permissionInfo() extracts display info (icon, title, lines, diff) from
// the request, delegating to tool.ts for tool-specific formatting.
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import type { PermissionV2Request } from "@opencode-ai/client/promise"
import type { PermissionReply } from "./types"
import { toolPath, toolPermissionInfo } from "./tool"
@@ -55,7 +55,7 @@ function text(v: unknown): string {
return typeof v === "string" ? v : ""
}
function data(request: PermissionRequest): Dict {
function data(request: PermissionV2Request): Dict {
const meta = dict(request.metadata)
return {
...meta,
@@ -63,8 +63,8 @@ function data(request: PermissionRequest): Dict {
}
}
function patterns(request: PermissionRequest): string[] {
return request.patterns.filter((item): item is string => typeof item === "string")
function patterns(request: PermissionV2Request): string[] {
return request.resources.filter((item): item is string => typeof item === "string")
}
export function createPermissionBodyState(requestID: string): PermissionBodyState {
@@ -89,15 +89,15 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
return []
}
export function permissionInfo(request: PermissionRequest): PermissionInfo {
export function permissionInfo(request: PermissionV2Request): PermissionInfo {
const pats = patterns(request)
const input = data(request)
const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats)
const info = toolPermissionInfo(request.action, input, dict(request.metadata), pats)
if (info) {
return info
}
if (request.permission === "external_directory") {
if (request.action === "external_directory") {
const meta = dict(request.metadata)
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
@@ -108,7 +108,7 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
}
}
if (request.permission === "doom_loop") {
if (request.action === "doom_loop") {
return {
icon: "⟳",
title: "Continue after repeated failures",
@@ -118,19 +118,20 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
return {
icon: "⚙",
title: `Call tool ${request.permission}`,
lines: [`Tool: ${request.permission}`],
title: `Call tool ${request.action}`,
lines: [`Tool: ${request.action}`],
}
}
export function permissionAlwaysLines(request: PermissionRequest): string[] {
if (request.always.length === 1 && request.always[0] === "*") {
return [`This will allow ${request.permission} until OpenCode is restarted.`]
export function permissionAlwaysLines(request: PermissionV2Request): string[] {
const save = request.save ?? []
if (save.length === 1 && save[0] === "*") {
return [`This will allow ${request.action} until OpenCode is restarted.`]
}
return [
"This will allow the following patterns until OpenCode is restarted.",
...request.always.map((item) => `- ${item}`),
...save.map((item) => `- ${item}`),
]
}
+15 -15
View File
@@ -13,7 +13,7 @@
//
// Custom answers: if a question has custom=true, an extra "Type your own
// answer" option appears. Selecting it enters editing mode with a text field.
import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionV2Info, QuestionV2Request } from "@opencode-ai/client/promise"
import type { QuestionReject, QuestionReply } from "./types"
export type QuestionBodyState = {
@@ -51,23 +51,23 @@ export function questionSync(state: QuestionBodyState, requestID: string): Quest
return createQuestionBodyState(requestID)
}
export function questionSingle(request: QuestionRequest): boolean {
export function questionSingle(request: QuestionV2Request): boolean {
return request.questions.length === 1 && request.questions[0]?.multiple !== true
}
export function questionTabs(request: QuestionRequest): number {
export function questionTabs(request: QuestionV2Request): number {
return questionSingle(request) ? 1 : request.questions.length + 1
}
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
export function questionConfirm(request: QuestionV2Request, state: QuestionBodyState): boolean {
return !questionSingle(request) && state.tab === request.questions.length
}
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
export function questionInfo(request: QuestionV2Request, state: QuestionBodyState): QuestionV2Info | undefined {
return request.questions[state.tab]
}
export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean {
export function questionCustom(request: QuestionV2Request, state: QuestionBodyState): boolean {
return questionInfo(request, state)?.custom !== false
}
@@ -84,7 +84,7 @@ export function questionPicked(state: QuestionBodyState): boolean {
return state.answers[state.tab]?.includes(value) ?? false
}
export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean {
export function questionOther(request: QuestionV2Request, state: QuestionBodyState): boolean {
const info = questionInfo(request, state)
if (!info || info.custom === false) {
return false
@@ -93,7 +93,7 @@ export function questionOther(request: QuestionRequest, state: QuestionBodyState
return state.selected === info.options.length
}
export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number {
export function questionTotal(request: QuestionV2Request, state: QuestionBodyState): number {
const info = questionInfo(request, state)
if (!info) {
return 0
@@ -156,7 +156,7 @@ export function questionStoreCustom(state: QuestionBodyState, tab: number, text:
function questionPick(
state: QuestionBodyState,
request: QuestionRequest,
request: QuestionV2Request,
answer: string,
custom = false,
): QuestionStep {
@@ -204,7 +204,7 @@ function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyS
return storeAnswers(state, state.tab, list)
}
export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState {
export function questionMove(state: QuestionBodyState, request: QuestionV2Request, dir: -1 | 1): QuestionBodyState {
const total = questionTotal(request, state)
if (total === 0) {
return state
@@ -216,7 +216,7 @@ export function questionMove(state: QuestionBodyState, request: QuestionRequest,
}
}
export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
export function questionSelect(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
const info = questionInfo(request, state)
if (!info) {
return { state }
@@ -255,7 +255,7 @@ export function questionSelect(state: QuestionBodyState, request: QuestionReques
return questionPick(state, request, option.label)
}
export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
export function questionSave(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
const info = questionInfo(request, state)
if (!info) {
return { state }
@@ -305,20 +305,20 @@ export function questionSave(state: QuestionBodyState, request: QuestionRequest)
return questionPick(state, request, value, true)
}
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply {
export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply {
return {
requestID: request.id,
answers: questionAnswers(state, request.questions.length),
}
}
export function questionReject(request: QuestionRequest): QuestionReject {
export function questionReject(request: QuestionV2Request): QuestionReject {
return {
requestID: request.id,
}
}
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string {
export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string {
if (state.submitting) {
return "Waiting for question event..."
}
+3 -3
View File
@@ -2,13 +2,13 @@ import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { open } from "node:fs/promises"
import path from "node:path"
import { Server } from "../services/server"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool"
import type { MiniToolPart } from "./types"
import { UI } from "./ui"
export type RunCommandInput = {
@@ -224,7 +224,7 @@ function isBinaryContent(bytes: Uint8Array) {
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
}
async function renderTool(part: ToolPart) {
async function renderTool(part: MiniToolPart) {
const info = toolInlineInfo(part)
if (info.mode === "block") {
UI.empty()
@@ -240,7 +240,7 @@ async function renderTool(part: ToolPart) {
)
}
async function renderToolError(part: ToolPart) {
async function renderToolError(part: MiniToolPart) {
const info = toolInlineInfo(part)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
-1
View File
@@ -547,7 +547,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
footer,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,
})
}
File diff suppressed because it is too large Load Diff
+49 -200
View File
@@ -1,15 +1,10 @@
// Session message extraction and prompt history.
//
// Fetches session messages from the SDK and extracts user turn text for
// the prompt history ring. Also finds the most recently used variant for
// the current model so the footer can pre-select it.
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { promptCopy, promptSame } from "./prompt.shared"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import type { RunInput, RunPrompt } from "./types"
const LIMIT = 200
export type SessionMessages = Array<{ info: Message; parts: Part[] }>
export type SessionMessages = SessionMessageInfo[]
type Turn = {
prompt: RunPrompt
@@ -25,133 +20,42 @@ export type RunSession = {
variant?: string
}
function fileName(url: string, filename?: string) {
if (filename) {
return filename
}
try {
const next = new URL(url)
if (next.protocol !== "file:") {
return url
}
const name = next.pathname.split("/").at(-1)
if (name) {
return decodeURIComponent(name)
}
} catch {}
return url
}
function fileSource(
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
text: { start: number; end: number; value: string },
) {
if (part.source) {
return {
...structuredClone(part.source),
text,
}
}
function messagePrompt(message: SessionMessageUser): RunPrompt {
return {
type: "file" as const,
path: part.filename ?? part.url,
text,
}
}
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
const parts: RunPrompt["parts"] = []
let text = msg.parts
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
return part.type === "text" && !part.synthetic
})
.map((part) => part.text)
.join("")
let cursor = Bun.stringWidth(text)
const used: Array<{ start: number; end: number }> = []
const take = (value: string): { start: number; end: number; value: string } | undefined => {
let from = 0
while (true) {
const idx = text.indexOf(value, from)
if (idx === -1) {
return undefined
}
const start = Bun.stringWidth(text.slice(0, idx))
const end = start + Bun.stringWidth(value)
if (!used.some((item) => item.start < end && start < item.end)) {
return { start, end, value }
}
from = idx + value.length
}
}
const add = (value: string) => {
const gap = text ? " " : ""
const start = cursor + Bun.stringWidth(gap)
text += gap + value
const end = start + Bun.stringWidth(value)
cursor = end
return { start, end, value }
}
for (const part of msg.parts) {
if (part.type === "file") {
const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename))
const span = next ?? add("@" + fileName(part.url, part.filename))
used.push({ start: span.start, end: span.end })
parts.push({
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: fileSource(part, span),
})
continue
}
if (part.type !== "agent") {
continue
}
const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name))
used.push({ start: span.start, end: span.end })
parts.push({
type: "agent",
name: part.name,
source: span,
})
}
return { text, parts }
}
function turn(msg: SessionMessages[number]): Turn | undefined {
if (msg.info.role !== "user") {
return undefined
}
return {
prompt: messagePrompt(msg),
provider: msg.info.model.providerID,
model: msg.info.model.modelID,
variant: msg.info.model.variant,
text: message.text,
parts: [
...(message.files ?? []).map((file) => ({
type: "file" as const,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
mime: file.mime,
filename: file.name,
source: file.mention
? {
type: "file",
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
}
: undefined,
})),
...(message.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
: undefined,
})),
],
}
}
export function createSession(messages: SessionMessages): RunSession {
return {
first: messages.length === 0,
turns: messages.flatMap((msg) => {
const item = turn(msg)
return item ? [item] : []
}),
turns: messages.flatMap((message) =>
message.type === "user"
? [{ prompt: messagePrompt(message), provider: undefined, model: undefined, variant: undefined }]
: [],
),
}
}
@@ -164,89 +68,34 @@ export async function resolveCurrentSession(
sdk.message.list({ sessionID, limit, order: "desc" }),
sdk.session.get({ sessionID }),
])
const messages = response.data.toReversed()
const current = createSession(response.data.toReversed())
return {
first: messages.length === 0,
turns: messages.flatMap((message) => {
if (message.type !== "user") return []
return [
{
prompt: {
text: message.text,
parts: [
...(message.files ?? []).map((file) => ({
type: "file" as const,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
mime: file.mime,
filename: file.name,
source: file.mention
? {
type: "file" as const,
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
}
: undefined,
})),
...(message.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
: undefined,
})),
],
},
provider: session.model?.providerID,
model: session.model?.id,
variant: session.model?.variant,
},
]
}),
...current,
turns: current.turns.map((turn) => ({
...turn,
provider: session.model?.providerID,
model: session.model?.id,
variant: session.model?.variant,
})),
...(session.model && {
model: {
providerID: session.model.providerID,
modelID: session.model.id,
},
model: { providerID: session.model.providerID, modelID: session.model.id },
variant: session.model.variant,
}),
}
}
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
const out: RunPrompt[] = []
for (const turn of session.turns) {
if (!turn.prompt.text.trim()) {
continue
}
if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
continue
}
out.push(promptCopy(turn.prompt))
}
return out.slice(-limit)
return session.turns
.map((turn) => turn.prompt)
.filter((prompt) => prompt.text.trim())
.filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt))
.map(promptCopy)
.slice(-limit)
}
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
if (!model) {
return undefined
}
if (!model) return
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) return session.variant
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) {
return session.variant
}
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
const turn = session.turns[idx]
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
continue
}
return turn.variant
}
return undefined
return session.turns.findLast((turn) => turn.provider === model.providerID && turn.model === model.modelID)?.variant
}
+11 -7
View File
@@ -15,10 +15,14 @@
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
// backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists.
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2"
import type {
EventSubscribeOutput,
OpenCodeClient,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, MiniToolPart, StreamCommit } from "./types"
const CHILD_MESSAGE_LIMIT = 80
const CHILD_FRAME_LIMIT = 80
@@ -32,11 +36,11 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
}
export function legacyTool(input: {
export function miniTool(input: {
sessionID: string
messageID: string
tool: SessionMessageAssistantTool
}): ToolPart {
}): MiniToolPart {
const tool = input.tool
const providerCall =
tool.executed === undefined && tool.providerState === undefined
@@ -109,7 +113,7 @@ export function legacyTool(input: {
}
}
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit {
export function toolCommit(part: MiniToolPart, phase: "start" | "progress" | "final"): StreamCommit {
const status = part.state.status
const text =
status === "running"
@@ -310,7 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
const part = legacyTool({
const part = miniTool({
sessionID: child.sessionID,
messageID,
tool: item,
+15 -38
View File
@@ -1,14 +1,15 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type {
PermissionRequest,
QuestionRequest,
SessionMessageInfo,
EventSubscribeOutput,
OpenCodeClient,
PermissionV2Request,
QuestionV2Request,
SessionMessageAssistantTool,
} from "@opencode-ai/sdk/v2"
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event"
import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream"
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
import { createSubagentTracker, miniTool, toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
FooterView,
@@ -87,8 +88,6 @@ type ShellWait = {
}
type RunV2Event = EventSubscribeOutput
type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
type ToolState = {
@@ -101,8 +100,8 @@ type ToolState = {
}
type State = {
permissions: PermissionRequest[]
questions: QuestionRequest[]
permissions: PermissionV2Request[]
questions: QuestionV2Request[]
view: FooterView
messageIDs: Set<string>
text: Map<string, string>
@@ -138,27 +137,6 @@ export function formatUnknownError(error: unknown): string {
return "unknown error"
}
function permission(request: PermissionV2Request): PermissionRequest {
return {
id: request.id,
sessionID: request.sessionID,
permission: request.action,
patterns: [...request.resources],
metadata: request.metadata ?? {},
always: [...(request.save ?? [])],
tool: request.source?.type === "tool" ? request.source : undefined,
}
}
function question(request: QuestionV2Request): QuestionRequest {
return {
id: request.id,
sessionID: request.sessionID,
questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
tool: request.tool,
}
}
function sessionID(event: RunV2Event) {
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
}
@@ -229,8 +207,7 @@ function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}`
}
// Matches the commit shapes the legacy session-data reducer produced for direct
// shell calls: one "start" commit rendering `$ command` and one "progress"
// Direct shell calls use one "start" commit rendering `$ command` and one "progress"
// commit rendering the merged output (see toolEntryBody in tool.ts).
function shellCommit(
callID: string,
@@ -384,7 +361,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
const renderTool = (messageID: string, item: SessionMessageAssistantTool) => {
const part = legacyTool({
const part = miniTool({
sessionID: input.sessionID,
messageID,
tool: item,
@@ -536,8 +513,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
])
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.map(permission)
state.questions = questions.map(question)
state.permissions = permissions
state.questions = questions
syncBlockers()
await subagents.hydrate({ messages: [...projected], active })
const running = input.sessionID in active
@@ -770,7 +747,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "permission.v2.asked") {
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(permission(event.data))
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(event.data)
syncBlockers()
return
}
@@ -780,7 +757,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "question.v2.asked") {
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data))
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(event.data)
syncBlockers()
return
}
+7 -7
View File
@@ -1,10 +1,10 @@
// Thin bridge between reducer output and the footer API.
// Thin bridge between transport output and the footer API.
//
// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
// Transports produce StreamCommit[] and an optional FooterOutput (patch +
// view + subagent state). This module forwards them to footer.append() and
// footer.event() respectively, adding trace writes along the way. It also
// defaults status updates to phase "running" if the caller didn't set a
// phase -- a convenience so reducer code doesn't have to repeat that.
// phase -- a convenience so transport code doesn't have to repeat that.
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
type Trace = {
@@ -103,9 +103,9 @@ export function traceSubagentState(state: FooterSubagentState) {
permissions: state.permissions.map((item) => ({
id: item.id,
sessionID: item.sessionID,
permission: item.permission,
patterns: item.patterns,
tool: item.tool,
action: item.action,
resources: item.resources,
source: item.source,
metadata: item.metadata
? {
keys: Object.keys(item.metadata),
@@ -137,7 +137,7 @@ export function traceFooterOutput(footer?: FooterOutput) {
}
}
// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
// Forwards transport output to the footer: commits go to scrollback, patches update the status bar.
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
for (const commit of out.commits) {
input.trace?.write("ui.commit", commit)
+3 -4
View File
@@ -15,10 +15,9 @@
import os from "os"
import path from "path"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
import type { MiniToolPart, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type ToolView = {
output: boolean
@@ -1177,7 +1176,7 @@ function rule(name?: string): AnyToolRule | undefined {
return TOOL_RULES[name]
}
function frame(part: ToolPart): ToolFrame {
function frame(part: MiniToolPart): ToolFrame {
const state = dict(part.state)
return {
raw: "",
@@ -1231,7 +1230,7 @@ export function toolStructuredFinal(commit: StreamCommit): boolean {
)
}
export function toolInlineInfo(part: ToolPart): ToolInline {
export function toolInlineInfo(part: MiniToolPart): ToolInline {
const ctx = frame(part)
const draw = rule(ctx.name)?.run
try {
+56 -13
View File
@@ -7,12 +7,16 @@
//
// Data flow through the system:
//
// SDK events → session-data reducer → StreamCommit[] + FooterOutput
// V2 events / demo actions → StreamCommit[] + FooterOutput
// → stream.ts bridges to footer API
// → footer.ts queues commits and patches the footer view
// → OpenTUI split-footer renderer writes to terminal
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type {
OpenCodeClient,
PermissionV2Request,
QuestionV2Request,
ReferenceListOutput,
} from "@opencode-ai/client/promise"
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
export type RunFilePart = {
@@ -30,7 +34,11 @@ export type RunPromptPart =
url: string
filename?: string
mime?: string
source?: FilePart["source"]
source?: {
type: string
text: { start: number; end: number; value: string }
[key: string]: unknown
}
}
| { type: "agent"; name: string; source?: { start: number; end: number; value: string } }
@@ -210,6 +218,41 @@ export type ToolQuestionSnapshot = {
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
export type MiniToolState =
| { status: "pending"; input: Record<string, unknown>; raw?: string }
| {
status: "running"
input: Record<string, unknown>
title?: string
metadata?: Record<string, unknown>
time: { start: number }
}
| {
status: "completed"
input: Record<string, unknown>
output: string
title?: string
metadata?: Record<string, unknown>
time: { start: number; end: number }
}
| {
status: "error"
input: Record<string, unknown>
error: string
metadata?: Record<string, unknown>
time: { start: number; end: number }
}
export type MiniToolPart = {
id: string
sessionID: string
messageID: string
type?: "tool"
callID: string
tool: string
state: MiniToolState
}
export type EntryLayout = "inline" | "block"
export type RunEntryBody =
@@ -220,13 +263,13 @@ export type RunEntryBody =
| { type: "structured"; snapshot: ToolSnapshot }
// Which interactive surface the footer is showing. Only one view is active at
// a time. The reducer drives transitions: when a permission arrives the view
// a time. The transport drives transitions: when a permission arrives the view
// switches to "permission", and when the permission resolves it falls back to
// "prompt".
export type FooterView =
| { type: "prompt" }
| { type: "permission"; request: PermissionRequest }
| { type: "question"; request: QuestionRequest }
| { type: "permission"; request: PermissionV2Request }
| { type: "question"; request: QuestionV2Request }
export type FooterPromptRoute =
| { type: "composer" }
@@ -259,11 +302,11 @@ export type FooterSubagentDetail = {
export type FooterSubagentState = {
tabs: FooterSubagentTab[]
details: Record<string, FooterSubagentDetail>
permissions: PermissionRequest[]
questions: QuestionRequest[]
permissions: PermissionV2Request[]
questions: QuestionV2Request[]
}
// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
// The transport emits this alongside scrollback commits so the footer can update in the same frame.
export type FooterOutput = {
patch?: FooterPatch
view?: FooterView
@@ -357,8 +400,8 @@ export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
export type StreamToolState = "running" | "completed" | "error"
// A single append-only commit to scrollback. The session-data reducer produces
// these from SDK events, and RunFooter.append() queues them for the next
// A single append-only commit to scrollback. The transport produces these from
// V2 events, and RunFooter.append() queues them for the next
// microtask flush. Once flushed, they become immutable terminal scrollback
// rows -- they cannot be rewritten.
export type StreamCommit = {
@@ -370,7 +413,7 @@ export type StreamCommit = {
messageID?: string
partID?: string
tool?: string
part?: ToolPart
part?: MiniToolPart
interrupted?: boolean
toolState?: StreamToolState
toolError?: string
+2 -2
View File
@@ -16,8 +16,8 @@
## Future Design Notes
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.
- Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside CodeMode) instead.
- Improve the failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
+4 -4
View File
@@ -20,7 +20,7 @@ ultimate source of truth.
- [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] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
shadowable by program declarations like other globals.
@@ -94,7 +94,7 @@ ultimate source of truth.
- [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; a plain value passes through unchanged, though every `await` still defers its
- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its
continuation one reaction turn.
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
@@ -109,7 +109,7 @@ ultimate source of truth.
## Promises and tools
- [x] Tool calls start eagerly and return supervised, run-once sandbox promises.
- [x] Tool calls start eagerly and return supervised, run-once CodeMode 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`, `Promise.race`, and `Promise.any` over supported collections containing
@@ -148,7 +148,7 @@ ultimate source of truth.
- [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] Object identity is preserved by in-CodeMode 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`.
+25 -25
View File
@@ -13,13 +13,13 @@ import {
import { rejectCircularInsertion } from "./references.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import {
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { invokeJsonMethod } from "../stdlib/json.js"
@@ -33,7 +33,7 @@ import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../
export type CallbackRunner<R> = {
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: SandboxPromise) => Effect.Effect<unknown, unknown, never>
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
}
export const invokeIntrinsic = <R>(
@@ -57,22 +57,22 @@ export const invokeIntrinsic = <R>(
if (Array.isArray(ref.receiver)) {
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof SandboxDate) {
if (ref.receiver instanceof CodeModeDate) {
return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof SandboxRegExp) {
if (ref.receiver instanceof CodeModeRegExp) {
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
}
if (ref.receiver instanceof SandboxMap) {
if (ref.receiver instanceof CodeModeMap) {
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof SandboxSet) {
if (ref.receiver instanceof CodeModeSet) {
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof SandboxURL) {
if (ref.receiver instanceof CodeModeURL) {
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof SandboxURLSearchParams) {
if (ref.receiver instanceof CodeModeURLSearchParams) {
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
@@ -153,7 +153,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
result = [value]
break
}
if (args[0] instanceof SandboxRegExp) {
if (args[0] instanceof CodeModeRegExp) {
result = value.split(args[0].regex, optNum(1))
break
}
@@ -181,7 +181,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
case "replace":
case "replaceAll": {
if (args[0] instanceof SandboxRegExp) {
if (args[0] instanceof CodeModeRegExp) {
const pattern = args[0].regex
const replacement = str(1)
if (name === "replaceAll" && !pattern.global) {
@@ -278,13 +278,13 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
[supportedSyntaxMessage],
)
}
if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values())
if (args[0] instanceof SandboxURLSearchParams) {
if (args[0] instanceof CodeModeMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
if (args[0] instanceof CodeModeSet) return Array.from(args[0].set.values())
if (args[0] instanceof CodeModeURLSearchParams) {
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
}
const source = args[0]
if (source instanceof SandboxPromise) {
if (source instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
@@ -341,7 +341,7 @@ const invokeStringReplacer = <R>(
}
const pattern = args[0]
if (pattern instanceof SandboxRegExp) {
if (pattern instanceof CodeModeRegExp) {
if (name === "replaceAll" && !pattern.regex.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
@@ -364,7 +364,7 @@ const invokeStringReplacer = <R>(
for (const match of matches) {
const replacement = yield* apply(match.args)
const resolved =
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof CodeModePromise
? yield* runner.settlePromise(replacement)
: replacement
output.push(
@@ -404,7 +404,7 @@ export const applyCollectionCallback = <R>(
const invokeMapMethod = <R>(
runner: CallbackRunner<R>,
target: SandboxMap,
target: CodeModeMap,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -446,7 +446,7 @@ const invokeMapMethod = <R>(
const invokeSetMethod = <R>(
runner: CallbackRunner<R>,
target: SandboxSet,
target: CodeModeSet,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -485,7 +485,7 @@ const invokeSetMethod = <R>(
const invokeURLSearchParamsMethod = <R>(
runner: CallbackRunner<R>,
target: SandboxURLSearchParams,
target: CodeModeURLSearchParams,
name: string,
args: Array<unknown>,
node: AstNode,
+3 -3
View File
@@ -1,5 +1,5 @@
import type { SafeObject } from "../tool-runtime.js"
import type { SandboxPromise, SandboxURL } from "../values.js"
import type { CodeModePromise, CodeModeURL } from "../values.js"
export type SourcePosition = {
line: number
@@ -35,7 +35,7 @@ export type StatementResult =
| { kind: "continue" }
export type MemberReference = {
target: SafeObject | Array<unknown> | SandboxURL
target: SafeObject | Array<unknown> | CodeModeURL
key: string | number
}
@@ -71,7 +71,7 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
export class PromiseInstanceMethodReference {
constructor(
readonly promise: SandboxPromise,
readonly promise: CodeModePromise,
readonly name: PromiseInstanceMethodName,
) {}
}
+26 -26
View File
@@ -17,24 +17,24 @@ import { applyCollectionCallback, type CallbackRunner } from "./methods.js"
import { typeofValue } from "./references.js"
import { spreadItems } from "../stdlib/collections.js"
import { createAggregateErrorValue } from "../stdlib/value.js"
import { SandboxPromise } from "../values.js"
import { CodeModePromise } from "../values.js"
// Observation only controls rejection reporting; program completion interrupts all promise work.
export class PromiseRuntime<R> {
private readonly active = new Set<SandboxPromise>()
private readonly ids = new WeakMap<SandboxPromise, number>()
private readonly observed = new WeakSet<SandboxPromise>()
private readonly active = new Set<CodeModePromise>()
private readonly ids = new WeakMap<CodeModePromise, number>()
private readonly observed = new WeakSet<CodeModePromise>()
private readonly failures = new Map<number, Diagnostic>()
private nextID = 0
constructor(private readonly scope: Scope.Scope) {}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
return Effect.suspend(() => {
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
const id = this.nextID++
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
const promise = new SandboxPromise(fiber)
const promise = new CodeModePromise(fiber)
this.active.add(promise)
this.ids.set(promise, id)
fiber.addObserver((exit) => {
@@ -55,14 +55,14 @@ export class PromiseRuntime<R> {
}
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
markObserved(promise: SandboxPromise): void {
markObserved(promise: CodeModePromise): void {
this.observed.add(promise)
const id = this.ids.get(promise)
this.ids.delete(promise)
if (id !== undefined) this.failures.delete(id)
}
await(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
await(promise: CodeModePromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
return Fiber.await(promise.fiber)
}
@@ -94,7 +94,7 @@ export const invokePromiseMethod = <R>(
): Effect.Effect<unknown, unknown, R> => {
if (ref.name === "resolve") {
const value = args[0]
return value instanceof SandboxPromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
return value instanceof CodeModePromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
}
if (ref.name === "reject") {
return promises.create(Effect.fail(new ProgramThrow(args[0])))
@@ -114,19 +114,19 @@ export const invokePromiseMethod = <R>(
const items = Array.from(spread)
for (const item of items) {
if (item instanceof SandboxPromise) promises.markObserved(item)
if (item instanceof CodeModePromise) promises.markObserved(item)
}
switch (ref.name) {
case "all": {
const observations = items.map((item) =>
item instanceof SandboxPromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
)
return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
}
case "allSettled": {
const observations = items.map((item) =>
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
)
return promises.create(
settleAfterTurn(
@@ -168,13 +168,13 @@ export const invokePromiseMethod = <R>(
)
}
const observations = items.map((item) =>
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
)
return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
}
case "any": {
const flipped = items.map((item) =>
item instanceof SandboxPromise
item instanceof CodeModePromise
? Effect.flatMap(promises.await(item), (exit) => {
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
@@ -201,7 +201,7 @@ export const invokePromiseInstanceMethod = <R>(
ref: PromiseInstanceMethodReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<SandboxPromise, never, R> => {
): Effect.Effect<CodeModePromise, never, R> => {
const method = `Promise.prototype.${ref.name}`
promises.markObserved(ref.promise)
if (ref.name === "finally") {
@@ -217,7 +217,7 @@ export const constructPromise = <R>(
promises: PromiseRuntime<R>,
executor: unknown,
node: AstNode,
): Effect.Effect<SandboxPromise, unknown, R> => {
): Effect.Effect<CodeModePromise, unknown, R> => {
if (!(executor instanceof CodeModeFunction)) {
throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
@@ -226,10 +226,10 @@ export const constructPromise = <R>(
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const box: { own?: SandboxPromise } = {}
const box: { own?: CodeModePromise } = {}
const promise = yield* promises.create(
Effect.flatMap(Deferred.await(deferred), (value) => {
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
if (value === box.own) return Effect.fail(selfResolutionError(node))
return runner.settlePromise(value)
}),
@@ -281,7 +281,7 @@ const reactionHandler = (value: unknown, method: string, node: AstNode): Reactio
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
const reactionExit = <R>(
promises: PromiseRuntime<R>,
source: SandboxPromise,
source: CodeModePromise,
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
Effect.gen(function* () {
const exit = yield* promises.await(source)
@@ -293,13 +293,13 @@ const reactionExit = <R>(
const chainReaction = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: SandboxPromise,
source: CodeModePromise,
onFulfilled: ReactionHandler | undefined,
onRejected: ReactionHandler | undefined,
method: string,
node: AstNode,
): Effect.Effect<SandboxPromise, never, R> => {
const box: { derived?: SandboxPromise } = {}
): Effect.Effect<CodeModePromise, never, R> => {
const box: { derived?: CodeModePromise } = {}
const body = Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
@@ -307,7 +307,7 @@ const chainReaction = <R>(
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
if (result instanceof SandboxPromise) return yield* runner.settlePromise(result)
if (result instanceof CodeModePromise) return yield* runner.settlePromise(result)
return result
})
return Effect.map(promises.create(body), (derived) => {
@@ -319,17 +319,17 @@ const chainReaction = <R>(
const chainFinally = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: SandboxPromise,
source: CodeModePromise,
cleanup: ReactionHandler | undefined,
method: string,
node: AstNode,
): Effect.Effect<SandboxPromise, never, R> =>
): Effect.Effect<CodeModePromise, never, R> =>
promises.create(
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
if (cleanup !== undefined) {
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
if (result instanceof SandboxPromise) yield* runner.settlePromise(result)
if (result instanceof CodeModePromise) yield* runner.settlePromise(result)
}
return yield* exit
}),
@@ -15,7 +15,7 @@ import {
UriFunction,
} from "./model.js"
import { ToolReference } from "../tool-runtime.js"
import { isSandboxValue, SandboxPromise } from "../values.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof CodeModeFunction ||
@@ -26,13 +26,13 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof PromiseNamespace ||
value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference ||
value instanceof SandboxPromise ||
value instanceof CodeModePromise ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
isSandboxValue(value)
isCodeModeValue(value)
export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
if (isRuntimeReference(value)) return true
@@ -46,9 +46,9 @@ export const containsRuntimeReference = (value: unknown, seen = new Set<object>(
return contains
}
// Sandbox values are data here, not opaque interpreter references.
// CodeMode values are data here, not opaque interpreter references.
export const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
if (isSandboxValue(value)) return false
if (isCodeModeValue(value)) return false
if (isRuntimeReference(value)) return true
if (value === null || typeof value !== "object") return false
if (seen.has(value)) return false
+64 -64
View File
@@ -73,14 +73,14 @@ import {
valueConstructors,
} from "../stdlib/value.js"
import {
isSandboxValue,
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
@@ -91,24 +91,24 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
if (rhs instanceof GlobalNamespace) {
switch (rhs.name) {
case "Date":
return lhs instanceof SandboxDate
return lhs instanceof CodeModeDate
case "RegExp":
return lhs instanceof SandboxRegExp
return lhs instanceof CodeModeRegExp
case "Map":
return lhs instanceof SandboxMap
return lhs instanceof CodeModeMap
case "Set":
return lhs instanceof SandboxSet
return lhs instanceof CodeModeSet
case "URL":
return lhs instanceof SandboxURL
return lhs instanceof CodeModeURL
case "URLSearchParams":
return lhs instanceof SandboxURLSearchParams
return lhs instanceof CodeModeURLSearchParams
case "Array":
return Array.isArray(lhs)
case "Object":
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
}
}
if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise
if (rhs instanceof PromiseNamespace) return lhs instanceof CodeModePromise
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
return false
}
@@ -226,7 +226,7 @@ export class Interpreter<R> {
}
// The implicit async body adopts returned promises before copy-out.
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
if (value instanceof CodeModePromise) value = yield* self.settlePromise(value)
return value
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
}
@@ -235,16 +235,16 @@ export class Interpreter<R> {
private createToolCallPromise(
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<SandboxPromise, never, R> {
): Effect.Effect<CodeModePromise, never, R> {
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args)))
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
return this.promises.create(effect)
}
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
private settlePromise(promise: SandboxPromise): Effect.Effect<unknown, unknown, never> {
private settlePromise(promise: CodeModePromise): Effect.Effect<unknown, unknown, never> {
const promises = this.promises
return Effect.suspend(() => {
promises.markObserved(promise)
@@ -971,7 +971,7 @@ export class Interpreter<R> {
// Await always suspends, including for plain values.
const self = this
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
value instanceof SandboxPromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
value instanceof CodeModePromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
)
}
case "NewExpression":
@@ -1019,23 +1019,23 @@ export class Interpreter<R> {
throw unsupportedSyntax("NewExpression", node)
}
private constructDate(args: Array<unknown>): SandboxDate {
if (args.length === 0) return new SandboxDate(Date.now())
private constructDate(args: Array<unknown>): CodeModeDate {
if (args.length === 0) return new CodeModeDate(Date.now())
if (args.length === 1) {
const arg = args[0]
if (arg instanceof SandboxDate) return new SandboxDate(arg.time)
if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime())
if (typeof arg === "string") return new SandboxDate(Date.parse(arg))
return new SandboxDate(Number.NaN)
if (arg instanceof CodeModeDate) return new CodeModeDate(arg.time)
if (typeof arg === "number") return new CodeModeDate(new Date(arg).getTime())
if (typeof arg === "string") return new CodeModeDate(Date.parse(arg))
return new CodeModeDate(Number.NaN)
}
const parts = args.map((arg) => coerceToNumber(arg))
return new SandboxDate(new Date(...(parts as [number, number])).getTime())
return new CodeModeDate(new Date(...(parts as [number, number])).getTime())
}
private constructRegExp(args: Array<unknown>, node: AstNode): SandboxRegExp {
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
const first = args[0]
const pattern =
first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
first instanceof CodeModeRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
const flagsArg = args[1]
if (flagsArg !== undefined && typeof flagsArg !== "string") {
throw new InterpreterRuntimeError(
@@ -1043,9 +1043,9 @@ export class Interpreter<R> {
node,
)
}
const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "")
const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
try {
return new SandboxRegExp(pattern, flags)
return new CodeModeRegExp(pattern, flags)
} catch (error) {
const reason = regexFailureReason(error)
throw new InterpreterRuntimeError(
@@ -1057,12 +1057,12 @@ export class Interpreter<R> {
}
}
private constructMap(init: unknown, node: AstNode): SandboxMap {
const target = new SandboxMap()
private constructMap(init: unknown, node: AstNode): CodeModeMap {
const target = new CodeModeMap()
if (init === undefined || init === null) return target
const entries = Array.isArray(init)
? init
: init instanceof SandboxMap
: init instanceof CodeModeMap
? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item])
: undefined
if (entries === undefined) {
@@ -1080,12 +1080,12 @@ export class Interpreter<R> {
return target
}
private constructSet(init: unknown, node: AstNode): SandboxSet {
const target = new SandboxSet()
private constructSet(init: unknown, node: AstNode): CodeModeSet {
const target = new CodeModeSet()
if (init === undefined || init === null) return target
const items = Array.isArray(init)
? init
: init instanceof SandboxSet
: init instanceof CodeModeSet
? Array.from(init.set.values())
: typeof init === "string"
? Array.from(init)
@@ -1097,7 +1097,7 @@ export class Interpreter<R> {
return target
}
private constructURL(args: Array<unknown>, node: AstNode): SandboxURL {
private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
if (args.length === 0) {
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
"TypeError",
@@ -1106,7 +1106,7 @@ export class Interpreter<R> {
const input = urlArgument(args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
try {
return new SandboxURL(new URL(input, base))
return new CodeModeURL(new URL(input, base))
} catch {
throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
@@ -1115,16 +1115,16 @@ export class Interpreter<R> {
}
}
private constructURLSearchParams(init: unknown, node: AstNode): SandboxURLSearchParams {
if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams())
if (init instanceof SandboxURLSearchParams) {
return new SandboxURLSearchParams(new URLSearchParams(init.params))
private constructURLSearchParams(init: unknown, node: AstNode): CodeModeURLSearchParams {
if (init === undefined) return new CodeModeURLSearchParams(new URLSearchParams())
if (init instanceof CodeModeURLSearchParams) {
return new CodeModeURLSearchParams(new URLSearchParams(init.params))
}
if (typeof init === "string") return new SandboxURLSearchParams(new URLSearchParams(init))
if (typeof init === "string") return new CodeModeURLSearchParams(new URLSearchParams(init))
if (init === null || typeof init === "number" || typeof init === "boolean") {
return new SandboxURLSearchParams(new URLSearchParams(coerceToString(init)))
return new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init)))
}
if (init instanceof SandboxMap) {
if (init instanceof CodeModeMap) {
return this.constructURLSearchParams(
Array.from(init.map.entries(), ([key, value]) => [key, value]),
node,
@@ -1143,9 +1143,9 @@ export class Interpreter<R> {
string,
]
})
return new SandboxURLSearchParams(new URLSearchParams(entries))
return new CodeModeURLSearchParams(new URLSearchParams(entries))
}
if (isSandboxValue(init)) return new SandboxURLSearchParams(new URLSearchParams())
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
const data = boundedData(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") {
throw new InterpreterRuntimeError(
@@ -1153,7 +1153,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
return new SandboxURLSearchParams(
return new CodeModeURLSearchParams(
new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))),
)
}
@@ -1176,7 +1176,7 @@ export class Interpreter<R> {
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use string coercion for `+` and epoch time elsewhere.
const coerceOperand = (operand: unknown): unknown => {
if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time
if (operand instanceof CodeModeDate) return operator === "+" ? coerceToString(operand) : operand.time
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
}
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"
@@ -1261,7 +1261,7 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
}
const operand =
value instanceof SandboxDate
value instanceof CodeModeDate
? value.time
: value !== null && typeof value === "object"
? coerceToString(value)
@@ -1520,11 +1520,11 @@ export class Interpreter<R> {
})
if (!fn.async) return run
// The initial yield assigns `box.own` before the body can self-resolve.
const box: { own?: SandboxPromise } = {}
const box: { own?: CodeModePromise } = {}
return Effect.map(
this.createPromise(
Effect.flatMap(run, (value) => {
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
if (value === box.own) return Effect.fail(selfResolutionError())
return invocation.settlePromise(value)
}),
@@ -1546,7 +1546,7 @@ export class Interpreter<R> {
if (property.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
if (spread === null || spread === undefined || isSandboxValue(spread)) continue
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError(
"Object spread requires a data object in CodeMode.",
@@ -1748,28 +1748,28 @@ export class Interpreter<R> {
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
}
if (objectValue instanceof SandboxDate) {
if (objectValue instanceof CodeModeDate) {
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof SandboxRegExp) {
if (objectValue instanceof CodeModeRegExp) {
if (typeof key === "string" && regexpProperties.has(key)) {
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
}
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof SandboxMap) {
if (objectValue instanceof CodeModeMap) {
if (key === "size") return new ComputedValue(objectValue.map.size)
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof SandboxSet) {
if (objectValue instanceof CodeModeSet) {
if (key === "size") return new ComputedValue(objectValue.set.size)
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof SandboxURL) {
if (objectValue instanceof CodeModeURL) {
if (key === "searchParams") {
return new ComputedValue(objectValue.searchParams)
}
@@ -1777,7 +1777,7 @@ export class Interpreter<R> {
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
return new ComputedValue(undefined)
}
if (objectValue instanceof SandboxURLSearchParams) {
if (objectValue instanceof CodeModeURLSearchParams) {
if (key === "size") return new ComputedValue(objectValue.params.size)
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
return new IntrinsicReference(objectValue, key)
@@ -1786,7 +1786,7 @@ export class Interpreter<R> {
}
// Reject unknown promise properties so a missing await cannot hide.
if (objectValue instanceof SandboxPromise) {
if (objectValue instanceof CodeModePromise) {
if (key === "then" || key === "catch" || key === "finally") {
return new PromiseInstanceMethodReference(objectValue, key)
}
@@ -1851,7 +1851,7 @@ export class Interpreter<R> {
}
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
}
if (reference.target instanceof SandboxURL) {
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
}
return reference.target[String(reference.key)]
@@ -1891,7 +1891,7 @@ export class Interpreter<R> {
}
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
const current =
reference.target instanceof SandboxURL
reference.target instanceof CodeModeURL
? (reference.target.url as unknown as Record<string, unknown>)[key]
: (reference.target as Record<PropertyKey, unknown>)[key]
const { write, next, result } = yield* compute(current)
@@ -1915,7 +1915,7 @@ export class Interpreter<R> {
target[index] = next
return
}
if (reference.target instanceof SandboxURL) {
if (reference.target instanceof CodeModeURL) {
const property = key as string
if (!urlWritableProperties.has(property)) {
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
+4 -4
View File
@@ -43,9 +43,9 @@ export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
if (Array.isArray(value)) return value
if (typeof value === "string") return Array.from(value)
if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
if (value instanceof SandboxSet) return Array.from(value.set.values())
if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
if (value instanceof CodeModeMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
if (value instanceof CodeModeSet) return Array.from(value.set.values())
if (value instanceof CodeModeURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
return undefined
}
import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js"
import { CodeModeMap, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
+17 -17
View File
@@ -1,14 +1,14 @@
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
import { copyIn, copyOut } from "../tool-runtime.js"
import {
isSandboxValue,
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { boundedData, coerceToString } from "./value.js"
@@ -34,14 +34,14 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (typeof value === "string") return JSON.stringify(value)
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (typeof value !== "object") return String(value)
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
if (value instanceof SandboxDate) return coerceToString(value)
if (value instanceof SandboxRegExp) return coerceToString(value)
if (value instanceof SandboxURL) return coerceToString(value)
if (value instanceof SandboxURLSearchParams) return coerceToString(value)
if (value instanceof CodeModePromise) return "[Promise (await it to get its value)]"
if (value instanceof CodeModeDate) return coerceToString(value)
if (value instanceof CodeModeRegExp) return coerceToString(value)
if (value instanceof CodeModeURL) return coerceToString(value)
if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
if (value instanceof SandboxMap) {
if (value instanceof CodeModeMap) {
seen.add(value)
try {
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
@@ -50,7 +50,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
seen.delete(value)
}
}
if (value instanceof SandboxSet) {
if (value instanceof CodeModeSet) {
seen.add(value)
try {
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
@@ -100,14 +100,14 @@ const consoleTableRows = (
if (Array.isArray(data)) {
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
}
if (data !== null && typeof data === "object" && !isSandboxValue(data)) {
if (data !== null && typeof data === "object" && !isCodeModeValue(data)) {
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
}
return [{ index: "0", values: { Value: data } }]
}
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isCodeModeValue(value)) {
const source = value as Record<string, unknown>
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
return Object.fromEntries(Object.entries(source))
+2 -2
View File
@@ -36,7 +36,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
}
}
export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => {
export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNode): unknown => {
const hosted = new Date(value.time)
switch (name) {
case "getTime":
@@ -88,5 +88,5 @@ export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { SandboxDate } from "../values.js"
import { CodeModeDate } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
+9 -9
View File
@@ -1,6 +1,6 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
@@ -9,8 +9,8 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
const requireObject = (): Record<string, unknown> => {
const input = args[0]
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
if (isSandboxValue(input)) return {}
if (input instanceof SandboxPromise) {
if (isCodeModeValue(input)) return {}
if (input instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
node,
@@ -46,12 +46,12 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return Object.hasOwn(requireObject(), String(args[1]))
case "assign": {
const target = args[0]
if (target === null || typeof target !== "object" || Array.isArray(target) || isSandboxValue(target)) {
if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) {
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
}
const out = target as Record<string, unknown>
for (const source of args.slice(1)) {
if (source === null || source === undefined || isSandboxValue(source)) continue
if (source === null || source === undefined || isCodeModeValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
@@ -60,17 +60,17 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return out
}
case "fromEntries": {
if (args[0] instanceof SandboxMap) {
if (args[0] instanceof CodeModeMap) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, item] of args[0].map.entries()) addEntry(out, key, item)
return out
}
if (args[0] instanceof SandboxURLSearchParams) {
if (args[0] instanceof CodeModeURLSearchParams) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
return out
}
const pairs = args[0] instanceof SandboxSet ? Array.from(args[0].set.values()) : args[0]
const pairs = args[0] instanceof CodeModeSet ? Array.from(args[0].set.values()) : args[0]
if (!Array.isArray(pairs)) {
boundedData(args[0], "Object.fromEntries input")
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
@@ -78,7 +78,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
const out: Record<string, unknown> = Object.create(null)
for (const pair of pairs) {
const validated = boundedData(pair, "Object.fromEntries entry")
if (validated === null || typeof validated !== "object" || isSandboxValue(validated))
if (validated === null || typeof validated !== "object" || isCodeModeValue(validated))
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
const entry = pair as Record<string, unknown>
addEntry(out, entry[0], entry[1])
+3 -3
View File
@@ -19,7 +19,7 @@ export const escapeRegexHint =
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
if (arg instanceof SandboxRegExp) return arg.regex
if (arg instanceof CodeModeRegExp) return arg.regex
if (typeof arg === "string") {
try {
return new RegExp(arg, extraFlags)
@@ -50,7 +50,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
}
export const invokeRegExpMethod = (
value: SandboxRegExp,
value: CodeModeRegExp,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -70,5 +70,5 @@ export const invokeRegExpMethod = (
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { SandboxRegExp } from "../values.js"
import { CodeModeRegExp } from "../values.js"
import { coerceToString } from "./value.js"
+4 -4
View File
@@ -66,7 +66,7 @@ export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node:
}
export const urlArgument = (value: unknown, label: string): string =>
value instanceof SandboxURL ? value.url.href : uriArgument(value, label)
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
@@ -75,16 +75,16 @@ export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNod
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
try {
const url = new URL(input, base)
return name === "canParse" ? true : new SandboxURL(url)
return name === "canParse" ? true : new CodeModeURL(url)
} catch {
return name === "canParse" ? false : null
}
}
export const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => {
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
}
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
import { SandboxURL } from "../values.js"
import { CodeModeURL } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
+16 -16
View File
@@ -34,13 +34,13 @@ export const boundedData = (value: unknown, label: string): unknown => copyIn(va
export const coerceToString = (value: unknown): string => {
if (value === null) return "null"
if (value === undefined) return "undefined"
if (value instanceof SandboxDate)
if (value instanceof CodeModeDate)
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof SandboxMap) return "[object Map]"
if (value instanceof SandboxSet) return "[object Set]"
if (value instanceof SandboxURL) return value.url.href
if (value instanceof SandboxURLSearchParams) return value.params.toString()
if (value instanceof CodeModeRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof CodeModeMap) return "[object Map]"
if (value instanceof CodeModeSet) return "[object Set]"
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof CodeModeURLSearchParams) return value.params.toString()
if (typeof value === "object") {
return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
@@ -50,14 +50,14 @@ export const coerceToString = (value: unknown): string => {
}
export const coerceToNumber = (value: unknown): number => {
if (value instanceof SandboxDate) return value.time
if (isSandboxValue(value)) return Number.NaN
if (value instanceof CodeModeDate) return value.time
if (isCodeModeValue(value)) return Number.NaN
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
}
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
const raw = args[0]
if (isSandboxValue(raw)) {
if (isCodeModeValue(raw)) {
if (ref.name === "Boolean") return true
if (ref.name === "Number") return coerceToNumber(raw)
if (ref.name === "String") return coerceToString(raw)
@@ -80,11 +80,11 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
import { copyIn, type SafeObject } from "../tool-runtime.js"
import {
isSandboxValue,
SandboxDate,
SandboxMap,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
+34 -34
View File
@@ -10,13 +10,13 @@ import {
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import {
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
@@ -141,16 +141,16 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
// Checkpoint mode preserves sandbox values; boundary mode JSON-normalizes them.
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
// Checkpoint mode preserves CodeMode values; boundary mode JSON-normalizes them.
export const copyIn = (value: unknown, label: string, preserveCodeModeValues = false): unknown =>
copyBounded(value, label, 0, new Set(), preserveCodeModeValues)
const copyBounded = (
value: unknown,
label: string,
depth: number,
seen: Set<object>,
preserveSandboxValues: boolean,
preserveCodeModeValues: boolean,
): unknown => {
if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
@@ -169,55 +169,55 @@ const copyBounded = (
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
if (value instanceof SandboxPromise) {
if (value instanceof CodeModePromise) {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
)
}
if (preserveSandboxValues) {
if (preserveCodeModeValues) {
if (
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
value instanceof CodeModeDate ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURL ||
value instanceof CodeModeURLSearchParams
) {
return value
}
if (value instanceof Date) return new SandboxDate(value.getTime())
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
if (value instanceof Date) return new CodeModeDate(value.getTime())
if (value instanceof RegExp) return new CodeModeRegExp(value.source, value.flags)
if (value instanceof Map) {
const wrapped = new SandboxMap()
const wrapped = new CodeModeMap()
for (const [key, item] of value.entries()) {
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
}
return wrapped
}
if (value instanceof Set) {
const wrapped = new SandboxSet()
const wrapped = new CodeModeSet()
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped
}
if (value instanceof URL) return new SandboxURL(new URL(value.href))
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
if (value instanceof URL) return new CodeModeURL(new URL(value.href))
if (value instanceof URLSearchParams) return new CodeModeURLSearchParams(new URLSearchParams(value))
}
if (value instanceof SandboxDate) {
if (value instanceof CodeModeDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null
}
if (value instanceof SandboxURL) return value.url.href
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof URL) return value.href
if (
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof SandboxURLSearchParams ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURLSearchParams ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set ||
@@ -233,8 +233,8 @@ const copyBounded = (
seen.add(value)
if (Array.isArray(value)) {
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
if (preserveSandboxValues) {
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveCodeModeValues))
if (preserveCodeModeValues) {
// Checkpoint copies retain array metadata that boundary copies omit.
for (const [key, item] of Object.entries(value)) {
if (Object.hasOwn(copied, key)) continue
@@ -258,7 +258,7 @@ const copyBounded = (
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues)
copied[key] = copyBounded(item, label, depth + 1, seen, preserveCodeModeValues)
}
seen.delete(value)
return copied
+17 -17
View File
@@ -1,45 +1,45 @@
import type { Fiber } from "effect"
export class SandboxPromise {
export class CodeModePromise {
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
}
export class SandboxDate {
export class CodeModeDate {
constructor(readonly time: number) {}
}
export class SandboxRegExp {
export class CodeModeRegExp {
readonly regex: RegExp
constructor(pattern: string, flags: string) {
this.regex = new RegExp(pattern, flags)
}
}
export class SandboxMap {
export class CodeModeMap {
readonly map = new Map<unknown, unknown>()
}
export class SandboxSet {
export class CodeModeSet {
readonly set = new Set<unknown>()
}
export class SandboxURLSearchParams {
export class CodeModeURLSearchParams {
constructor(readonly params: URLSearchParams) {}
}
export class SandboxURL {
readonly searchParams: SandboxURLSearchParams
export class CodeModeURL {
readonly searchParams: CodeModeURLSearchParams
constructor(readonly url: URL) {
this.searchParams = new SandboxURLSearchParams(url.searchParams)
this.searchParams = new CodeModeURLSearchParams(url.searchParams)
}
}
export const isSandboxValue = (
export const isCodeModeValue = (
value: unknown,
): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams =>
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
): value is CodeModeDate | CodeModeRegExp | CodeModeMap | CodeModeSet | CodeModeURL | CodeModeURLSearchParams =>
value instanceof CodeModeDate ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURL ||
value instanceof CodeModeURLSearchParams
+2 -2
View File
@@ -276,7 +276,7 @@ describe("CodeMode console capture", () => {
expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}'])
})
test("renders sandbox values nested inside logged containers", async () => {
test("renders CodeMode values nested inside logged containers", async () => {
const result = await Effect.runPromise(
CodeMode.execute({
code: `
@@ -311,7 +311,7 @@ describe("CodeMode console capture", () => {
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}'])
})
test("console.table renders sandbox value cells", async () => {
test("console.table renders CodeMode value cells", async () => {
const result = await Effect.runPromise(
CodeMode.execute({
code: `
+6 -6
View File
@@ -8,7 +8,7 @@ import { ToolRuntime } from "../src/tool-runtime.js"
// a strict interpreter would throw but idiomatic JS yields undefined / succeeds.
//
// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when
// it crosses out of the sandbox (results are JSON data), so tests asserting an in-sandbox
// it crosses out of CodeMode (results are JSON data), so tests asserting an in-CodeMode
// `undefined` read check `=== undefined` inside the program and `null` at the boundary.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
@@ -108,7 +108,7 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0)
})
test("a non-finite value becomes null when it leaves the sandbox", async () => {
test("a non-finite value becomes null when it leaves CodeMode", async () => {
expect(await value(`return 5/0`)).toBeNull()
expect(await value(`return 0/0`)).toBeNull()
expect(await value(`return Math.max()`)).toBeNull()
@@ -116,12 +116,12 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] })
})
test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => {
test("NaN and Infinity are usable identifiers and inspectable in-CodeMode", async () => {
expect(await value(`return Number.isNaN(NaN)`)).toBe(true)
expect(await value(`return Infinity > 1e9`)).toBe(true)
expect(await value(`return Number.isFinite(1/0)`)).toBe(false)
expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3)
// JSON.stringify inside the sandbox matches JS: non-finite serializes to null
// JSON.stringify inside CodeMode matches JS: non-finite serializes to null
expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}')
})
@@ -321,12 +321,12 @@ describe("compound assignment matches its binary operator", () => {
return a
}
test("sandbox Date += concatenates its string form, like d = d + 1", async () => {
test("CodeMode Date += concatenates its string form, like d = d + 1", async () => {
const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`)
expect(result).toBe("1970-01-01T00:00:01.000Z1")
})
test("sandbox Date numeric compound ops use its time value", async () => {
test("CodeMode Date numeric compound ops use its time value", async () => {
expect(
await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
).toBe(600)
@@ -212,7 +212,7 @@ describe("Test262 Promise statics", () => {
])
})
test("Promise.resolve adopts values and preserves sandbox-promise identity", async () => {
test("Promise.resolve adopts values and preserves CodeMode-promise identity", async () => {
// Sources:
// test/built-ins/Promise/resolve/S25.4.4.5_A2.1_T1.js
// test/built-ins/Promise/resolve/resolve-non-obj.js
@@ -289,7 +289,7 @@ describe("Test262 Promise statics", () => {
// test/built-ins/Promise/all/reject-immed.js
// test/built-ins/Promise/allSettled/reject-immed.js
// test/built-ins/Promise/race/reject-immed.js
// (adapted: immediately-rejecting thenables become sandbox promises that settled,
// (adapted: immediately-rejecting thenables become CodeMode promises that settled,
// and were even observed, before the combinator call)
expect(
await value(`
@@ -360,7 +360,7 @@ describe("Test262 Promise statics", () => {
// test/built-ins/Promise/race/S25.4.4.3_A2.1_T1.js
// test/built-ins/Promise/race/S25.4.4.3_A5.1_T1.js
// (adapted: upstream requires Promise.race([]) to never settle; CodeMode intentionally
// rejects with a catchable diagnostic instead of hanging, so this asserts the sandbox
// rejects with a catchable diagnostic instead of hanging, so this asserts CodeMode
// divergence rather than the spec never-settles behavior)
expect(
await value(`
@@ -375,7 +375,7 @@ describe("Test262 Promise statics", () => {
).toEqual([true, true])
})
test("Promise.resolve passes the same sandbox promise through nested chains", async () => {
test("Promise.resolve passes the same CodeMode promise through nested chains", async () => {
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.2_T1.js
// (adapted: no executor construction, and identity is observed with Array includes
// because promises are not comparable data values in CodeMode)
@@ -1208,7 +1208,7 @@ describe("Test262 AggregateError", () => {
test("coerces a non-string message to a string", async () => {
// Source: test/built-ins/AggregateError/message-method-prop-cast.js (value coercion only; the
// upstream object-with-toString case is omitted because the sandbox has no user toString dispatch)
// upstream object-with-toString case is omitted because CodeMode has no user toString dispatch)
expect(
await value(`
return [
@@ -1408,7 +1408,7 @@ describe("Test262 Promise constructor", () => {
test.failing("calling Promise without new throws TypeError", async () => {
// Source: test/built-ins/Promise/undefined-newtarget.js
// The sandbox currently reports a generic Error ("Only tools are callable in CodeMode.").
// CodeMode currently reports a generic Error ("Only tools are callable in CodeMode.").
expect(
await value(`
try {
+8 -8
View File
@@ -3,7 +3,7 @@ import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
@@ -69,7 +69,7 @@ describe("Date", () => {
).toEqual([2024, 2, 5, 6, 7, 8, 9])
})
test("invalid dates yield NaN times, guardable in-sandbox", async () => {
test("invalid dates yield NaN times, guardable in-CodeMode", async () => {
expect(await value(`return Number.isNaN(new Date("garbage").getTime())`)).toBe(true)
expect(await value(`return new Date("garbage").toJSON()`)).toBeNull()
})
@@ -702,11 +702,11 @@ describe("stdlib integration", () => {
expect(await value(`const fn = () => 1; return !fn`)).toBe(false)
})
test("object spread of sandbox values is a no-op, like JS", async () => {
test("object spread of CodeMode values is a no-op, like JS", async () => {
expect(await value(`return { ...new Map([["a", 1]]), kept: true }`)).toEqual({ kept: true })
})
test("dates inside Map values survive in-sandbox reads", async () => {
test("dates inside Map values survive in-CodeMode reads", async () => {
expect(
await value(`
const m = new Map([["start", new Date(1000)]])
@@ -748,7 +748,7 @@ describe("stdlib integration", () => {
})
})
describe("sandbox values at intra-sandbox checkpoints", () => {
describe("CodeMode values at intra-CodeMode checkpoints", () => {
test("Object.values/entries keep Dates usable", async () => {
expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0)
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe(
@@ -799,7 +799,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
)
})
test("object and array spread keep sandbox values usable", async () => {
test("object and array spread keep CodeMode values usable", async () => {
expect(
await value(`
const src = { m: new Map([["a", 1]]) }
@@ -811,7 +811,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000)
})
test("Array.from over arrays keeps nested sandbox values usable", async () => {
test("Array.from over arrays keeps nested CodeMode values usable", async () => {
expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5)
})
@@ -866,7 +866,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true)
})
test("Object.* helpers see sandbox values as empty objects, never internals", async () => {
test("Object.* helpers see CodeMode values as empty objects, never internals", async () => {
expect(await value(`return Object.keys(new Map([["a", 1]]))`)).toEqual([])
expect(await value(`return Object.values(new Date(0))`)).toEqual([])
expect(await value(`return Object.entries(new Set([1]))`)).toEqual([])
+2 -1
View File
@@ -16,6 +16,7 @@ import { Token } from "../util/token"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
@@ -320,7 +321,7 @@ const make = (dependencies: Dependencies) => {
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const output = input.model.route.defaults.limits?.output ?? 0
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const used =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (used <= 0) return false
+24
View File
@@ -139,6 +139,11 @@ const compactModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
})
const fullOutputModel = Model.make({
id: "full-output",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 262_144, output: 262_144 } }),
})
const undersizedContextModel = Model.make({
id: "undersized-context",
provider: "fake",
@@ -1865,6 +1870,25 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not compact immediately when the advertised output limit fills the context", () =>
Effect.gen(function* () {
const session = yield* setup
currentModel = fullOutputModel
response = reply.textWithUsage("Earlier answer", "text-full-output-first", 9_500)
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
requests.length = 0
response = reply.text("Continued", "text-full-output-final")
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])).toContain("Continue")
expect(yield* session.context(sessionID)).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
}),
)
it.effect("stops after required automatic compaction fails", () =>
Effect.gen(function* () {
const session = yield* setup
+1 -2
View File
@@ -22,7 +22,7 @@
"./context/log": "./src/context/log.tsx",
"./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx",
"./context/sdk": "./src/context/sdk.tsx",
"./context/client": "./src/context/client.tsx",
"./context/theme": "./src/context/theme.tsx",
"./context/editor": "./src/context/editor.ts",
"./context/clipboard": "./src/context/clipboard.tsx",
@@ -55,7 +55,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:",
+12 -56
View File
@@ -41,7 +41,7 @@ import { PluginRouteMissing } from "./component/plugin-route-missing"
import { ProjectProvider, useProject } from "./context/project"
import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk"
import { ClientProvider, useClient } from "./context/client"
import { StartupLoading } from "./component/startup-loading"
import { Reconnecting } from "./component/reconnecting"
import { DataProvider, useData } from "./context/data"
@@ -55,7 +55,6 @@ import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
@@ -66,8 +65,6 @@ import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { isDefaultTitle } from "./util/session"
import * as Model from "./util/model"
@@ -198,7 +195,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const endpoint = await reconnectEndpoint(attempt)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return {
client: createOpencodeClient({ ...next, directory }),
api: OpenCode.make(next),
}
}
@@ -336,8 +332,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
client={createOpencodeClient({ ...options, directory })}
<ClientProvider
api={api}
reconnect={reconnect}
reload={input.server.reload}
@@ -377,7 +372,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</DataProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
@@ -426,7 +421,7 @@ function App(props: {
const local = useLocal()
const keymap = useOpencodeKeymap()
const event = useEvent()
const sdk = useSDK()
const client = useClient()
const toast = useToast()
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
@@ -475,7 +470,7 @@ function App(props: {
route,
routes: pluginRuntime.routes,
event,
sdk,
client,
project,
data,
theme: themeState,
@@ -585,7 +580,7 @@ function App(props: {
if (continued || !args.continue) return
continued = true
const location = data.location.default()
void sdk.api.session
void client.api.session
.list({
limit: 1,
order: "desc",
@@ -600,7 +595,7 @@ function App(props: {
route.navigate({ type: "session", sessionID: match })
return
}
void sdk.api.session
void client.api.session
.fork({ sessionID: match })
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
.catch(toast.error)
@@ -613,7 +608,7 @@ function App(props: {
createEffect(() => {
if (forked || !args.sessionID || !args.fork) return
forked = true
void sdk.api.session
void client.api.session
.fork({ sessionID: args.sessionID })
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
.catch(toast.error)
@@ -815,7 +810,7 @@ function App(props: {
},
category: "System",
},
...(sdk.reload
...(client.reload
? [
{
name: "server.reload",
@@ -826,7 +821,7 @@ function App(props: {
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
// reload resolves once the replacement service is healthy; the
// event stream reattaches through the reconnect loop.
await sdk.reload!()
await client.reload!()
.then(() => toast.show({ variant: "success", message: "Server reloaded" }))
.catch(toast.error)
},
@@ -1092,45 +1087,6 @@ function App(props: {
})
})
event.on("installation.update-available", async (evt) => {
const version = evt.data.version
const choice = await DialogConfirm.show(
dialog,
`Update Available`,
`A new release v${version} is available. Would you like to update now?`,
"later",
)
if (choice !== true) return
toast.show({
variant: "info",
message: `Updating to v${version}...`,
duration: 30000,
})
const result = await sdk.client.global.upgrade({ target: version })
if (result.error || !result.data?.success) {
toast.show({
variant: "error",
title: "Update Failed",
message: "Update failed",
duration: 10000,
})
return
}
await DialogAlert.show(
dialog,
"Update Complete",
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
)
void exit()
})
const plugin = createMemo(() => {
if (!ready()) return
if (route.data.type !== "plugin") return
@@ -1148,7 +1104,7 @@ function App(props: {
clearTimeout(reconnectTimer)
reconnectTimer = undefined
}
const status = sdk.connection.status()
const status = client.connection.status()
if (status === "connected") {
setShowReconnecting(false)
return
@@ -1211,7 +1167,7 @@ function App(props: {
<StartupLoading ready={ready} />
</Show>
<Show when={showReconnecting()}>
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
<Reconnecting attempt={client.connection.attempt()} error={client.connection.error()} />
</Show>
</box>
)
+2 -1
View File
@@ -7,7 +7,8 @@ import {
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { tint, useTheme } from "../context/theme"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
@@ -8,7 +8,7 @@ import type {
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useSDK } from "../context/sdk"
import { useClient } from "../context/client"
import { useTheme } from "../context/theme"
import { useBindings } from "../keymap"
import { useDialog } from "../ui/dialog"
@@ -102,7 +102,7 @@ function manageConnections(
) {
dialog.replace(() => {
const data = useData()
const sdk = useSDK()
const client = useClient()
const toast = useToast()
return (
<DialogSelect
@@ -121,7 +121,7 @@ function manageConnections(
title: `Disconnect ${connection.label}`,
value: connection.id,
onSelect: () => {
void sdk.api.credential
void client.api.credential
.remove({ credentialID: connection.id, location: location(data) })
.then(() => disconnected(integration.name, data, dialog, toast))
.catch(toast.error)
@@ -172,7 +172,7 @@ function KeyMethod(props: {
}) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const client = useClient()
const toast = useToast()
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
@@ -183,7 +183,7 @@ function KeyMethod(props: {
placeholder="API key"
onConfirm={(key) => {
if (!key) return
void sdk.api.integration
void client.api.integration
.connect.key({
integrationID: props.integration.id,
location: location(data),
@@ -218,11 +218,11 @@ function OAuthStarting(props: {
}) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const client = useClient()
const toast = useToast()
onMount(() => {
void sdk.api.integration
void client.api.integration
.connect.oauth({
integrationID: props.integration.id,
location: location(data),
@@ -267,7 +267,7 @@ function OAuthAuto(props: {
}) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const client = useClient()
const toast = useToast()
const clipboard = useClipboard()
let timer: ReturnType<typeof setTimeout> | undefined
@@ -291,7 +291,7 @@ function OAuthAuto(props: {
}))
const poll = () => {
void sdk.api.integration
void client.api.integration
.attempt.status({ attemptID: props.attempt.attemptID, location: location(data) })
.then((result) => {
const status = result.data
@@ -318,7 +318,7 @@ function OAuthAuto(props: {
onCleanup(() => {
if (timer) clearTimeout(timer)
if (settled) return
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
@@ -340,7 +340,7 @@ function OAuthCode(props: {
}) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const client = useClient()
const toast = useToast()
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
@@ -348,7 +348,7 @@ function OAuthCode(props: {
onCleanup(() => {
if (settled) return
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
@@ -357,7 +357,7 @@ function OAuthCode(props: {
placeholder="Authorization code"
onConfirm={(code) => {
if (!code) return
void sdk.api.integration
void client.api.integration
.attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
.then(() => {
settled = true
@@ -4,7 +4,7 @@ import { createMemo, createResource, createSignal, onMount, Show } from "solid-j
import path from "path"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useClient } from "../context/client"
import { useTheme } from "../context/theme"
import { useData } from "../context/data"
import { abbreviateHome } from "../runtime"
@@ -35,7 +35,7 @@ type DialogMoveSessionProps = {
export function DialogMoveSession(props: DialogMoveSessionProps) {
const dialog = useDialog()
const sdk = useSDK()
const client = useClient()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const sessionData = useData()
@@ -63,7 +63,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const [loadedProject] = createResource(
() => (projectContext.project() === undefined ? props.projectID : undefined),
(projectID) =>
sdk.api.project
client.api.project
.current({ location: { directory: projectContext.instance.directory() || paths.cwd } })
.then((project) => (project.id === projectID ? project.directory : undefined))
.catch(() => undefined),
@@ -78,11 +78,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
try {
const location = { directory: projectContext.instance.directory() || paths.cwd }
await sdk.api.projectCopy.refresh({
await client.api.projectCopy.refresh({
projectID,
location,
})
const directories = await sdk.api.project.directories({
const directories = await client.api.project.directories({
projectID,
location,
})
@@ -232,7 +232,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
setToDelete(undefined)
setRemoving(selected.directory)
setWorking(true)
const error = await sdk.api.projectCopy
const error = await client.api.projectCopy
.remove({
projectID: props.projectID,
location: { directory: projectContext.instance.directory() || paths.cwd },
@@ -247,7 +247,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
setRemoving(undefined)
setWorking(false)
if (isRecord(error) && isRecord(error.data) && error.data.forceRequired === true) {
const status = await sdk.client.vcs.status({ directory: selected.directory }).catch(() => undefined)
const status = await client.api.vcs
.status({ location: { directory: selected.directory } })
.catch(() => undefined)
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
title: "Delete working copy?",
message: "This working copy has file changes. Do you want to delete it anyway?",
@@ -257,7 +259,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return
}
reopen(selected.directory)
const forcedError = await sdk.api.projectCopy
const forcedError = await client.api.projectCopy
.remove({
projectID: props.projectID,
location: { directory: projectContext.instance.directory() || paths.cwd },
+3 -3
View File
@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { renderUnicodeCompact } from "uqr"
import { useSDK } from "../context/sdk"
import { useClient } from "../context/client"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { errorMessage } from "../util/error"
@@ -13,7 +13,7 @@ export type DialogPairCredentials = {
}
export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const sdk = useSDK()
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
@@ -25,7 +25,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
dialog.setCentered(true)
const [server] = createResource(() =>
sdk.api.server
client.api.server
.get()
.catch((error) => {
setLoadError(error)
@@ -8,7 +8,7 @@ import { useData } from "../context/data"
import { Locale } from "../util/locale"
import { useProject } from "../context/project"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { useClient } from "../context/client"
import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
@@ -23,7 +23,7 @@ export function DialogSessionList() {
const data = useData()
const project = useProject()
const { theme } = useTheme()
const sdk = useSDK()
const client = useClient()
const local = useLocal()
const toast = useToast()
const [search, setSearch] = createDebouncedSignal("", 150)
@@ -36,7 +36,7 @@ export function DialogSessionList() {
if (!query) return
const location = data.location.default()
try {
const response = await sdk.api.session.list({
const response = await client.api.session.list({
search: query,
limit: 50,
order: "desc",
@@ -140,7 +140,7 @@ export function DialogSessionList() {
setToDelete(option.value)
return
}
void sdk.api.session.remove({ sessionID: option.value }).catch((error) => {
void client.api.session.remove({ sessionID: option.value }).catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
@@ -1,12 +1,12 @@
import { DialogPrompt } from "../ui/dialog-prompt"
import { type DialogContext, useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useClient } from "../context/client"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
export function DialogSessionRename(props: { sessionID: string; currentTitle?: string }) {
const dialog = useDialog()
const sdk = useSDK()
const client = useClient()
const toast = useToast()
return (
@@ -17,7 +17,7 @@ export function DialogSessionRename(props: { sessionID: string; currentTitle?: s
onConfirm={(value) => {
const title = value.trim()
if (!title) return
void sdk.api.session
void client.api.session
.rename({ sessionID: props.sessionID, title })
.then(() => dialog.clear())
.catch((error) =>
+3 -3
View File
@@ -2,11 +2,11 @@ import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useProject } from "../context/project"
import { useSDK } from "../context/sdk"
import { useClient } from "../context/client"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const client = useClient()
const dialog = useDialog()
const project = useProject()
@@ -17,7 +17,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
const [files] = createResource(
() => [store.filter],
async () => {
const result = await sdk.api.file
const result = await client.api.file
.find({
query: store.filter,
type: "file",
+2 -1
View File
@@ -1,6 +1,7 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { tint, useTheme } from "../context/theme"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { logo } from "../logo"
export function Logo() {
@@ -7,7 +7,7 @@ import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Sh
import { createStore } from "solid-js/store"
import { useEditorContext } from "../../context/editor"
import { useProject } from "../../context/project"
import { useSDK } from "../../context/sdk"
import { useClient } from "../../context/client"
import { useData } from "../../context/data"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime"
@@ -84,7 +84,7 @@ export function Autocomplete(props: {
promptPartTypeId: () => number
}) {
const editor = useEditorContext()
const sdk = useSDK()
const client = useClient()
const data = useData()
const project = useProject()
const slashes = useCommandSlashes()
@@ -315,7 +315,7 @@ export function Autocomplete(props: {
if (referenceMatch()) return []
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
const result = await sdk.api.file
const result = await client.api.file
.find({
query: baseQuery,
limit: 20,
+15 -14
View File
@@ -15,12 +15,13 @@ import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { Flag } from "@opencode-ai/core/flag/flag"
import { tint, useTheme } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { tint } from "../../theme/color"
import { EmptyBorder, SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { useClipboard } from "../../context/clipboard"
import { Spinner } from "../spinner"
import { useSDK } from "../../context/sdk"
import { useClient } from "../../context/client"
import { useRoute } from "../../context/route"
import { useProject } from "../../context/project"
import { useEvent } from "../../context/event"
@@ -147,7 +148,7 @@ export function Prompt(props: PromptProps) {
const paths = useTuiPaths()
const terminalEnvironment = useTuiTerminalEnvironment()
const clipboard = useClipboard()
const sdk = useSDK()
const client = useClient()
const editor = useEditorContext()
const route = useRoute()
const project = useProject()
@@ -420,7 +421,7 @@ export function Prompt(props: PromptProps) {
}, 5000)
if (store.interrupt >= 2) {
void sdk.api.session.interrupt({
void client.api.session.interrupt({
sessionID: props.sessionID,
})
setStore("interrupt", 0)
@@ -439,7 +440,7 @@ export function Prompt(props: PromptProps) {
if (!input.focused) return
if (!props.sessionID) return
void sdk.api.session.background({
void client.api.session.background({
sessionID: props.sessionID,
})
dialog.clear()
@@ -964,7 +965,7 @@ export function Prompt(props: PromptProps) {
finishMoveProgress = Boolean(move.progress())
const location = data.location.default()
const created = await sdk.api.session
const created = await client.api.session
.create({
location: directory ? { directory } : location,
agent: agent.id,
@@ -1008,7 +1009,7 @@ export function Prompt(props: PromptProps) {
if (store.mode === "shell") {
move.startSubmit()
void sdk.api.session.shell({
void client.api.session.shell({
sessionID,
command: inputText,
})
@@ -1027,7 +1028,7 @@ export function Prompt(props: PromptProps) {
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
void sdk.api.session
void client.api.session
.command({
sessionID,
command: command.slice(1),
@@ -1047,7 +1048,7 @@ export function Prompt(props: PromptProps) {
)
) {
move.startSubmit()
void sdk.api.session.skill({
void client.api.session.skill({
sessionID,
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
})
@@ -1058,20 +1059,20 @@ export function Prompt(props: PromptProps) {
session = data.session.get(sessionID)
}
if (session?.agent !== agent.id) {
await sdk.api.session.switchAgent({ sessionID, agent: agent.id })
await client.api.session.switchAgent({ sessionID, agent: agent.id })
}
if (
session?.model?.providerID !== selectedModel.providerID ||
session.model.id !== selectedModel.modelID ||
session.model.variant !== variant
) {
await sdk.api.session.switchModel({
await client.api.session.switchModel({
sessionID,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
})
}
if (session?.revert) {
const error = await sdk.api.session.revert.commit({ sessionID }).then(
const error = await client.api.session.revert.commit({ sessionID }).then(
() => undefined,
(error) => error,
)
@@ -1082,7 +1083,7 @@ export function Prompt(props: PromptProps) {
}
if (pendingEditorSelection) {
// Keep editor context hidden while admitting it before the corresponding user prompt.
const error = await sdk.api.session
const error = await client.api.session
.synthetic({
sessionID,
text: formatEditorContext(pendingEditorSelection),
@@ -1097,7 +1098,7 @@ export function Prompt(props: PromptProps) {
return false
}
}
const error = await sdk.api.session
const error = await client.api.session
.prompt({
sessionID,
text: inputText,
+10 -8
View File
@@ -3,7 +3,7 @@ import path from "path"
import { useTuiPaths } from "../../context/runtime"
import { errorMessage } from "../../util/error"
import { useDialog } from "../../ui/dialog"
import { useSDK } from "../../context/sdk"
import { useClient } from "../../context/client"
import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
@@ -17,7 +17,7 @@ function moveReminderText(directory: string) {
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
const sdk = useSDK()
const client = useClient()
const toast = useToast()
const homeDestination = useHomeSessionDestination()
const project = useProject()
@@ -33,7 +33,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
setCreating(true)
setProgress("Creating copy")
try {
const result = await sdk.api.projectCopy.create({
const result = await client.api.projectCopy.create({
projectID,
location: { directory: project.instance.directory() || paths.cwd },
strategy: "git_worktree",
@@ -44,7 +44,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
if (!directory) throw new Error("No project copy directory returned")
// Call a location-based route to make sure it's bootstrapped before moving on.
await sdk.api.location.get({ location: { directory } })
await client.api.location.get({ location: { directory } })
setProgress("Creating session")
return directory
@@ -98,7 +98,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
const session = await resolveSession(sessionID)
const status = await sdk.client.vcs.status({ directory: session?.location.directory }).catch(() => undefined)
const status = await client.api.vcs
.status({ location: session?.location.directory ? { directory: session.location.directory } : undefined })
.catch(() => undefined)
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
if (!choice) return
dialog.clear()
@@ -110,8 +112,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
setProgress("Moving session")
try {
await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
await sdk.api.session
await client.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
await client.api.session
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
.catch(() => undefined)
dialog.clear()
@@ -129,7 +131,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
if (projectID) return projectID
const sessionID = input.sessionID()
if (sessionID) return (await resolveSession(sessionID))?.projectID
return sdk.api.project
return client.api.project
.current({ location: { directory: project.instance.directory() || paths.cwd } })
.then((project) => project.id)
.catch(() => undefined)
+4 -1
View File
@@ -14,7 +14,10 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
const config = useConfig().data
const color = () => props.color ?? theme.textMuted
return (
<Show when={config.animations ?? true} fallback={<text fg={color()}> {props.children}</text>}>
<Show
when={config.animations ?? true}
fallback={<text fg={color()}>{props.children ? <> {props.children}</> : "⋯"}</text>}
>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
@@ -1,13 +1,12 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting"
export type SDKConnectionEvent = {
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
export type ClientConnectionEvent = {
readonly type: "client.connection"
readonly created: number
readonly data: {
@@ -17,27 +16,25 @@ export type SDKConnectionEvent = {
}
}
type SDKEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
const connectTimeout = 2_000
const connectionHistoryLimit = 50
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client",
init: (props: {
client: OpencodeClient
api: OpenCodeClient
reconnect?: (attempt: number) => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
reconnect?: (attempt: number) => Promise<{ api: OpenCodeClient }>
// Stops and starts the managed service; present only in service mode.
reload?: () => Promise<void>
}) => {
const log = useLog({ component: "sdk" })
const log = useLog({ component: "client" })
const abort = new AbortController()
const history: SDKConnectionEvent[] = []
let client = props.client
const history: ClientConnectionEvent[] = []
let api = props.api
const events = createGlobalEmitter<SDKEventMap>()
const events = createGlobalEmitter<ClientEventMap>()
const [connection, setConnection] = createStore<{
status: SDKConnectionStatus
status: ClientConnectionStatus
attempt: number
error?: string
}>({
@@ -46,7 +43,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
})
let stream: AbortController | undefined
function record(status: SDKConnectionEvent["data"]["status"], attempt: number, error?: string) {
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
if (history.length > connectionHistoryLimit) history.shift()
}
@@ -121,7 +118,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const next = await props.reconnect(attempt).catch(() => undefined)
if (abort.signal.aborted || controller.signal.aborted) return
if (next) {
client = next.client
api = next.api
}
}
@@ -144,9 +140,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
})
return {
get client() {
return client
},
get api() {
return api
},
+28 -28
View File
@@ -30,7 +30,7 @@ import type {
import type { Data } from "@opencode-ai/plugin/v2/tui/context"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { useClient } from "./client"
import { createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
@@ -110,7 +110,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
location: {},
})
const sdk = useSDK()
const client = useClient()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: process.cwd(),
})
@@ -328,7 +328,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
time: { created: event.created },
})
})
void sdk.api.session
void client.api.session
.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
.then((item) => {
message.update(event.data.sessionID, (draft, index) => {
@@ -851,8 +851,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
const result = {
on: sdk.event.on,
listen: sdk.event.listen,
on: client.event.on,
listen: client.event.listen,
session: {
list() {
return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated)
@@ -899,7 +899,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.pending[sessionID] ?? []
},
async refresh(sessionID: string) {
const pending = await sdk.api.session.pending.list({ sessionID })
const pending = await client.api.session.pending.list({ sessionID })
setStore("session", "pending", sessionID, reconcile(pending))
setStore(
"session",
@@ -916,7 +916,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
},
async refresh(sessionID: string) {
setStore("session", "info", sessionID, await sdk.api.session.get({ sessionID }))
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
registerSession(sessionID)
},
message: {
@@ -929,7 +929,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return position === undefined ? undefined : messages?.[position]
},
async refresh(sessionID: string) {
const messages = (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed()
const messages = (await client.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed()
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
},
@@ -939,7 +939,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.permission[sessionID]
},
async refresh(sessionID: string) {
setStore("session", "permission", sessionID, await sdk.api.permission.list({ sessionID }))
setStore("session", "permission", sessionID, await client.api.permission.list({ sessionID }))
},
},
form: {
@@ -952,7 +952,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
async refresh(sessionID: string, ref?: LocationRef) {
if (sessionID === "global") {
const response = await sdk.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) })
const response = await client.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) })
const location = {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
@@ -966,7 +966,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
])
return
}
setStore("session", "form", sessionID, await sdk.api.form.list({ sessionID }))
setStore("session", "form", sessionID, await client.api.form.list({ sessionID }))
},
},
},
@@ -976,7 +976,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.project.permission[projectID]
},
async refresh(projectID: string) {
setStore("project", "permission", projectID, await sdk.api.permission.saved.list({ projectID }))
setStore("project", "permission", projectID, await client.api.permission.saved.list({ projectID }))
},
},
},
@@ -990,7 +990,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.find((shell) => shell !== undefined)
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.shell.list({ location: locationQuery(ref) })
const result = await client.api.shell.list({ location: locationQuery(ref) })
const key = locationKey(result.location)
setStore("location", key, {
...store.location[key],
@@ -1003,7 +1003,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return defaultLocation()
},
async refresh(ref?: LocationRef) {
const location = await sdk.api.location.get({ location: locationQuery(ref ?? defaultLocation()) })
const location = await client.api.location.get({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(location)
if (!store.location[key]) setStore("location", key, {})
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
@@ -1013,7 +1013,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.agent
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) })
const result = await client.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], agent: result.data })
},
@@ -1023,7 +1023,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.command
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.command.list({ location: locationQuery(ref ?? defaultLocation()) })
const result = await client.api.command.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], command: result.data })
},
@@ -1033,7 +1033,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.integration
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) })
const result = await client.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], integration: result.data })
},
@@ -1044,7 +1044,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.mcp.list({ location: locationQuery(ref) })
const result = await client.api.mcp.list({ location: locationQuery(ref) })
const key = locationKey(result.location)
setStore("location", key, {
...store.location[key],
@@ -1057,7 +1057,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.mcp.resource.catalog({ location: locationQuery(ref) })
const result = await client.api.mcp.resource.catalog({ location: locationQuery(ref) })
const key = locationKey(result.location)
setStore("location", key, {
...store.location[key],
@@ -1071,7 +1071,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.model
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.model.list({ location: locationQuery(ref ?? defaultLocation()) })
const result = await client.api.model.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], model: result.data })
},
@@ -1081,7 +1081,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.provider
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) })
const result = await client.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], provider: result.data })
},
@@ -1091,7 +1091,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.reference
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) })
const result = await client.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], reference: result.data })
},
@@ -1101,7 +1101,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.skill
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) })
const result = await client.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], skill: result.data })
},
@@ -1113,7 +1113,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() {
if (bootstrapping) return bootstrapping
bootstrapping = Promise.allSettled([
sdk.api.session
client.api.session
.list({
limit: 50,
order: "desc",
@@ -1130,7 +1130,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
for (const session of response.data) registerSession(session.id)
}),
sdk.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
client.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
const permissions = response.data.reduce<Record<string, PermissionV2Request[]>>(
(result, request) => ({
...result,
@@ -1140,7 +1140,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
setStore("session", "permission", reconcile(permissions))
}),
sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
client.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
const location = {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
@@ -1193,7 +1193,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
function refreshActive() {
void sdk.api.session
void client.api.session
.active()
.then((active) => {
setStore(
@@ -1206,7 +1206,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
onCleanup(
sdk.event.listen(({ details }) => {
client.event.listen(({ details }) => {
if (details.type === "server.connected") {
const messages = connected ? Object.keys(store.session.message) : []
const compactions = connected ? Object.keys(store.session.compaction) : []
+4 -4
View File
@@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client"
import { useSDK } from "./sdk"
import { useClient } from "./client"
type EventMetadata = {
directory: string | undefined
@@ -7,10 +7,10 @@ type EventMetadata = {
}
export function useEvent() {
const sdk = useSDK()
const client = useClient()
function subscribe(handler: (event: OpenCodeEvent, metadata: EventMetadata) => void) {
return sdk.event.listen(({ details }) => {
return client.event.listen(({ details }) => {
if (details.type === "server.connected") return
handler(details, { directory: details.location?.directory, workspace: details.location?.workspaceID })
})
@@ -20,7 +20,7 @@ export function useEvent() {
type: T,
handler: (event: Extract<OpenCodeEvent, { type: T }>, metadata: EventMetadata) => void,
) {
return sdk.event.on(type, (event) => {
return client.event.on(type, (event) => {
handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID })
})
}
+2 -19
View File
@@ -5,7 +5,7 @@ import { useEvent } from "./event"
import path from "path"
import { useTuiPaths } from "./runtime"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { useClient } from "./client"
import { RGBA } from "@opentui/core"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTheme } from "./theme"
@@ -52,7 +52,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
const data = useData()
const sdk = useSDK()
const client = useClient()
const toast = useToast()
const theme = useTheme().theme
const route = useRoute()
@@ -493,22 +493,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const session = createSession()
const mcp = {
isEnabled(name: string) {
return data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status === "connected"
},
async toggle(name: string) {
const status = data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status
if (status === "connected") {
// Disable: disconnect the MCP
await sdk.client.mcp.disconnect({ name })
} else {
// Enable/Retry: connect the MCP (handles disabled, failed, and other states)
await sdk.client.mcp.connect({ name })
}
},
}
createEffect(() => {
const value = agent.current()
if (!value?.model) return
@@ -523,7 +507,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const result = {
model,
agent,
mcp,
session,
permission,
}
+4 -4
View File
@@ -1,12 +1,12 @@
import { batch } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { useClient } from "./client"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project",
init: () => {
const sdk = useSDK()
const client = useClient()
const defaultPath = {
home: "",
@@ -33,8 +33,8 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
async function sync() {
const workspace = store.workspace.current
const location = { workspace }
const current = await sdk.api.location.get({ location })
const directories = await sdk.api.project.directories({ projectID: current.project.id, location })
const current = await client.api.location.get({ location })
const directories = await client.api.project.directories({ projectID: current.project.id, location })
batch(() => {
setStore(
"instance",
+1 -6
View File
@@ -5,7 +5,6 @@ import {
addTheme,
allThemes,
generateSyntax,
generateSystem,
hasTheme,
isTheme,
resolveTheme,
@@ -13,11 +12,10 @@ import {
setCustomThemes,
setSystemTheme,
subscribeThemes,
terminalMode,
tint,
upsertTheme,
type ThemeJson,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
@@ -63,13 +61,10 @@ export {
addTheme,
allThemes,
generateSyntax,
generateSystem,
hasTheme,
isTheme,
resolveTheme,
selectedForeground,
terminalMode,
tint,
upsertTheme,
type Theme,
type ThemeJson,
@@ -3,7 +3,6 @@ import type { PluginRuntime } from "../plugin/runtime"
import HomeFooter from "./home/footer"
import HomeTips from "./home/tips"
import SidebarContext from "./sidebar/context"
import SidebarFiles from "./sidebar/files"
import SidebarFooter from "./sidebar/footer"
import SidebarLsp from "./sidebar/lsp"
import SidebarMcp from "./sidebar/mcp"
@@ -26,7 +25,6 @@ export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
SidebarContext,
SidebarMcp,
SidebarLsp,
SidebarFiles,
SidebarFooter,
Notifications,
PluginManager,
@@ -1,72 +0,0 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, For, Show, createSignal } from "solid-js"
import { FilePath } from "../../ui/file-path"
const id = "internal:sidebar-files"
function changeCountWidth(item: { additions: number; deletions: number }) {
return [item.additions ? `+${item.additions}` : "", item.deletions ? `-${item.deletions}` : ""]
.filter(Boolean)
.join(" ").length
}
function View(props: { api: TuiPluginApi; session_id: string }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.session.diff(props.session_id))
return (
<Show when={list().length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<b>Modified Files</b>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1} justifyContent="space-between">
<FilePath
value={item.file}
maxWidth={Math.max(2, 36 - changeCountWidth(item))}
fg={theme().textMuted}
/>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme().diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme().diffRemoved}>-{item.deletions}</text>
</Show>
</box>
</box>
)}
</For>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 500,
slots: {
sidebar_content(_ctx, props) {
return <View api={api} session_id={props.session_id} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { Locale } from "../../util/locale"
import { tint } from "../../context/theme"
import { tint } from "../../theme/color"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
import type { FileDiffInfo, FileDiffLegacyInfo } from "@opencode-ai/client"
import type { FileDiffInfo } from "@opencode-ai/client"
import {
TextAttributes,
type BorderSides,
@@ -11,7 +11,7 @@ import {
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
import { useBindings, useCommandShortcut } from "../../keymap"
import { useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { useClient } from "../../context/client"
import { useTerminalDimensions } from "@opentui/solid"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
@@ -42,7 +42,7 @@ const MIN_SPLIT_WIDTH = 100
const FILE_TREE_WIDTH = 32
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
const VCS_DIFF_CONTEXT_LINES = 12
type DiffMode = "working" | "branch" | "last-turn"
type DiffMode = "working" | "branch"
type DiffViewerFocus = "patches" | "files"
type DiffView = "split" | "unified"
type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number }
@@ -55,20 +55,14 @@ type DiffFile = {
readonly status: "added" | "deleted" | "modified"
}
const normalizeDiffs = (diffs: readonly (FileDiffInfo | FileDiffLegacyInfo)[]): DiffFile[] =>
diffs.flatMap((item) =>
item.file
? [
{
file: item.file,
patch: item.patch,
additions: item.additions,
deletions: item.deletions,
status: item.status ?? "modified",
} satisfies DiffFile,
]
: [],
)
const normalizeDiffs = (diffs: readonly FileDiffInfo[]): DiffFile[] =>
diffs.map((item) => ({
file: item.file,
patch: item.patch,
additions: item.additions,
deletions: item.deletions,
status: item.status,
}))
function filetype(input?: string) {
if (!input) return "none"
@@ -82,14 +76,13 @@ function storedView(value: unknown): DiffView | undefined {
}
function diffSourceLabel(mode: DiffMode) {
if (mode === "last-turn") return "last turn"
if (mode === "branch") return "main branch"
return "working tree"
}
function DiffViewer(props: { api: TuiPluginApi }) {
const dimensions = useTerminalDimensions()
const sdk = useSDK()
const client = useClient()
const config = useConfig()
const themeState = useTheme()
const theme = () => props.api.theme.current
@@ -98,7 +91,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
| {
mode?: DiffMode
sessionID?: string
messageID?: string
returnRoute?: TuiRouteCurrent
}
| undefined
@@ -108,22 +100,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
return {
mode: mode(),
sessionID,
messageID: params()?.messageID,
directory: sessionID ? props.api.state.session.get(sessionID)?.directory : undefined,
}
})
const [diff] = createResource(diffInput, async (input) => {
if (input.mode === "last-turn") {
const sessionID = input.sessionID
if (!sessionID) return []
const result = await props.api.client.session.diff(
{ sessionID, messageID: input.messageID },
{ throwOnError: true },
)
return normalizeDiffs(result.data ?? [])
}
const result = await sdk.api.vcs.diff(
const result = await client.api.vcs.diff(
{
location: input.directory ? { directory: input.directory } : undefined,
mode: input.mode,
@@ -704,26 +685,16 @@ function DiffViewer(props: { api: TuiPluginApi }) {
]
const switchDiffOptions = createMemo(() => {
const vcs = props.api.state.vcs
return [
{
title: "Working tree",
value: "working" as const,
description: "Show current git changes",
},
...(vcs?.branch && vcs.default_branch && vcs.branch !== vcs.default_branch
? [
{
title: "Main branch",
value: "branch" as const,
description: "Show changes compared to main branch",
},
]
: []),
{
title: "Last turn",
value: "last-turn" as const,
description: "Show changes from the last assistant turn",
title: "Main branch",
value: "branch" as const,
description: "Show changes compared to main branch",
},
]
})
@@ -742,7 +713,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
props.api.route.navigate(ROUTE, {
mode: option.value,
sessionID: params()?.sessionID,
messageID: params()?.messageID,
returnRoute: params()?.returnRoute,
})
},
@@ -1011,7 +981,7 @@ function DiffViewerHelpDialog() {
{
shortcut: useCommandShortcut("diff.switch_source"),
action: "Switch source",
description: "Choose working tree, main branch, or last-turn changes",
description: "Choose working tree or main branch changes",
},
{
shortcut: useCommandShortcut("diff.toggle_view"),
+12 -5
View File
@@ -2,7 +2,7 @@ import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencod
import type { Config } from "../config"
import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route"
import type { useSDK } from "../context/sdk"
import type { useClient } from "../context/client"
import type { useData } from "../context/data"
import type { useProject } from "../context/project"
import type { useTheme } from "../context/theme"
@@ -28,7 +28,7 @@ type Input = {
route: ReturnType<typeof useRoute>
routes: PluginRoutes
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK>
client: ReturnType<typeof useClient>
project: ReturnType<typeof useProject>
data: ReturnType<typeof useData>
theme: ReturnType<typeof useTheme>
@@ -167,6 +167,15 @@ function appApi(version: string): TuiPluginApi["app"] {
}
}
const unsupportedClient = new Proxy(
{},
{
get() {
throw new Error("The legacy plugin client is not supported in V2")
},
},
) as TuiPluginApi["client"]
export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycle"> {
return {
app: appApi(input.version),
@@ -292,9 +301,7 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
ready: true,
},
state: stateApi(input.project, input.data),
get client() {
return input.sdk.client
},
client: unsupportedClient,
event: input.event,
renderer: input.renderer,
slots: {
@@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { useData } from "../../../context/data"
import { useLocation } from "../../../context/location"
import { useSDK } from "../../../context/sdk"
import { useClient } from "../../../context/client"
import { useTheme, selectedForeground } from "../../../context/theme"
import { useBindings, useCommandShortcut } from "../../../keymap"
import { useComposerTab } from "./index"
@@ -11,7 +11,7 @@ import { useComposerTab } from "./index"
export function ShellTab(props: { sessionID: string }) {
const data = useData()
const location = useLocation()
const sdk = useSDK()
const client = useClient()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const composer = useComposerTab()
@@ -84,7 +84,7 @@ export function ShellTab(props: { sessionID: string }) {
const entry = selectedEntry()
if (!entry) return
const ref = location()
void sdk.api.shell.remove({
void client.api.shell.remove({
id: entry.id,
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
})
@@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { useRoute, useRouteData } from "../../../context/route"
import { useData } from "../../../context/data"
import { useSDK } from "../../../context/sdk"
import { useClient } from "../../../context/client"
import { useTheme, selectedForeground } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { useBindings, useCommandShortcut } from "../../../keymap"
@@ -20,7 +20,7 @@ interface SubagentEntry {
export function SubagentsTab(props: { sessionID: string }) {
const route = useRouteData("session")
const data = useData()
const sdk = useSDK()
const client = useClient()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const navigate = useRoute().navigate
@@ -183,7 +183,7 @@ export function SubagentsTab(props: { sessionID: string }) {
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return
void sdk.api.session.interrupt({ sessionID: entry.sessionID })
void client.api.session.interrupt({ sessionID: entry.sessionID })
},
},
],
@@ -2,7 +2,7 @@ import { createMemo, createSignal, onMount, Show } from "solid-js"
import { unwrap } from "solid-js/store"
import { useData } from "../../context/data"
import { useRoute } from "../../context/route"
import { useSDK } from "../../context/sdk"
import { useClient } from "../../context/client"
import { Spinner } from "../../component/spinner"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
@@ -13,14 +13,14 @@ import { Locale } from "../../util/locale"
export function DialogFork(props: { sessionID: string; messageID?: string; onMove?: (messageID?: string) => void }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const client = useClient()
const route = useRoute()
const toast = useToast()
const [pending, setPending] = createSignal(false)
const fork = async (messageID?: string) => {
setPending(true)
const result = await sdk.api.session.fork({ sessionID: props.sessionID, messageID }).catch((error) => {
const result = await client.api.session.fork({ sessionID: props.sessionID, messageID }).catch((error) => {
toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
return undefined
})
@@ -3,7 +3,7 @@ import { useData } from "../../context/data"
import { DialogSelect } from "../../ui/dialog-select"
import { useClipboard } from "../../context/clipboard"
import { useToast } from "../../ui/toast"
import { useSDK } from "../../context/sdk"
import { useClient } from "../../context/client"
import { errorMessage } from "../../util/error"
import { DialogFork } from "./dialog-fork"
import type { PromptInfo } from "../../prompt/history"
@@ -16,7 +16,7 @@ export function DialogMessage(props: {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
const sdk = useSDK()
const client = useClient()
const message = createMemo(() => data.session.message.get(props.sessionID, props.messageID))
return (
@@ -45,7 +45,7 @@ export function DialogMessage(props: {
pasted: [],
})
}
void sdk.api.session.revert
void client.api.session.revert
.stage({ sessionID: props.sessionID, messageID: props.messageID })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
dialog.clear()
+8 -7
View File
@@ -3,10 +3,11 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import open from "open"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import { selectedForeground, useTheme } from "../../context/theme"
import { tint } from "../../theme/color"
import type { FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useSDK } from "../../context/sdk"
import { useClient } from "../../context/client"
import { useClipboard } from "../../context/clipboard"
import { SplitBorder } from "../../ui/border"
import { useToast } from "../../ui/toast"
@@ -145,7 +146,7 @@ function requestOptions(form: FormWithLocation) {
}
export function FormPrompt(props: { form: FormWithLocation }) {
const sdk = useSDK()
const client = useClient()
const { theme } = useTheme()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
@@ -296,7 +297,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
function replySingle(field: Field, value: FormValue) {
sdk.api.form
client.api.form
.reply(
{
sessionID: props.form.sessionID,
@@ -478,7 +479,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
function cancel() {
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
}
function openExternal() {
@@ -530,7 +531,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
return
}
sdk.api.form
client.api.form
.reply(
{
sessionID: props.form.sessionID,
@@ -581,7 +582,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
group: "Form",
cmd: () => {
if (textual()) {
void sdk.api.form.cancel(
void client.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id },
requestOptions(props.form),
)
+78 -30
View File
@@ -41,7 +41,7 @@ import { Locale } from "../../util/locale"
import { FilePath } from "../../ui/file-path"
import { webSearchProviderLabel } from "../../util/tool-display"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "../../context/sdk"
import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
@@ -215,7 +215,7 @@ export function Session() {
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const toast = useToast()
const sdk = useSDK()
const client = useClient()
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
@@ -389,7 +389,7 @@ export function Session() {
aliases: ["summarize"],
},
run: () => {
void sdk.api.session.compact({ sessionID: route.sessionID })
void client.api.session.compact({ sessionID: route.sessionID })
dialog.clear()
},
},
@@ -417,7 +417,7 @@ export function Session() {
dialog.clear()
return
}
void sdk.api.session.revert
void client.api.session.revert
.stage({ sessionID: route.sessionID, messageID: message.id })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
prompt?.set({
@@ -445,7 +445,7 @@ export function Session() {
slash: { name: "redo" },
run: () => {
void (async () => {
const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then(
const error = await client.api.session.revert.clear({ sessionID: route.sessionID }).then(
() => undefined,
(error) => error,
)
@@ -722,11 +722,11 @@ export function Session() {
: await (async () => {
if (options.debug) {
const events: { readonly created: number }[] = []
for await (const event of sdk.api.session.log({ sessionID: sessionData.id, follow: false })) {
for await (const event of client.api.session.log({ sessionID: sessionData.id, follow: false })) {
if (event.type !== "log.synced") events.push(event)
}
// Durable events stay in aggregate order even when their wall-clock timestamps differ.
sdk.connection.internal.history().forEach((event) => {
client.connection.internal.history().forEach((event) => {
const index = events.findIndex((item) => item.created > event.created)
if (index === -1) {
events.push(event)
@@ -740,7 +740,7 @@ export function Session() {
const messages: unknown[] = []
let cursor: string | undefined
do {
const page = await sdk.api.message.list(
const page = await client.api.message.list(
cursor
? { sessionID: sessionData.id, limit: 200, cursor }
: { sessionID: sessionData.id, limit: 200, order: "asc" },
@@ -776,7 +776,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
void sdk.api.session.background({ sessionID: route.sessionID })
void client.api.session.background({ sessionID: route.sessionID })
dialog.clear()
},
},
@@ -1380,7 +1380,7 @@ function RevertMessage(props: {
const ctx = use()
const { theme } = useTheme()
const route = useRouteData("session")
const sdk = useSDK()
const client = useClient()
const toast = useToast()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
@@ -1392,7 +1392,7 @@ function RevertMessage(props: {
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
void (async () => {
const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then(
const error = await client.api.session.revert.clear({ sessionID: route.sessionID }).then(
() => undefined,
(error) => error,
)
@@ -1978,6 +1978,7 @@ function InlineTool(props: {
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
part: SessionMessageAssistantTool
onClick?: () => void
@@ -2028,6 +2029,7 @@ function InlineTool(props: {
pending={props.pending}
failure={props.failure}
spinner={props.spinner}
status={props.status}
onMouseOver={() => clickable() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@@ -2057,6 +2059,7 @@ export function InlineToolRow(props: {
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
@@ -2066,7 +2069,16 @@ export function InlineToolRow(props: {
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
<Switch>
<Match when={props.spinner}>
<Spinner color={props.color} children={props.children} />
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
{(status) => (
<box flexDirection="row" gap={1}>
<Spinner color={props.color} />
<InlineToolLabel color={props.color} status={status()}>
{props.children}
</InlineToolLabel>
</box>
)}
</Show>
</Match>
<Match when={true}>
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
@@ -2078,13 +2090,28 @@ export function InlineToolRow(props: {
>
{props.icon}
</text>
<text
flexGrow={1}
fg={props.failed ? props.errorColor : props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
<Show
when={props.status}
fallback={
<text
flexGrow={1}
fg={props.failed ? props.errorColor : props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</text>
}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</text>
{(status) => (
<InlineToolLabel
color={props.failed ? props.errorColor : props.color}
denied={props.denied}
status={status()}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</InlineToolLabel>
)}
</Show>
</box>
</Show>
</Match>
@@ -2098,6 +2125,32 @@ export function InlineToolRow(props: {
)
}
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
return (
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
<text
maxWidth="100%"
flexShrink={0}
fg={props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.children}
</text>
{props.status}
</box>
)
}
function StatusBadge(props: { children: string }) {
const { theme } = useTheme()
return (
<text flexShrink={0} bg={theme.backgroundElement} fg={theme.textMuted}>
{" "}
{props.children}{" "}
</text>
)
}
function BlockTool(props: {
title?: string
path?: { label: string; value: string }
@@ -2241,9 +2294,7 @@ function Shell(props: ToolProps) {
</Show>
</Show>
<Show when={shellID()}>
<text>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Background </span>
</text>
<StatusBadge>Background</StatusBadge>
</Show>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
@@ -2386,20 +2437,17 @@ function Subagent(props: ToolProps) {
const id = sessionID()
if (id) navigate({ type: "session", sessionID: id })
}}
status={
props.input.background === true || props.metadata.status === "running" ? (
<StatusBadge>Background</StatusBadge>
) : undefined
}
>
{formatSubagentTitle(
Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Subagent",
props.input.background === true || props.metadata.status === "running",
)}
{`${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
</InlineTool>
)
}
export function formatSubagentTitle(agent: string, description: string, background: boolean) {
return `${agent} Subagent — ${description}${background ? " [background]" : ""}`
}
export function formatSubagentRetry(attempt: number, message: string) {
return `Retrying (attempt ${attempt}) · ${message}`
}
@@ -5,7 +5,7 @@ import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/s
import type { TextareaRenderable } from "@opentui/core"
import { useTheme, selectedForeground } from "../../context/theme"
import type { PermissionV2Request } from "@opencode-ai/client"
import { useSDK } from "../../context/sdk"
import { useClient } from "../../context/client"
import { SplitBorder } from "../../ui/border"
import { useData } from "../../context/data"
import { filetype } from "../../util/filetype"
@@ -135,7 +135,7 @@ function TextBody(props: { title: string; description?: string; icon?: string })
}
export function PermissionPrompt(props: { request: PermissionV2Request; directory?: string }) {
const sdk = useSDK()
const client = useClient()
const data = useData()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
@@ -187,7 +187,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
void sdk.api.permission.reply({
void client.api.permission.reply({
sessionID: props.request.sessionID,
reply: "always",
requestID: props.request.id,
@@ -198,7 +198,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
<Match when={store.stage === "reject"}>
<RejectPrompt
onConfirm={(message) => {
void sdk.api.permission.reply({
void client.api.permission.reply({
sessionID: props.request.sessionID,
reply: "reject",
requestID: props.request.id,
@@ -444,14 +444,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
setStore("stage", "reject")
return
}
void sdk.api.permission.reply({
void client.api.permission.reply({
sessionID: props.request.sessionID,
reply: "reject",
requestID: props.request.id,
})
return
}
void sdk.api.permission.reply({
void client.api.permission.reply({
sessionID: props.request.sessionID,
reply: "once",
requestID: props.request.id,
+48
View File
@@ -0,0 +1,48 @@
import { RGBA } from "@opentui/core"
export function ansiToRgba(code: number): RGBA {
if (code < 16) {
const ansiColors = [
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
]
return RGBA.fromHex(ansiColors[code] ?? "#000000")
}
if (code < 232) {
const index = code - 16
const b = index % 6
const g = Math.floor(index / 6) % 6
const r = Math.floor(index / 36)
const val = (x: number) => (x === 0 ? 0 : x * 40 + 55)
return RGBA.fromInts(val(r), val(g), val(b))
}
if (code < 256) {
const gray = (code - 232) * 10 + 8
return RGBA.fromInts(gray, gray, gray)
}
return RGBA.fromInts(0, 0, 0)
}
export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
+2 -256
View File
@@ -1,4 +1,5 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import { SyntaxStyle, RGBA } from "@opentui/core"
import { ansiToRgba } from "./color"
import aura from "./assets/aura.json" with { type: "json" }
import ayu from "./assets/ayu.json" with { type: "json" }
import carbonfox from "./assets/carbonfox.json" with { type: "json" }
@@ -297,261 +298,6 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
} as Theme
}
function ansiToRgba(code: number): RGBA {
// Standard ANSI colors (0-15)
if (code < 16) {
const ansiColors = [
"#000000", // Black
"#800000", // Red
"#008000", // Green
"#808000", // Yellow
"#000080", // Blue
"#800080", // Magenta
"#008080", // Cyan
"#c0c0c0", // White
"#808080", // Bright Black
"#ff0000", // Bright Red
"#00ff00", // Bright Green
"#ffff00", // Bright Yellow
"#0000ff", // Bright Blue
"#ff00ff", // Bright Magenta
"#00ffff", // Bright Cyan
"#ffffff", // Bright White
]
return RGBA.fromHex(ansiColors[code] ?? "#000000")
}
// 6x6x6 Color Cube (16-231)
if (code < 232) {
const index = code - 16
const b = index % 6
const g = Math.floor(index / 6) % 6
const r = Math.floor(index / 36)
const val = (x: number) => (x === 0 ? 0 : x * 40 + 55)
return RGBA.fromInts(val(r), val(g), val(b))
}
// Grayscale Ramp (232-255)
if (code < 256) {
const gray = (code - 232) * 10 + 8
return RGBA.fromInts(gray, gray, gray)
}
// Fallback for invalid codes
return RGBA.fromInts(0, 0, 0)
}
export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
export function terminalMode(colors: TerminalColors): "dark" | "light" | undefined {
const bg = colors.defaultBackground
if (!bg) return
const { r, g, b } = RGBA.fromHex(bg)
return 0.299 * r + 0.587 * g + 0.114 * b > 0.5 ? "light" : "dark"
}
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
const isDark = mode == "dark"
const col = (i: number) => {
const value = colors.palette[i]
if (value) return RGBA.fromHex(value)
return ansiToRgba(i)
}
// Generate gray scale based on terminal background
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)
// ANSI color references
const ansiColors = {
black: col(0),
red: col(1),
green: col(2),
yellow: col(3),
blue: col(4),
magenta: col(5),
cyan: col(6),
white: col(7),
redBright: col(9),
greenBright: col(10),
}
const diffAlpha = isDark ? 0.22 : 0.14
const diffAddedBg = tint(bg, ansiColors.green, diffAlpha)
const diffRemovedBg = tint(bg, ansiColors.red, diffAlpha)
const diffContextBg = grays[2]
const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha)
const diffLineNumber = textMuted
return {
theme: {
// Primary colors using ANSI
primary: ansiColors.cyan,
secondary: ansiColors.magenta,
accent: ansiColors.cyan,
// Status colors using ANSI
error: ansiColors.red,
warning: ansiColors.yellow,
success: ansiColors.green,
info: ansiColors.cyan,
// Text colors
text: fg,
textMuted,
selectedListItemText: bg,
// Background colors - use transparent to respect terminal transparency
background: transparent,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
// Border colors
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
// Diff colors
diffAdded: ansiColors.green,
diffRemoved: ansiColors.red,
diffContext: grays[7],
diffHunkHeader: grays[7],
diffHighlightAdded: ansiColors.greenBright,
diffHighlightRemoved: ansiColors.redBright,
diffAddedBg,
diffRemovedBg,
diffContextBg,
diffLineNumber,
diffAddedLineNumberBg,
diffRemovedLineNumberBg,
// Markdown colors
markdownText: fg,
markdownHeading: fg,
markdownLink: ansiColors.blue,
markdownLinkText: ansiColors.cyan,
markdownCode: ansiColors.green,
markdownBlockQuote: ansiColors.yellow,
markdownEmph: ansiColors.yellow,
markdownStrong: fg,
markdownHorizontalRule: grays[7],
markdownListItem: ansiColors.blue,
markdownListEnumeration: ansiColors.cyan,
markdownImage: ansiColors.blue,
markdownImageText: ansiColors.cyan,
markdownCodeBlock: fg,
// Syntax colors
syntaxComment: textMuted,
syntaxKeyword: ansiColors.magenta,
syntaxFunction: ansiColors.blue,
syntaxVariable: fg,
syntaxString: ansiColors.green,
syntaxNumber: ansiColors.yellow,
syntaxType: ansiColors.cyan,
syntaxOperator: ansiColors.cyan,
syntaxPunctuation: fg,
},
}
}
function generateGrayScale(bg: RGBA, isDark: boolean): Record<number, RGBA> {
const grays: Record<number, RGBA> = {}
// RGBA stores floats in range 0-1, convert to 0-255
const bgR = bg.r * 255
const bgG = bg.g * 255
const bgB = bg.b * 255
const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
for (let i = 1; i <= 12; i++) {
const factor = i / 12.0
let grayValue: number
let newR: number
let newG: number
let newB: number
if (isDark) {
if (luminance < 10) {
grayValue = Math.floor(factor * 0.4 * 255)
newR = grayValue
newG = grayValue
newB = grayValue
} else {
const newLum = luminance + (255 - luminance) * factor * 0.4
const ratio = newLum / luminance
newR = Math.min(bgR * ratio, 255)
newG = Math.min(bgG * ratio, 255)
newB = Math.min(bgB * ratio, 255)
}
} else {
if (luminance > 245) {
grayValue = Math.floor(255 - factor * 0.4 * 255)
newR = grayValue
newG = grayValue
newB = grayValue
} else {
const newLum = luminance * (1 - factor * 0.4)
const ratio = newLum / luminance
newR = Math.max(bgR * ratio, 0)
newG = Math.max(bgG * ratio, 0)
newB = Math.max(bgB * ratio, 0)
}
}
grays[i] = RGBA.fromInts(Math.floor(newR), Math.floor(newG), Math.floor(newB))
}
return grays
}
function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
// RGBA stores floats in range 0-1, convert to 0-255
const bgR = bg.r * 255
const bgG = bg.g * 255
const bgB = bg.b * 255
const bgLum = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
let grayValue: number
if (isDark) {
if (bgLum < 10) {
// Very dark/black background
grayValue = 180 // #b4b4b4
} else {
// Scale up for lighter dark backgrounds
grayValue = Math.min(Math.floor(160 + bgLum * 0.3), 200)
}
} else {
if (bgLum > 245) {
// Very light/white background
grayValue = 75 // #4b4b4b
} else {
// Scale down for darker light backgrounds
grayValue = Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60)
}
}
return RGBA.fromInts(grayValue, grayValue, grayValue)
}
export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
+144
View File
@@ -0,0 +1,144 @@
import { RGBA, type TerminalColors } from "@opentui/core"
import { ansiToRgba, tint } from "./color"
export function terminalMode(colors: TerminalColors): "dark" | "light" | undefined {
const bg = colors.defaultBackground
if (!bg) return
const { r, g, b } = RGBA.fromHex(bg)
return 0.299 * r + 0.587 * g + 0.114 * b > 0.5 ? "light" : "dark"
}
export function generateSystem(colors: TerminalColors, mode: "dark" | "light") {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
const isDark = mode === "dark"
const col = (index: number) => {
const value = colors.palette[index]
if (value) return RGBA.fromHex(value)
return ansiToRgba(index)
}
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)
const ansiColors = {
red: col(1),
green: col(2),
yellow: col(3),
blue: col(4),
magenta: col(5),
cyan: col(6),
redBright: col(9),
greenBright: col(10),
}
const diffAlpha = isDark ? 0.22 : 0.14
const diffAddedBg = tint(bg, ansiColors.green, diffAlpha)
const diffRemovedBg = tint(bg, ansiColors.red, diffAlpha)
const diffContextBg = grays[2]
const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha)
return {
theme: {
primary: ansiColors.cyan,
secondary: ansiColors.magenta,
accent: ansiColors.cyan,
error: ansiColors.red,
warning: ansiColors.yellow,
success: ansiColors.green,
info: ansiColors.cyan,
text: fg,
textMuted,
selectedListItemText: bg,
background: transparent,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
diffAdded: ansiColors.green,
diffRemoved: ansiColors.red,
diffContext: grays[7],
diffHunkHeader: grays[7],
diffHighlightAdded: ansiColors.greenBright,
diffHighlightRemoved: ansiColors.redBright,
diffAddedBg,
diffRemovedBg,
diffContextBg,
diffLineNumber: textMuted,
diffAddedLineNumberBg,
diffRemovedLineNumberBg,
markdownText: fg,
markdownHeading: fg,
markdownLink: ansiColors.blue,
markdownLinkText: ansiColors.cyan,
markdownCode: ansiColors.green,
markdownBlockQuote: ansiColors.yellow,
markdownEmph: ansiColors.yellow,
markdownStrong: fg,
markdownHorizontalRule: grays[7],
markdownListItem: ansiColors.blue,
markdownListEnumeration: ansiColors.cyan,
markdownImage: ansiColors.blue,
markdownImageText: ansiColors.cyan,
markdownCodeBlock: fg,
syntaxComment: textMuted,
syntaxKeyword: ansiColors.magenta,
syntaxFunction: ansiColors.blue,
syntaxVariable: fg,
syntaxString: ansiColors.green,
syntaxNumber: ansiColors.yellow,
syntaxType: ansiColors.cyan,
syntaxOperator: ansiColors.cyan,
syntaxPunctuation: fg,
},
}
}
function generateGrayScale(bg: RGBA, isDark: boolean): Record<number, RGBA> {
const grays: Record<number, RGBA> = {}
const bgR = bg.r * 255
const bgG = bg.g * 255
const bgB = bg.b * 255
const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
for (let i = 1; i <= 12; i++) {
const factor = i / 12
if (isDark && luminance < 10) {
const gray = Math.floor(factor * 0.4 * 255)
grays[i] = RGBA.fromInts(gray, gray, gray)
continue
}
if (!isDark && luminance > 245) {
const gray = Math.floor(255 - factor * 0.4 * 255)
grays[i] = RGBA.fromInts(gray, gray, gray)
continue
}
const next = isDark ? luminance + (255 - luminance) * factor * 0.4 : luminance * (1 - factor * 0.4)
const ratio = next / luminance
grays[i] = RGBA.fromInts(
Math.floor(Math.min(Math.max(bgR * ratio, 0), 255)),
Math.floor(Math.min(Math.max(bgG * ratio, 0), 255)),
Math.floor(Math.min(Math.max(bgB * ratio, 0), 255)),
)
}
return grays
}
function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
const luminance = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
if (isDark) {
const gray = luminance < 10 ? 180 : Math.min(Math.floor(160 + luminance * 0.3), 200)
return RGBA.fromInts(gray, gray, gray)
}
const gray = luminance > 245 ? 75 : Math.max(Math.floor(100 - (255 - luminance) * 0.2), 60)
return RGBA.fromInts(gray, gray, gray)
}
+38 -36
View File
@@ -130,42 +130,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable
const actions = createMemo(() => props.actions ?? [])
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
const actionBindings = useKeymapSelector((keymap) =>
keymap.getCommandBindings({
visibility: "registered",
commands: shownActions().map((item) => item.command),
}),
)
const actionLabels = createMemo(() => {
const labels = new Map<string, string>()
for (const action of shownActions()) {
const label = formatKeyBindings(actionBindings().get(action.command), config)
if (label) labels.set(action.command, label)
}
return labels
})
const visibleActions = createMemo(() => [
...shownActions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => item.label),
...(props.footerHints ?? []),
])
const actionItems = createMemo(() =>
visibleActions()
.filter(isActionItem)
.filter((item) => !isActionDisabled(item)),
)
createEffect(() => {
const command = focusedAction()
if (command && !actionItems().some((item) => item.command === command)) setFocusedAction(undefined)
})
const filtered = createMemo(() => {
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
@@ -229,6 +193,44 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const selected = createMemo(() => flat()[store.selected])
// Action availability depends on the selected option, so initialize the
// option graph before registering action bindings that may run immediately.
const actions = createMemo(() => props.actions ?? [])
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
const actionBindings = useKeymapSelector((keymap) =>
keymap.getCommandBindings({
visibility: "registered",
commands: shownActions().map((item) => item.command),
}),
)
const actionLabels = createMemo(() => {
const labels = new Map<string, string>()
for (const action of shownActions()) {
const label = formatKeyBindings(actionBindings().get(action.command), config)
if (label) labels.set(action.command, label)
}
return labels
})
const visibleActions = createMemo(() => [
...shownActions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => item.label),
...(props.footerHints ?? []),
])
const actionItems = createMemo(() =>
visibleActions()
.filter(isActionItem)
.filter((item) => !isActionDisabled(item)),
)
createEffect(() => {
const command = focusedAction()
if (command && !actionItems().some((item) => item.command === command)) setFocusedAction(undefined)
})
createEffect(
on(
() => props.options,
+1 -1
View File
@@ -4,7 +4,7 @@ import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -1,14 +1,19 @@
import { describe, expect, test } from "bun:test"
import Notifications from "../../../../src/feature-plugins/system/notifications"
import type { OpenCodeEvent, PermissionAsked, QuestionAsked } from "@opencode-ai/client"
import type { Session } from "@opencode-ai/sdk/v2"
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
import type { TuiAttentionNotifyInput, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
type Session = NonNullable<ReturnType<TuiPluginApi["state"]["session"]["get"]>>
async function setup() {
const notifications: TuiAttentionNotifyInput[] = []
const handlers = new Map<OpenCodeEvent["type"], ((event: OpenCodeEvent) => void)[]>()
const session = (id: string, title: string, parentID?: string): Session => ({
const session = (
id: string,
title: string,
parentID?: string,
): Session => ({
id,
title,
slug: id,
+86 -86
View File
@@ -6,10 +6,10 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { EventV2 } from "@opencode-ai/core/event"
import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider, useSDK } from "../../../src/context/sdk"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [
@@ -90,13 +90,13 @@ test("refreshes resources into reactive getters", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -148,13 +148,13 @@ test("applies absolute usage events to session info", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -233,13 +233,13 @@ test("truncates committed revert messages without changing lifetime usage", asyn
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -371,13 +371,13 @@ test("updates session location when moved", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -430,13 +430,13 @@ test("restores running manual compaction before applying live deltas", async ()
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -514,23 +514,23 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
})
}, events)
let data!: ReturnType<typeof useData>
let sdk!: ReturnType<typeof useSDK>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
sdk = useSDK()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -539,15 +539,15 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
await wait(() => data.session.status("session-stale") === "running")
await data.session.message.refresh("session-stale")
expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale")
expect(sdk.connection.status()).toBe("connected")
expect(sdk.connection.attempt()).toBe(0)
expect(client.connection.status()).toBe("connected")
expect(client.connection.attempt()).toBe(0)
events.disconnect()
await wait(() => sdk.connection.status() === "reconnecting")
expect(sdk.connection.attempt()).toBe(1)
expect(sdk.connection.error()).toBe("Event stream disconnected")
await wait(() => client.connection.status() === "reconnecting")
expect(client.connection.attempt()).toBe(1)
expect(client.connection.error()).toBe("Event stream disconnected")
await wait(() => requests.active === 2 && sdk.connection.status() === "connected", 4000)
await wait(() => requests.active === 2 && client.connection.status() === "connected", 4000)
resolveActive(json({ data: { "session-new": { type: "running" } } }))
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
@@ -565,9 +565,9 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
await wait(() => data.session.status("session-new") === "running")
expect(requests.event).toBe(2)
expect(requests.message).toBe(2)
expect(sdk.connection.status()).toBe("connected")
expect(sdk.connection.attempt()).toBe(0)
expect(sdk.connection.error()).toBeUndefined()
expect(client.connection.status()).toBe("connected")
expect(client.connection.attempt()).toBe(0)
expect(client.connection.error()).toBeUndefined()
} finally {
app.renderer.destroy()
}
@@ -588,13 +588,13 @@ test("completes exploration when a queued prompt is promoted", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -668,13 +668,13 @@ test("removes committed revert messages from local state", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -733,34 +733,34 @@ test("distinguishes initial connection from reconnection", async () => {
const calls = createFetch((url) => {
if (url.pathname === "/api/event") return eventResponse()
})
let sdk!: ReturnType<typeof useSDK>
let client!: ReturnType<typeof useClient>
function Probe() {
sdk = useSDK()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => stream !== undefined)
expect(sdk.connection.status()).toBe("connecting")
expect(client.connection.status()).toBe("connecting")
connect()
await wait(() => sdk.connection.status() === "connected")
await wait(() => client.connection.status() === "connected")
disconnect()
await wait(() => sdk.connection.status() === "reconnecting")
await wait(() => client.connection.status() === "reconnecting")
} finally {
app.renderer.destroy()
}
@@ -811,13 +811,13 @@ test("tracks session status from active sessions and execution events", async ()
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -1180,13 +1180,13 @@ test("restores queued compaction from durable pending input", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -1295,13 +1295,13 @@ test("refreshes integrations after integration updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -1351,13 +1351,13 @@ test("refreshes MCP resources after catalog updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -1400,13 +1400,13 @@ test("refreshes effective catalog data after catalog updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<box />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -1448,13 +1448,13 @@ test("refreshes agents after agent updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -1492,13 +1492,13 @@ test("refreshes references after updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -1547,13 +1547,13 @@ test("keeps shell state scoped to location", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -1593,28 +1593,28 @@ test("adds and dismisses permission requests from live events", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
let data!: ReturnType<typeof useData>
let sdk!: ReturnType<typeof useSDK>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
sdk = useSDK()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => sdk.connection.status() === "connected")
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_permission_asked_1",
created: 0,
@@ -1681,13 +1681,13 @@ test("reconciles all pending permission requests when the event stream reconnect
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(fetch.fetch)} api={createApi(fetch.fetch)}>
<ClientProvider api={createApi(fetch.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -1715,28 +1715,28 @@ test("adds, dismisses, and refreshes form requests", async () => {
})
}, events)
let data!: ReturnType<typeof useData>
let sdk!: ReturnType<typeof useSDK>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
sdk = useSDK()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => sdk.connection.status() === "connected")
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_form_created_1",
created: 0,
@@ -1785,28 +1785,28 @@ test("tracks global forms by location", async () => {
const calls = createFetch(undefined, events)
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
let data!: ReturnType<typeof useData>
let sdk!: ReturnType<typeof useSDK>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
sdk = useSDK()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => sdk.connection.status() === "connected")
await wait(() => client.connection.status() === "connected")
events.emit({
id: "evt_form_created_global_other",
created: 0,
@@ -1871,28 +1871,28 @@ test("refreshes global forms for the requested location", async () => {
})
}, events)
let data!: ReturnType<typeof useData>
let sdk!: ReturnType<typeof useSDK>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
sdk = useSDK()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => sdk.connection.status() === "connected" && requests.length > 0)
await wait(() => client.connection.status() === "connected" && requests.length > 0)
requests.length = 0
await data.session.form.refresh("global", { directory })
@@ -1957,13 +1957,13 @@ test("refreshes global forms once per loaded location after reconnect", async ()
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -2025,13 +2025,13 @@ test("reconciles all pending form requests when the event stream reconnects", as
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(fetch.fetch)} api={createApi(fetch.fetch)}>
<ClientProvider api={createApi(fetch.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -2078,13 +2078,13 @@ test("settles pending tools when a live failure arrives", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -2225,13 +2225,13 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -2318,13 +2318,13 @@ test("projects live instruction updates with their message ID", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
@@ -2387,13 +2387,13 @@ async function mountData(parents: Record<string, string>, costs: Record<string,
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
await mounted
@@ -126,6 +126,7 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
<DialogSelect
title="Mutable options"
options={options()}
current={initial[0]?.value}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
actions={[
+5 -34
View File
@@ -4,17 +4,16 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import type { Session } from "@opencode-ai/sdk/v2"
import { ThemeProvider } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { SDKProvider } from "../../../src/context/sdk"
import { ClientProvider } from "../../../src/context/client"
import { TuiKeybind } from "../../../src/config/keybind"
import { OpencodeKeymapProvider } from "../../../src/keymap"
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createApi, createClient, createEventStream, createFetch, json } from "../../fixture/tui-sdk"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
@@ -111,7 +110,6 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
let current = initialRoute ?? startRoute
let renderDiff: TuiRouteDefinition["render"] | undefined
let vcsDiffInput: unknown
let sessionDiffInput: unknown
const config = createTuiResolvedConfig()
const transport = createFetch((url) => {
if (url.pathname !== "/api/vcs/diff") return
@@ -135,14 +133,6 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
}
const base = createTuiPluginApi({
keymap,
client: {
session: {
diff: async (input: unknown) => {
sessionDiffInput = input
return { data: [] }
},
},
} as unknown as TuiPluginApi["client"],
state: {
session: {
get: () => session,
@@ -170,7 +160,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
return (
<TestTuiContexts>
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
<ClientProvider api={createApi(transport.fetch)}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark">
@@ -178,7 +168,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
)
}
@@ -190,7 +180,6 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
commands,
current: () => current,
vcsDiffInput: () => vcsDiffInput,
sessionDiffInput: () => sessionDiffInput,
}
}
@@ -217,7 +206,7 @@ const session = {
created: 0,
updated: 0,
},
} satisfies Session
} satisfies NonNullable<ReturnType<TuiPluginApi["state"]["session"]["get"]>>
test("branch diff source requests branch VCS diff", async () => {
const viewer = await renderDiffViewer([], 20, {
@@ -234,24 +223,6 @@ test("branch diff source requests branch VCS diff", async () => {
mode: "branch",
context: "12",
})
expect(viewer.sessionDiffInput()).toBeUndefined()
} finally {
viewer.app.renderer.destroy()
}
})
test("last-turn diff source requests session diff", async () => {
const viewer = await renderDiffViewer([], 20, {
name: "diff",
params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
})
try {
expect(viewer.current()).toEqual({
name: "diff",
params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
})
expect(viewer.sessionDiffInput()).toEqual({ sessionID: "session-1", messageID: "message-1" })
expect(viewer.vcsDiffInput()).toBeUndefined()
} finally {
viewer.app.renderer.destroy()
}
+4 -4
View File
@@ -7,7 +7,7 @@ import path from "node:path"
import { onCleanup } from "solid-js"
import { ClipboardProvider } from "../../../src/context/clipboard"
import type { FormWithLocation } from "../../../src/context/data"
import { SDKProvider } from "../../../src/context/sdk"
import { ClientProvider } from "../../../src/context/client"
import { ThemeProvider } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
@@ -15,7 +15,7 @@ import { ToastProvider } from "../../../src/ui/toast"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80) {
const state = path.join(root, "state")
@@ -75,13 +75,13 @@ async function mountForm(root: string, width = 80) {
>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
<ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<FormPrompt form={form} />
</ToastProvider>
</ThemeProvider>
</SDKProvider>
</ClientProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
@@ -3,7 +3,6 @@ import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid"
import {
formatSubagentRetry,
formatSubagentTitle,
InlineToolRow,
parseApplyPatchFiles,
parseDiagnostics,
@@ -99,7 +98,16 @@ function ReminderAlignmentFixture() {
)
}
function TrailingStatusFixture() {
return (
<InlineToolRow icon=":" complete={true} pending="" status={<text flexShrink={0}> Background </text>}>
Explore Subagent Inspect renderer status styling
</InlineToolRow>
)
}
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
testSetup?.renderer.destroy()
testSetup = await testRender(component, options)
await testSetup.renderOnce()
await testSetup.renderOnce()
@@ -142,6 +150,15 @@ describe("TUI inline tool wrapping", () => {
)
})
test("wraps a trailing status as one padded item", async () => {
expect(await renderFrame(() => <TrailingStatusFixture />, { width: 70, height: 2 })).toBe(
" : Explore Subagent — Inspect renderer status styling Background",
)
expect(await renderFrame(() => <TrailingStatusFixture />, { width: 62, height: 2 })).toBe(
" : Explore Subagent — Inspect renderer status styling\n Background",
)
})
test("filters malformed nested tool wire data", () => {
expect(
parseApplyPatchFiles([
@@ -180,13 +197,6 @@ describe("TUI inline tool wrapping", () => {
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
})
test("keeps background state attached to the subagent identity", () => {
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer")
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
"Explore Subagent — Inspect renderer [background]",
)
})
test("keeps retry status ahead of wrapping messages", () => {
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
})
@@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test"
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
// Enter, or the input's native onSubmit racing another dispatch) each
// passed the `if (!store.prompt.text) return false` guard, each
// `await sdk.client.session.create(...)`, and each only captured
// `await client.api.session.create(...)`, and each only captured
// `inputText = store.prompt.text` AFTER that await. The first invocation
// finished, sent the prompt, and cleared the store; the second invocation,
// now past its await, read the cleared store and sent an empty prompt to a
+23 -25
View File
@@ -2,12 +2,11 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/context/project"
import { SDKProvider, useSDK } from "../../../src/context/sdk"
import { ClientProvider, useClient } from "../../../src/context/client"
import { useEvent } from "../../../src/context/event"
import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import type { LogLevel, LogSink } from "../../../src/context/log"
@@ -54,7 +53,7 @@ function update(version: string): OpenCodeEvent {
}
async function mount(
reconnect?: (attempt: number) => Promise<{ client: OpencodeClient; api: OpenCodeClient }>,
reconnect?: (attempt: number) => Promise<{ api: OpenCodeClient }>,
log?: LogSink,
) {
const events = createEventStream()
@@ -62,7 +61,7 @@ async function mount(
const seen: OpenCodeEvent[] = []
const workspaces: Array<string | undefined> = []
let project!: ReturnType<typeof useProject>
let sdk!: ReturnType<typeof useSDK>
let client!: ReturnType<typeof useClient>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
@@ -70,12 +69,12 @@ async function mount(
const app = await testRender(() => (
<TestTuiContexts log={log}>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} reconnect={reconnect}>
<ClientProvider api={createApi(calls.fetch)} reconnect={reconnect}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {
project = ctx.project
sdk = ctx.sdk
client = ctx.client
await project.sync()
done()
}}
@@ -83,21 +82,21 @@ async function mount(
workspaces={workspaces}
/>
</ProjectProvider>
</SDKProvider>
</ClientProvider>
</TestTuiContexts>
))
await ready
return { app, events, emit: events.emit, project, sdk, seen, workspaces }
return { app, events, emit: events.emit, project, client, seen, workspaces }
}
function Probe(props: {
seen: OpenCodeEvent[]
workspaces: Array<string | undefined>
onReady: (ctx: { project: ReturnType<typeof useProject>; sdk: ReturnType<typeof useSDK> }) => void
onReady: (ctx: { project: ReturnType<typeof useProject>; client: ReturnType<typeof useClient> }) => void
}) {
const project = useProject()
const sdk = useSDK()
const client = useClient()
const event = useEvent()
onMount(() => {
@@ -105,7 +104,7 @@ function Probe(props: {
props.seen.push(evt)
props.workspaces.push(workspace)
})
props.onReady({ project, sdk })
props.onReady({ project, client })
})
return <box />
@@ -137,7 +136,7 @@ describe("useEvent", () => {
{
level: "debug",
message: "event",
tags: { component: "sdk", type: "session.renamed", aggregateID: "ses_test", seq: 1 },
tags: { component: "client", type: "session.renamed", aggregateID: "ses_test", seq: 1 },
},
])
} finally {
@@ -194,25 +193,24 @@ describe("useEvent", () => {
const attempts: number[] = []
const replacementEvents = createEventStream()
const replacementCalls = createFetch(undefined, replacementEvents)
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
const { app, events, sdk, seen } = await mount(async (attempt) => {
const replacement = { api: createApi(replacementCalls.fetch) }
const { app, events, client, seen } = await mount(async (attempt) => {
attempts.push(attempt)
return replacement
})
try {
await wait(() => sdk.connection.status() === "connected")
await wait(() => client.connection.status() === "connected")
// Reconnection only runs when the stream is down, never while connected.
expect(attempts).toEqual([])
events.disconnect()
await wait(() => sdk.connection.status() === "connected" && attempts.length > 0)
await wait(() => client.connection.status() === "connected" && attempts.length > 0)
replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" }))
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "rediscovered"))
expect(sdk.client).toBe(replacement.client)
expect(sdk.api).toBe(replacement.api)
expect(client.api).toBe(replacement.api)
expect(attempts).toEqual([1])
const history = sdk.connection.internal.history()
const history = client.connection.internal.history()
expect(history.map((event) => [event.data.status, event.data.attempt])).toEqual([
["connecting", 0],
["connected", 0],
@@ -228,22 +226,22 @@ describe("useEvent", () => {
test("keeps the current client when reconnection fails", async () => {
let calls = 0
const { app, events, sdk, seen } = await mount(async () => {
const { app, events, client, seen } = await mount(async () => {
calls += 1
throw new Error("no server")
})
try {
await wait(() => sdk.connection.status() === "connected")
const original = sdk.client
await wait(() => client.connection.status() === "connected")
const original = client.api
events.disconnect()
// Reconnection rejects; the loop retries against the last known transport,
// which succeeds once the fixture accepts the reconnect.
await wait(() => calls > 0 && sdk.connection.status() === "connected")
await wait(() => calls > 0 && client.connection.status() === "connected")
events.emit(event(vcs("recovered"), { directory: "/tmp/recovered" }))
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "recovered"))
expect(sdk.client).toBe(original)
expect(client.api).toBe(original)
} finally {
app.renderer.destroy()
}
@@ -1,5 +1,4 @@
import { OpenCode, type OpenCodeEvent } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/tui`
@@ -135,10 +134,6 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return { fetch, session }
}
export function createClient(fetch: typeof globalThis.fetch) {
return createOpencodeClient({ baseUrl: "http://test", fetch })
}
export function createApi(fetch: typeof globalThis.fetch) {
return OpenCode.make({ baseUrl: "http://test", fetch })
}
+2 -1
View File
@@ -2,8 +2,9 @@ import { expect, test } from "bun:test"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import type { TerminalColors } from "@opentui/core"
import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme, terminalMode } from "../src/theme"
import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme } from "../src/theme"
import { discoverThemes } from "../src/context/theme"
import { terminalMode } from "../src/theme/system"
import { tmpdir } from "./fixture/fixture"
test("addTheme writes into module theme store", () => {