Compare commits

..

21 Commits

Author SHA1 Message Date
Kit Langton 25a4f833cf fix(simulation): align vertical box drawing 2026-07-15 11:21:59 -04:00
Kit Langton 04fdf59db8 feat(simulation): expose normalized terminal frames 2026-07-15 11:09:53 -04:00
Dax Raad 83cfafc884 fix(tui): mount toast at app root 2026-07-15 01:24:18 -04:00
Dax Raad 09cd7445f1 tui: remove "Plugins reloaded" toast to reduce UI noise during plugin updates 2026-07-15 01:14:40 -04:00
Dax Raad 39f1336621 refactor(tui): remove home screen tips 2026-07-15 00:55:11 -04:00
Dax Raad ce636daf04 feat(core): include session ID in instructions 2026-07-15 00:47:10 -04:00
Dax Raad c1d2d7aba3 feat(session): support directory moves from slash commands 2026-07-15 00:33:59 -04:00
Dax Raad e8964ce672 refactor(tui): remove hidden command metadata 2026-07-14 23:40:40 -04:00
Dax Raad 00093c70c1 feat(tui): show session location below prompt 2026-07-14 23:32:23 -04:00
Aiden Cline 7eb7fe0763 fix(core): expand reasoning option variants (#36894) 2026-07-14 22:12:02 -05:00
Dax Raad 2cb2f9d538 refactor(tui): simplify location and command contexts 2026-07-14 23:01:53 -04:00
Dax Raad 94a23b8537 refactor(tui): centralize location state 2026-07-14 23:01:41 -04:00
James Long ff3442ce34 refactor(tui): extract V1 theme definitions (#36969) 2026-07-14 22:14:34 -04:00
Aiden Cline 72cf7f12f3 fix(core): restore default model headers (#36975) 2026-07-14 20:50:32 -05:00
Aiden Cline ba1f3d3d32 perf(codemode): batch OpenAPI query parameters (#36978) 2026-07-14 20:37:36 -05:00
Dax Raad 05fdbcce04 fix(cli): rename bash tool to shell 2026-07-14 21:31:42 -04:00
Dax Raad 387bff8fd9 feat(tui): stream shell tool output 2026-07-14 21:29:59 -04:00
James Long 9a25673c37 feat(tui): add V2 theme system (#36950) 2026-07-14 20:28:45 -04:00
Aiden Cline 563f6a65de refactor(codemode): simplify tools types (#36941) 2026-07-14 18:40:43 -05:00
opencode-agent[bot] fca3bca19d test(tui): wait for connection before tool event (#36958)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-14 18:38:00 -05:00
Aiden Cline d88faeb6da fix(core): restore xAI OAuth in v2 (#36955) 2026-07-14 18:25:14 -05:00
139 changed files with 5190 additions and 2342 deletions
-6
View File
@@ -1,6 +0,0 @@
---
"@opencode-ai/client": patch
"@opencode-ai/cli": patch
---
Recover explicitly restarted background services that still own the service lock but no longer answer health checks.
+8 -16
View File
@@ -42,7 +42,6 @@ framework.
| Lifecycle shell | Implemented; the owner binds and registers before application boot |
| Failed-state latching | Implemented; deterministic boot failure stays bound and actionable |
| Recovery diagnostics | Implemented; the TUI shows status instead of transport internals |
| Explicit recovery | Implemented; restart can replace an unchanged unresponsive owner |
| Cross-platform validation | macOS runtime verified; Linux and Windows run in the unit-test matrix |
## Context
@@ -454,14 +453,13 @@ After a bounded diagnostic threshold, the client may show:
```text
The background service owns the service lock but is not responding.
Run `opencode service restart` to recover it.
```
```
Only explicit `service restart` may perform destructive recovery. It verifies
destructive recovery. It verifies that the complete registration is unchanged
before signaling, waits for graceful exit, and re-checks registration before
escalation. This deliberately accepts the narrow risk that a stale PID was
reused by an unrelated process; automatic reconnect never takes that risk.
the complete registration and process instance before signaling, waits for
graceful exit, re-checks identity before escalation, and refuses to kill a
process it cannot positively identify.
Automatic frozen-owner recovery is deferred.
## Failure Walkthroughs
@@ -506,11 +504,8 @@ Automatic frozen-owner recovery is deferred.
1. Health fails, but the process still holds the service lock.
2. Contenders fail lock acquisition and exit.
3. Clients wait and eventually show explicit recovery guidance.
4. The user presses `r`, or runs `opencode service restart`.
5. Restart rechecks the complete registration before each signal.
6. Once the process exits, normal election starts one replacement.
7. No TUI kills the owner automatically.
4. No TUI kills the owner automatically.
## TDD Verification
Implementation should proceed test-first with real subprocesses and real locks.
@@ -614,8 +609,6 @@ was the observed incident cost.
- A deleted registration heals without restarting the owner or any client.
- An unresponsive owner is not killed without an explicit recovery command.
- Raw transport defects never escape to the terminal.
when the replacement is ready.
- Raw transport defects never escape to the terminal.
## Follow-ups
@@ -624,8 +617,7 @@ was the observed incident cost.
- Durable execution recovery for provider attempts and tools.
- Shell, sub-agent, permission, question, and background-job continuity.
- Automatic recovery for a positively identified frozen owner.
explicit-restart PID-reuse tradeoff.
- Cold-boot concurrency limits and interaction-prioritized location loading.
- Cold-boot concurrency limits and interaction-prioritized location loading.
- A steward or socket-handoff architecture if zero-downtime replacement becomes
a real requirement.
a real requirement.
@@ -9,7 +9,8 @@ export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options()
const transport = yield* Service.restart(options)
yield* Service.stop(options, { targetVersion: options.version })
const transport = yield* Service.start(options)
process.stdout.write(transport.url + EOL)
}),
)
+2 -5
View File
@@ -116,14 +116,11 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
},
}),
...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
...(kv.dismissed_getting_started === undefined
? {}
: {
hints: {
...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
...(kv.dismissed_getting_started === undefined
? {}
: { onboarding: !kv.dismissed_getting_started }),
onboarding: !kv.dismissed_getting_started,
},
}),
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
+9 -9
View File
@@ -9,7 +9,7 @@
// Slash commands:
// /permission [kind] → triggers a permission request variant
// /question [kind] → triggers a question request variant
// /fmt <kind> → emits a specific tool/text type (text, reasoning, bash,
// /fmt <kind> → emits a specific tool/text type (text, reasoning, shell,
// write, edit, patch, task, question, error, mix)
//
// Demo mode also handles permission and question replies locally, completing
@@ -33,7 +33,7 @@ const KINDS = [
"table",
"text",
"reasoning",
"bash",
"shell",
"write",
"edit",
"patch",
@@ -42,7 +42,7 @@ const KINDS = [
"error",
"mix",
]
const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const
const PERMISSIONS = ["edit", "shell", "read", "task", "external", "doom"] as const
const QUESTIONS = ["multi", "single", "checklist", "custom"] as const
type PermissionKind = (typeof PERMISSIONS)[number]
@@ -436,7 +436,7 @@ function emitError(state: State, text: string): void {
}
async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
const ref = make(state, "bash", {
const ref = make(state, "shell", {
command: "git status",
workdir: process.cwd(),
description: "Show git status",
@@ -623,16 +623,16 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
const root = process.cwd()
const file = path.join(root, "src", "demo-format.ts")
if (kind === "bash") {
if (kind === "shell") {
const command = "git status --short"
const ref = make(state, "bash", {
const ref = make(state, "shell", {
command,
workdir: root,
description: "Inspect worktree changes",
})
askPermission(state, {
ref,
permission: "bash",
permission: "shell",
patterns: [command],
always: ["*"],
done: {
@@ -862,7 +862,7 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
return true
}
if (kind === "bash") {
if (kind === "shell") {
await emitBash(state, signal)
return true
}
@@ -924,7 +924,7 @@ function intro(state: State): void {
`- /question [kind] (${QUESTIONS.join(", ")})`,
`- /fmt <kind> (${KINDS.join(", ")})`,
"Examples:",
"- /permission bash",
"- /permission shell",
"- /question custom",
"- /fmt markdown",
"- /fmt table",
+1 -1
View File
@@ -218,7 +218,7 @@ function shellCommit(
kind: "tool",
source: "tool",
partID: `shell:${callID}`,
tool: "bash",
tool: "shell",
shell: { callID, command },
...next,
}
+10 -10
View File
@@ -1,6 +1,6 @@
// Per-tool display rules shared across `opencode run` output paths.
//
// Each known tool (bash, edit, write, task, etc.) has a ToolRule that controls
// Each known tool (shell, edit, write, task, etc.) has a ToolRule that controls
// five display hooks:
//
// view → visibility policy for progress/final scrollback entries and
@@ -114,7 +114,7 @@ type ToolPermissionCtx = {
type ToolName =
| "invalid"
| "bash"
| "shell"
| "write"
| "edit"
| "patch"
@@ -648,7 +648,7 @@ function scrollBashProgress(p: ToolProps): string {
return fmt(out)
}
function scrollBashFinal(p: ToolProps): string {
function scrollShellFinal(p: ToolProps): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
@@ -657,13 +657,13 @@ function scrollBashFinal(p: ToolProps): string {
const time = span(p.frame.state)
if (code === undefined) {
if (!time) {
return "bash completed"
return "shell completed"
}
return `bash completed · ${time}`
return `shell completed · ${time}`
}
return `bash completed (exit ${code})${time ? ` · ${time}` : ""}`
return `shell completed (exit ${code})${time ? ` · ${time}` : ""}`
}
function scrollReadStart(p: ToolProps): string {
@@ -976,16 +976,16 @@ const TOOL_RULES = {
start: () => "",
},
},
bash: {
shell: {
view: {
output: true,
final: false,
},
run: runBash,
run: runShell,
scroll: {
start: scrollBashStart,
progress: scrollBashProgress,
final: scrollBashFinal,
final: scrollShellFinal,
},
permission: permBash,
},
@@ -1202,7 +1202,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
}
}
function runBash(p: ToolProps): ToolInline {
function runShell(p: ToolProps): ToolInline {
return {
icon: "$",
title: p.input.command || "",
+8 -3
View File
@@ -17,7 +17,7 @@ export type Args = {
export type Resolved = {
readonly endpoint: Service.Endpoint
readonly reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
readonly reload?: (signal?: AbortSignal) => Promise<void>
readonly reload?: () => Promise<void>
}
export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
@@ -53,8 +53,13 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
Effect.runPromise(Service.start({ ...reconnectOptions, onStatus }).pipe(Effect.provide(NodeFileSystem.layer)), {
signal,
}),
reload: (signal?: AbortSignal) =>
Effect.runPromise(Service.restart(options).pipe(Effect.asVoid, Effect.provide(NodeFileSystem.layer)), { signal }),
reload: () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Service.stop(options, { targetVersion: options.version })
yield* Service.start(options)
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
} satisfies Resolved
})
+1 -2
View File
@@ -47,7 +47,6 @@ test("migrates tui and kv config into cli.json", async () => {
scrollbar_visible: true,
thinking_mode: "show",
exploration_grouping: false,
tips_hidden: true,
dismissed_getting_started: true,
animations_enabled: false,
skipped_version: "9.9.9",
@@ -75,7 +74,7 @@ test("migrates tui and kv config into cli.json", async () => {
terminal: { title: false },
prompt: { editor: false, paste: "full" },
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
hints: { tips: false, onboarding: false },
hints: { onboarding: false },
animations: false,
mouse: false,
})
+18
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "node:path"
import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini"
import { toolInlineInfo, toolView } from "../src/mini/tool"
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
@@ -18,6 +19,23 @@ async function cli(args: string[]) {
}
describe("mini command", () => {
test("renders the renamed shell tool with the shell rule", () => {
const part = {
id: "part-shell",
sessionID: "session-shell",
messageID: "message-shell",
callID: "call-shell",
tool: "shell",
state: {
status: "pending" as const,
input: { command: "pwd" },
},
} as const
expect(toolView(part.tool)).toEqual({ output: true, final: false })
expect(toolInlineInfo(part)).toMatchObject({ icon: "$", title: "pwd", mode: "block" })
})
test("uses piped stdin as the initial prompt", () => {
expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin")
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
+2 -2
View File
@@ -130,8 +130,8 @@ export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Eff
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.move"]>[0]
export type Endpoint5_9Input = {
readonly sessionID: Endpoint5_9Request["params"]["sessionID"]
readonly destination: Endpoint5_9Request["payload"]["destination"]
readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"]
readonly directory: Endpoint5_9Request["payload"]["directory"]
readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"]
}
export type Endpoint5_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.move"]>>
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
@@ -159,13 +159,13 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.move"]>[0]
type Endpoint5_9Input = {
readonly sessionID: Endpoint5_9Request["params"]["sessionID"]
readonly destination: Endpoint5_9Request["payload"]["destination"]
readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"]
readonly directory: Endpoint5_9Request["payload"]["directory"]
readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"]
}
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { destination: input["destination"], moveChanges: input["moveChanges"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint5_10Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
+23 -54
View File
@@ -87,10 +87,6 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
// version-mismatched one, and otherwise spawns small contenders until a server
// becomes discoverable. A contender is never killed merely for slow startup.
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
return yield* startInternal(options)
})
const startInternal = Effect.fnUntraced(function* (options: StartOptions, stale?: Info) {
const contenders = new Set<Contender>()
let announced = false
let reported: Status | undefined
@@ -143,10 +139,7 @@ const startInternal = Effect.fnUntraced(function* (options: StartOptions, stale?
yield* kill(service, options, options.version).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) {
// A process observed exiting cannot still own its unchanged registration.
if (stale === undefined || !same(info, stale)) lastSpawn = Date.now()
}
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error): error is Error => error !== undefined)
if (failure !== undefined) return yield* Effect.fail(failure)
@@ -206,22 +199,6 @@ export const stop = Effect.fn("service.stop")(function* (options: Options = {},
if (existing !== undefined) yield* kill(existing, options, metadata.targetVersion)
})
// Explicit recovery path: unlike stop(), restart may terminate an unchanged
// registered process that no longer answers authenticated health checks.
export const restart = Effect.fn("service.restart")(function* (options: StartOptions = {}) {
const existing = yield* registered(options.file, true)
if (existing.info === undefined) return yield* startInternal(options)
const requested = existing.service === undefined ? "unsupported" : yield* requestStop(existing.service, options.version)
if (requested === "rejected") return yield* Effect.fail(new Error("Background service rejected restart"))
const result = yield* terminate(
existing.info,
read(options.file),
requested === "unsupported" ? "signal" : "requested",
)
return yield* startInternal(options, result === "stopped" ? existing.info : undefined)
})
function fallback() {
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
return join(state, "opencode", "service.json")
@@ -240,7 +217,6 @@ export const Info = Schema.Struct({
password: Schema.optional(Schema.String),
})
export type Info = typeof Info.Type
const same = Schema.toEquivalence(Info)
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health)
@@ -271,10 +247,10 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const response = yield* Effect.tryPromise((signal) =>
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", info.url), {
headers: headers(endpoint),
signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]),
signal: AbortSignal.timeout(2_000),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined) return undefined
@@ -329,46 +305,39 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
const terminate = Effect.fnUntraced(function* (
info: Info,
current: Effect.Effect<Info | undefined, never, FileSystem.FileSystem>,
mode: "requested" | "signal",
) {
if (mode === "signal") {
const owner = yield* current
if (owner === undefined || !same(owner, info)) return "changed" as const
yield* signal(info.pid, "SIGTERM")
}
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
if (Option.isSome(done)) return "stopped" as const
const latest = yield* current
if (latest === undefined || !same(latest, info)) return "changed" as const
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll))
return "stopped" as const
})
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const kill = Effect.fnUntraced(function* (service: LocalService, options: Options, targetVersion?: string) {
const requested = yield* requestStop(service, targetVersion)
if (requested === "rejected") return "rejected" as const
return yield* terminate(
service.info,
registered(options.file, true).pipe(Effect.map((result) => result.service?.info)),
requested === "unsupported" ? "signal" : "requested",
)
if (requested === "rejected") return
if (requested === "unsupported") {
// A stale registration may point at a reused PID. Authenticate again
// immediately before the legacy signal fallback.
const current = yield* find(options)
if (current === undefined || !same(current.info, service.info)) return
yield* signal(service.info.pid, "SIGTERM")
}
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll), Effect.option)
if (Option.isSome(done)) return
const latest = yield* find(options)
if (latest === undefined || !same(latest.info, service.info)) return
yield* signal(service.info.pid, "SIGKILL")
yield* stopped(service.info.pid).pipe(Effect.retry(poll))
})
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
const requestStop = Effect.fnUntraced(function* (service: LocalService, targetVersion?: string) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = yield* Effect.tryPromise((signal) =>
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id, targetVersion }),
signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]),
signal: AbortSignal.timeout(2_000),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
@@ -522,7 +522,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
body: { destination: input["destination"], moveChanges: input["moveChanges"] },
body: { directory: input["directory"], workspaceID: input["workspaceID"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
@@ -567,7 +567,7 @@ export type SessionMoved = {
type: "session.moved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; location: LocationRef; subpath?: string }
data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionRenamed = {
@@ -2715,14 +2715,8 @@ export type SessionRenameOutput = void
export type SessionMoveInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly destination: {
readonly destination: { readonly directory: string }
readonly moveChanges?: boolean | undefined
}["destination"]
readonly moveChanges?: {
readonly destination: { readonly directory: string }
readonly moveChanges?: boolean | undefined
}["moveChanges"]
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"]
}
export type SessionMoveOutput = void
+3 -39
View File
@@ -1,4 +1,4 @@
import { appendFile, rename, rm, writeFile } from "node:fs/promises"
import { appendFile, rename, writeFile } from "node:fs/promises"
const [registration, mode, delay] = process.argv.slice(2)
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
@@ -8,35 +8,6 @@ if (mode === "record-start") {
process.exit(1)
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "unresponsive" || mode === "unresponsive-stubborn" || mode === "unresponsive-slow") {
const stalled =
mode === "unresponsive-slow"
? Bun.serve({
port: 0,
fetch: () => {
void writeFile(registration + ".health-request", "")
return new Promise<Response>(() => {})
},
})
: undefined
await writeFile(
registration,
JSON.stringify({
id: crypto.randomUUID(),
version: "test",
url: stalled?.url.toString() ?? "http://127.0.0.1:1",
pid: process.pid,
password: "secret",
}),
{ mode: 0o600 },
)
process.on("SIGTERM", async () => {
if (mode === "unresponsive-stubborn") return
await rm(registration, { force: true })
process.exit()
})
await new Promise(() => {})
}
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
await appendFile(registration + ".starts", process.pid + "\n")
@@ -51,7 +22,6 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
}
let requests = 0
let stopDropped = false
const version = mode === "old" || mode === "reject-stop" ? "old" : "test"
const id = crypto.randomUUID()
const server = Bun.serve({
@@ -62,10 +32,6 @@ const server = Bun.serve({
await writeFile(registration + ".stop-attempt", "")
return Response.json({ accepted: false })
}
if (pathname === "/api/service/stop" && mode === "drop-stop") {
stopDropped = true
return new Promise<Response>(() => {})
}
if (pathname === "/api/service/stop" && mode === "graceful") {
const body = await request.json()
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
@@ -74,7 +40,6 @@ const server = Bun.serve({
return Response.json({ accepted: true })
}
if (pathname !== "/api/health") return new Response(null, { status: 404 })
if (stopDropped) return new Promise<Response>(() => {})
requests += 1
if (mode === "modern" && requests === 1) {
await writeFile(registration + ".first-request", "")
@@ -98,7 +63,7 @@ const server = Bun.serve({
},
{ status: 503 },
)
if (mode === "starting" || mode === "graceful" || mode === "reject-stop" || mode === "drop-stop")
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
return Response.json({ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "ready" } })
return Response.json({ healthy: true, version, pid: process.pid })
},
@@ -116,9 +81,8 @@ await writeFile(
)
await rename(registration + ".tmp", registration)
async function shutdown() {
function shutdown() {
server.stop(true)
await rm(registration, { force: true })
process.exit()
}
process.on("SIGTERM", shutdown)
+2 -166
View File
@@ -11,14 +11,8 @@ const processes: Bun.Subprocess[] = []
const directories: string[] = []
afterEach(async () => {
await Promise.all(
processes.splice(0).map(async (process) => {
process.kill("SIGTERM")
const exited = await Promise.race([process.exited.then(() => true), Bun.sleep(1_000).then(() => false)])
if (!exited) process.kill("SIGKILL")
await process.exited
}),
)
processes.forEach((process) => process.kill("SIGTERM"))
await Promise.all(processes.splice(0).map((process) => process.exited))
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
@@ -95,153 +89,6 @@ test("requests graceful replacement of the exact service instance", async () =>
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id, targetVersion: "next" })
})
test("explicit restart replaces an unresponsive registered process", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "unresponsive")
await waitForFile(registration)
const endpoint = await run(
Service.restart({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "ready"],
}),
)
await existing.exited
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(info.pid).not.toBe(existing.pid)
expect(await health(endpoint.url)).toMatchObject({ healthy: true, version: "test", pid: info.pid })
} finally {
await run(Service.stop({ file: registration }))
}
}, 15_000)
test("restart waits for accepted shutdown before starting a replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "graceful")
await waitForFile(registration)
const original = await Bun.file(registration).json()
const endpoint = await run(
Service.restart({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "ready"],
}),
)
await existing.exited
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(info.pid).not.toBe(existing.pid)
expect(await Bun.file(registration + ".stop").json()).toEqual({
instanceID: original.id,
targetVersion: "test",
})
} finally {
await run(Service.stop({ file: registration }))
}
})
test("restart recovers when a healthy owner stops responding during shutdown", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "drop-stop")
await waitForFile(registration)
const endpoint = await run(
Service.restart({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "ready"],
}),
)
await existing.exited
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(info.pid).not.toBe(existing.pid)
} finally {
await run(Service.stop({ file: registration }))
}
}, 15_000)
test("restart fails when a responsive owner rejects shutdown", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "reject-stop")
await waitForFile(registration)
await expect(run(Service.restart({ file: registration, version: "test" }))).rejects.toThrow(
"Background service rejected restart",
)
expect(existing.exitCode).toBe(null)
})
test("ordinary stop never signals an unresponsive registered process", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "unresponsive")
await waitForFile(registration)
await run(Service.stop({ file: registration }))
expect(existing.exitCode).toBe(null)
})
test.skipIf(process.platform === "win32")(
"restart escalates when an unresponsive process ignores SIGTERM",
async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "unresponsive-stubborn")
await waitForFile(registration)
const endpoint = await run(
Service.restart({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "ready"],
}),
)
await existing.exited
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(existing.signalCode).toBe("SIGKILL")
} finally {
await run(Service.stop({ file: registration }))
}
},
20_000,
)
test("restart does not signal a process after registration changes", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "unresponsive-slow")
await waitForFile(registration)
const restarting = run(
Service.restart({ file: registration, version: "test", command: [] }),
)
await waitForFile(registration + ".health-request")
const replacement = spawn(registration, "ready")
await waitForRegistration(registration, replacement.pid)
const endpoint = await restarting
expect(existing.exitCode).toBe(null)
expect(endpoint.url).toBe((await Bun.file(registration).json()).url)
})
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -394,17 +241,6 @@ async function waitForFile(file: string) {
throw new Error(`Timed out waiting for ${file}`)
}
async function waitForRegistration(file: string, pid: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const info = await Bun.file(file)
.json()
.catch(() => undefined)
if (info?.pid === pid) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for registration from ${pid}`)
}
async function health(url: string) {
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
}
+5 -5
View File
@@ -15,7 +15,7 @@ runs, no sandbox required.
The deliberate differences:
- **No ambient authority.** No `fetch`, `process`, filesystem, timers, or host globals - only the allowlisted standard
library and the `tools` tree.
library and supplied `tools`.
- **No dynamic code.** No `eval`, `Function`, or module loading.
- **Plain-data boundaries.** Tool arguments and program results are JSON-like data. Dates become ISO strings, RegExp,
Map, and Set serialize as `{}`, and promises, functions, and runtime references cannot cross the boundary.
@@ -33,8 +33,8 @@ generators, and full sparse-array parity) are tracked as unchecked items in the
## Quick Start
The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect`
and should depend on `effect` themselves. Define tools with Effect Schema, then place them in the object tree exposed
to programs as `tools`:
and should depend on `effect` themselves. Define tools with Effect Schema, then expose them to programs through
`tools`:
```ts
import { CodeMode, Tool } from "@opencode-ai/codemode"
@@ -94,8 +94,8 @@ Effect-returning and must not fail.
### OpenAPI tools
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation, namespaced by dotted
`operationId`:
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into namespaced tools - one tool per operation, using dotted
`operationId` segments as namespaces:
```ts
const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
+18 -22
View File
@@ -1,7 +1,7 @@
import { Effect, Schema } from "effect"
import { executeWithLimits } from "./interpreter/execute.js"
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import type { Definition } from "./tool.js"
import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import type { Tools } from "./tools.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
@@ -28,10 +28,6 @@ export type DiscoveryOptions = {
readonly catalogBudget?: number
}
type ToolTree<R = never> = {
readonly [name: string]: Definition<R> | ToolTree<R>
}
export type ResolvedExecutionLimits = {
readonly timeoutMs: number | undefined
readonly maxToolCalls: number | undefined
@@ -39,24 +35,24 @@ export type ResolvedExecutionLimits = {
}
/** Options for one CodeMode execution. */
export type ExecuteOptions<Tools extends Record<string, unknown> = {}> = {
export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
/** Source for one program in the supported JavaScript subset. */
code: string
/** Explicit tool tree exposed to the program as `tools`. */
tools?: Tools & ToolTree<Services<Tools>>
/** Explicit tools exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Per-execution overrides for the default resource limits. */
limits?: ExecutionLimits
/** Observes decoded tool input immediately before tool execution. */
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Tools>>
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Provided>>
/** Observes each admitted tool call as it settles, with outcome and duration. */
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Tools>>
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Provided>>
}
/** A JSON value that can cross the confined interpreter boundary. */
export type DataValue = Schema.Json
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code"> & {
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code"> & {
/** Progressive-disclosure configuration for the agent-facing tool catalog. */
readonly discovery?: DiscoveryOptions
}
@@ -117,7 +113,7 @@ export const Result = Schema.Union([Success, Failure])
/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */
export type Result = typeof Result.Type
/** Reusable confined runtime over one explicit tool tree. */
/** Reusable confined runtime over explicit tools. */
export type Runtime<R = never> = {
readonly catalog: () => ReadonlyArray<ToolDescription>
readonly instructions: () => string
@@ -138,24 +134,24 @@ const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimi
})
/** Executes one Effect-native CodeMode program without constructing a reusable runtime. */
export const execute = <const Tools extends Record<string, unknown>>(
options: ExecuteOptions<Tools>,
): Effect.Effect<Result, never, Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
export const execute = <const Provided extends Record<string, unknown>>(
options: ExecuteOptions<Provided>,
): Effect.Effect<Result, never, Services<Provided>> => {
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
}
/** Creates an Effect-native runtime over explicit, schema-described tools. */
export const make = <const Tools extends Record<string, unknown> = {}>(
options: Options<Tools> = {} as Options<Tools>,
): Runtime<Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
export const make = <const Provided extends Record<string, unknown> = {}>(
options: Options<Provided> = {} as Options<Provided>,
): Runtime<Services<Provided>> => {
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
const limits = resolveExecutionLimits(options.limits)
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
return {
catalog: () => prepared.catalog,
instructions: () => prepared.instructions,
execute: (code) => executeWithLimits<Tools>({ ...options, code }, limits, prepared.searchIndex),
execute: (code) => executeWithLimits<Provided>({ ...options, code }, limits, prepared.searchIndex),
}
}
+15 -8
View File
@@ -2,17 +2,18 @@ import { parse } from "acorn"
import { Cause, Effect, Scope } from "effect"
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
import { copyIn, copyOut, ToolRuntime, type HostTools, type Services } from "../tool-runtime.js"
import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js"
import type { Tools } from "../tools.js"
import { normalizeError } from "./errors.js"
import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js"
import { PromiseRuntime } from "./promises.js"
import { Interpreter } from "./runtime.js"
export const executeWithLimits = <const Tools extends Record<string, unknown>>(
options: ExecuteOptions<Tools>,
export const executeWithLimits = <const Provided extends Record<string, unknown>>(
options: ExecuteOptions<Provided>,
limits: ResolvedExecutionLimits,
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
): Effect.Effect<Result, never, Services<Tools>> => {
): Effect.Effect<Result, never, Services<Provided>> => {
if (options.code.trim().length === 0) {
return Effect.succeed({
ok: false,
@@ -24,7 +25,7 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
// Allocate execution state inside suspension so reused Effects never share it.
return Effect.suspend(() => {
const tools = ToolRuntime.make(
(options.tools ?? {}) as HostTools<Services<Tools>>,
(options.tools ?? {}) as Tools<Services<Provided>>,
limits.maxToolCalls,
searchIndex,
{
@@ -35,15 +36,21 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
const logs: Array<string> = []
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
// Set only after copy-out so timeouts cannot report invalid values as completed.
let returned: { value: DataValue; promises: PromiseRuntime<Services<Tools>> } | undefined
let returned: { value: DataValue; promises: PromiseRuntime<Services<Provided>> } | undefined
const base = Effect.acquireUseRelease(
Scope.make("parallel"),
(scope) =>
Effect.gen(function* () {
const program = parseProgram(options.code)
const promises = new PromiseRuntime<Services<Tools>>(scope)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.search, tools.keys, promises, logs)
const promises = new PromiseRuntime<Services<Provided>>(scope)
const interpreter = new Interpreter<Services<Provided>>(
tools.invoke,
tools.search,
tools.keys,
promises,
logs,
)
const value = yield* interpreter.run(program)
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
returned = { value: result, promises }
+27 -19
View File
@@ -68,14 +68,16 @@ const buildRequest = (
}
let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url)
const query: Array<readonly [string, string]> = []
for (const field of plan.fields) {
if (field.location !== "query") continue
const item = own(input, field.inputName)
if (item === undefined) continue
const serialized = serializeQuery(request, field, item)
const serialized = serializeQuery(field, item)
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
request = serialized
for (const parameter of serialized) query.push(parameter)
}
if (query.length > 0) request = HttpClientRequest.appendUrlParams(request, query)
request = HttpClientRequest.setHeaders(request, plan.headers)
for (const field of plan.fields) {
@@ -246,40 +248,46 @@ const serializeSimple = (
}
const serializeQuery = (
request: HttpClientRequest.HttpClientRequest,
field: Plan["fields"][number],
value: unknown,
): HttpClientRequest.HttpClientRequest | ToolError => {
): ReadonlyArray<readonly [string, string]> | ToolError => {
if (field.style === "deepObject") {
if (!isRecord(value)) return toolError(`Deep-object parameter '${field.inputName}' must be an object.`)
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
if (current instanceof ToolError) return current
const parameters: Array<readonly [string, string]> = []
for (const [name, item] of Object.entries(value)) {
if (item === undefined || (item !== null && typeof item === "object")) {
return toolError(`Deep-object parameter '${field.inputName}' contains an unsupported nested value.`)
}
return HttpClientRequest.appendUrlParam(current, `${field.name}[${name}]`, String(item))
}, request)
parameters.push([`${field.name}[${name}]`, String(item)])
}
return parameters
}
if (Array.isArray(value)) {
const rendered = serializeSimple(field, value, String)
if (rendered instanceof ToolError) return rendered
if (!field.explode) return HttpClientRequest.appendUrlParam(request, field.name, rendered)
if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
if (!field.explode) {
const rendered = serializeSimple(field, value, String)
return rendered instanceof ToolError ? rendered : [[field.name, rendered]]
}
return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request)
const parameters: Array<readonly [string, string]> = []
for (const item of value) {
if (item !== null && typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean") {
return toolError(`Parameter '${field.inputName}' contains an unsupported nested value.`)
}
parameters.push([field.name, String(item)])
}
return parameters
}
if (isRecord(value) && field.explode) {
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
if (current instanceof ToolError) return current
const parameters: Array<readonly [string, string]> = []
for (const [name, item] of Object.entries(value)) {
if (item === undefined || (item !== null && typeof item === "object")) {
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
}
return HttpClientRequest.appendUrlParam(current, name, String(item))
}, request)
parameters.push([name, String(item)])
}
return parameters
}
const rendered = serializeSimple(field, value, String)
return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
return rendered instanceof ToolError ? rendered : [[field.name, rendered]]
}
const readResponseBody = (
+1 -1
View File
@@ -61,7 +61,7 @@ export type Skipped = {
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
export type Result = {
/** Tool subtree; the host places it under a key in its `tools` tree. */
/** Namespaced tools; the host places them under a key in its `tools` object. */
readonly tools: Tools
readonly skipped: ReadonlyArray<Skipped>
}
+30 -54
View File
@@ -9,6 +9,7 @@ import {
outputTypeScript,
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import type { Tools } from "./tools.js"
import {
CodeModeDate,
CodeModeMap,
@@ -21,28 +22,20 @@ import {
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
export type HostTool<R = never> = (...args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type Services<T> = ServicesOf<T, []>
export type HostTools<R = never> = {
[name: string]: HostTool<R> | Definition<R> | HostTools<R>
}
export type Services<Tools> = ServicesOf<Tools, []>
type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
? never
: Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
: T extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: Tools extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: Tools extends object
? string extends keyof Tools
? ServicesOf<Tools[string], [...Depth, unknown]>
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
: never
: T extends object
? string extends keyof T
? ServicesOf<T[string], [...Depth, unknown]>
: ServicesOf<T[keyof T], [...Depth, unknown]>
: never
export type ToolCall = {
readonly name: string
@@ -125,7 +118,7 @@ export class ToolRuntimeError extends Error {
}
}
const isDefinition = <R>(value: HostTool<R> | Definition<R> | HostTools<R>): value is Definition<R> =>
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> =>
isToolDefinition<R>(value)
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
@@ -282,13 +275,13 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
}
const definitions = <R>(
tools: HostTools<R>,
tools: Tools<R>,
path: ReadonlyArray<string> = [],
): Array<{ path: string; definition: Definition<R> }> =>
Object.entries(tools).flatMap(([name, value]) => {
const next = [...path, name]
if (isDefinition(value)) return [{ path: next.join("."), definition: value }]
return typeof value === "function" ? [] : definitions(value, next)
return definitions(value, next)
})
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
@@ -297,7 +290,7 @@ const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDes
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
})
const visibleDefinitions = <R>(tools: HostTools<R>) =>
const visibleDefinitions = <R>(tools: Tools<R>) =>
definitions(tools).map(({ path, definition }) => ({
path,
definition,
@@ -415,11 +408,11 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
.toLowerCase(),
})
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
// Budget signatures round-robin so every namespace remains visible.
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
}
@@ -562,43 +555,33 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
}
}
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
const namespaceKeys = <R>(tools: Tools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
let value: Definition<R> | Tools<R> = tools
for (const segment of path) {
if (
isBlockedMember(segment) ||
typeof value === "function" ||
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
if (isBlockedMember(segment) || isDefinition(value) || !Object.hasOwn(value, segment)) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [
"Object.keys(tools) lists the available namespaces; search({ query }) finds described tools.",
])
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
value = value[segment] as Definition<R> | Tools<R>
}
if (typeof value === "function" || isDefinition(value)) return []
if (isDefinition(value)) return []
return Object.keys(value)
}
const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<R> | Definition<R> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
const resolve = <R>(tools: Tools<R>, path: ReadonlyArray<string>): Definition<R> => {
let value: Definition<R> | Tools<R> = tools
for (const segment of path) {
if (
isBlockedMember(segment) ||
typeof value === "function" ||
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
if (isBlockedMember(segment) || isDefinition(value) || !Object.hasOwn(value, segment)) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
"Use search({ query }) to find available described tools.",
])
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
value = value[segment] as Definition<R> | Tools<R>
}
if (typeof value !== "function" && !isDefinition(value)) {
if (!isDefinition(value)) {
throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`)
}
@@ -614,7 +597,7 @@ export type ToolRuntime<R = never> = {
}
export const make = <R>(
tools: HostTools<R>,
tools: Tools<R>,
maxToolCalls: number | undefined,
searchIndex: ReadonlyArray<SearchEntry>,
hooks?: ToolCallHooks<R>,
@@ -701,14 +684,7 @@ export const make = <R>(
const name = path.join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
const tool = resolve(tools, path)
if (isDefinition(tool)) return yield* invokeDefinition(name, tool, externalArgs)
const index = yield* recordAndObserve(name, externalArgs)
return yield* observeEnd(
Effect.gen(function* () {
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
}),
{ index, name, input: externalArgs },
)
return yield* invokeDefinition(name, tool, externalArgs)
}),
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ export type JsonSchema = {
/** Either a validating Effect Schema or a render-only JSON Schema document. */
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
/** Schema-backed tool definition consumed by a CodeMode tool tree. */
/** Schema-backed tool definition exposed through CodeMode's `tools` object. */
export type Definition<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
+5
View File
@@ -0,0 +1,5 @@
import type { Definition } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Definition<R> | Tools<R>
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Key enumeration: Object.keys and for...in share one surface over plain objects, arrays
// (index strings), and tool references (namespace/tool names from the host tool tree), so a
// (index strings), and tool references (namespace/tool names from the supplied tools), so a
// model can discover what it may call instead of guessing names from the instructions. The
// motivating transcript: `Object.keys(tools)` failed with the generic plain-objects-only
// message and `for (const key in tools)` was unsupported syntax, forcing blind guesses.
@@ -137,7 +137,7 @@ describe("for...in", () => {
).toBe("only")
})
test("enumerates namespaces and tools from the callable tool tree", async () => {
test("enumerates namespaces and tools from the supplied tools", async () => {
expect(
await value(`
const names = []
+42
View File
@@ -495,6 +495,48 @@ describe("OpenAPI.fromSpec", () => {
)
})
test("preserves ordered exploded and deep-object query parameters", async () => {
const client = recordingClient(() => json({ ok: true }))
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: singleOperation({
parameters: [
{ name: "tags", in: "query", style: "form", explode: true, schema: { type: "array" } },
{ name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } },
{ name: "location", in: "query", style: "deepObject", explode: true, schema: { type: "object" } },
],
}),
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
await Effect.runPromise(
tool
.run({
tags: ["first value", "second&value"],
filter: { state: "open now", page: 2 },
location: { directory: "/tmp/a b", workspace: "work&1" },
})
.pipe(Effect.provide(client.layer)),
)
expect(client.requests[0]?.url).toBe(
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
)
await expect(
Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Parameter 'tags' contains an unsupported nested value.")
await expect(
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
await expect(
Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.")
expect(client.requests).toHaveLength(1)
})
test("skips unsupported parameter encodings and malformed security", () => {
const result = OpenAPI.fromSpec({
baseUrl,
+43 -11
View File
@@ -103,7 +103,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
}
function prepareOptions(model: ModelV2.Info, pkg: string) {
const projected = mapBodyToProviderOptions(model)
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.providerID,
...(model.settings ?? {}),
@@ -265,7 +265,7 @@ export const locationLayer = Layer.effect(
cause: new Error(`Unsupported package ${model.package}`),
})
const packageName = ProviderV2.packageName(model.package) ?? ""
const packageName = ProviderV2.packageName(model.package)
const options = prepareOptions(model, packageName)
const sdkKey = cacheKey({
providerID: model.providerID,
@@ -301,11 +301,17 @@ export const locationLayer = Layer.effect(
export const defaultLayer = locationLayer
function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
const packageName = ProviderV2.packageName(info.package)
const projected = mapBodyToProviderOptions(info)
const packageName = ProviderV2.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
const providerOptions = (() => {
if (projected.settings === undefined) return
if (packageName === "@ai-sdk/gateway") return gatewayProviderOptions(info.modelID ?? info.id, projected.settings)
if (packageName === "@ai-sdk/azure") return { openai: projected.settings, azure: projected.settings }
return { [optionKey]: projected.settings }
})()
const route: AnyRoute = {
id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`,
id: `ai-sdk:${packageName}`,
provider: ProviderID.make(info.providerID),
providerMetadataKey: optionKey,
protocol: "ai-sdk",
@@ -326,7 +332,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
headers: info.headers,
},
limits: { context: info.limit.context, output: info.limit.output },
providerOptions: projected.settings === undefined ? undefined : { [optionKey]: projected.settings },
providerOptions,
},
body: {
schema: Schema.Unknown,
@@ -340,13 +346,35 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
}
function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<string, unknown>>) {
const gateway =
typeof settings.gateway === "object" && settings.gateway !== null && !Array.isArray(settings.gateway)
? Object.fromEntries(Object.entries(settings.gateway))
: undefined
const model = Object.fromEntries(Object.entries(settings).filter(([key]) => key !== "gateway"))
if (Object.keys(model).length === 0) return gateway === undefined ? undefined : { gateway }
const separator = modelID.indexOf("/")
const prefix = separator > 0 ? modelID.slice(0, separator) : undefined
if (prefix)
return { ...(gateway === undefined ? {} : { gateway }), [prefix === "amazon" ? "bedrock" : prefix]: model }
if (typeof gateway === "object" && gateway !== null && !Array.isArray(gateway))
return { gateway: { ...gateway, ...model } }
return { gateway: model }
}
function providerOptionKey(packageName: string | undefined, providerID: ProviderV2.ID) {
if (packageName === "@ai-sdk/google") return "google"
if (packageName === "@ai-sdk/google-vertex") return "vertex"
if (packageName === "@ai-sdk/google-vertex/anthropic") return "anthropic"
if (packageName === "@ai-sdk/amazon-bedrock" || packageName === "@ai-sdk/amazon-bedrock/mantle") return "bedrock"
if (packageName === "@ai-sdk/amazon-bedrock") return "bedrock"
if (packageName === "@ai-sdk/amazon-bedrock/mantle") return "openai"
if (packageName === "@ai-sdk/azure") return "azure"
if (packageName === "@ai-sdk/github-copilot") return "copilot"
if (packageName === "@jerome-benoit/sap-ai-provider-v2") return "sap-ai"
if (packageName === "@ai-sdk/openai-compatible") return providerID.split(".")[0]
if (packageName === "@openrouter/ai-sdk-provider") return "openrouter"
if (packageName === "ai-gateway-provider") return "openaiCompatible"
if (packageName?.startsWith("@ai-sdk/")) return packageName.slice("@ai-sdk/".length)
return providerID
}
@@ -361,14 +389,18 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
return Object.keys(result).length === 0 ? undefined : result
}
function mapBodyToProviderOptions(model: ModelV2.Info) {
function mapBodyToProviderOptions(model: ModelV2.Info, packageName: string) {
const settings = requestSettings(model.settings)
if (!Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning))
return { settings, body: model.body }
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
const forceReasoning =
["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(packageName) &&
(pro || settings?.reasoningEffort !== undefined || settings?.reasoningSummary !== undefined)
const normalized = forceReasoning ? ProviderV2.mergeOverlay(settings, { forceReasoning: true }) : settings
if (!pro) return { settings: normalized, body: model.body }
const body = { ...model.body }
delete body.reasoning
return {
settings: ProviderV2.mergeOverlay(settings, { reasoningMode: "pro" }),
settings: ProviderV2.mergeOverlay(normalized, { reasoningMode: "pro" }),
body: Object.keys(body).length === 0 ? undefined : body,
}
}
+38 -12
View File
@@ -2,16 +2,15 @@ export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Global } from "../global"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionEvent } from "../session/event"
import { SessionExecution } from "../session/execution"
import { SessionSchema } from "../session/schema"
import { SessionStore } from "../session/store"
import { AbsolutePath, RelativePath } from "../schema"
import { AbsolutePath } from "../schema"
import path from "path"
export const Destination = Schema.Struct({
@@ -34,6 +33,16 @@ export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<Des
},
) {}
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
"MoveSession.DestinationNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
"MoveSession.DestinationNotDirectoryError",
{ directory: AbsolutePath },
) {}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
}) {}
@@ -57,6 +66,10 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
export type Error =
| SessionV2.NotFoundError
| DestinationProjectMismatchError
| DestinationNotFoundError
| DestinationNotDirectoryError
| SessionV2.DestinationNotFoundError
| SessionV2.DestinationNotDirectoryError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
@@ -71,23 +84,29 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const project = yield* ProjectV2.Service
const sessions = yield* SessionStore.Service
const session = yield* SessionV2.Service
const execution = yield* SessionExecution.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
if (!current) return yield* new SessionV2.NotFoundError({ sessionID: input.sessionID })
const directory = AbsolutePath.make(input.destination.directory)
const value = input.destination.directory.trim()
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory) return
const source = yield* project.resolve(current.location.directory)
const destination = yield* project.resolve(directory)
if (current.projectID !== destination.id) {
if (input.moveChanges && current.projectID !== destination.id) {
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
// A move must not race active execution: a mid-drain relocation would let
// the source Location dispatch a request assembled under stale instructions
// and history. Serialize like removal does — stop the drain, then move.
@@ -111,10 +130,9 @@ const layer = Layer.effect(
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
}
yield* events.publish(SessionEvent.Moved, {
yield* session.move({
sessionID: input.sessionID,
location: Location.Ref.make({ directory }),
subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
directory,
})
if (patch) {
@@ -151,5 +169,13 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, SessionExecution.node],
deps: [
FSUtil.node,
Git.node,
Global.node,
ProjectV2.node,
SessionV2.node,
SessionStore.node,
SessionExecution.node,
],
})
+40 -32
View File
@@ -3,10 +3,11 @@ export * as InstructionBuiltIns from "./builtins"
import { makeLocationNode } from "../effect/app-node"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Location } from "../location"
import { SessionSchema } from "../session/schema"
import { Instructions } from "./index"
export interface Interface {
readonly load: () => Effect.Effect<Instructions.Instructions>
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionBuiltIns") {}
@@ -15,38 +16,45 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const instructions = Instructions.combine([
Instructions.make({
key: Instructions.Key.make("core/environment"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() =>
[
"<env>",
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
"</env>",
].join("\n"),
return Service.of({
load: (sessionID) =>
Effect.succeed(
Instructions.combine([
Instructions.make({
key: Instructions.Key.make("core/environment"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() =>
[
"<env>",
` Session ID: ${sessionID}`,
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
"</env>",
].join("\n"),
),
render: {
initial: (environment) =>
["Here is some useful information about the environment you are running in:", environment].join(
"\n",
),
changed: (_previous, environment) =>
["The environment you are running in is now:", environment].join("\n"),
},
}),
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
}),
]),
),
render: {
initial: (environment) =>
["Here is some useful information about the environment you are running in:", environment].join("\n"),
changed: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
},
}),
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
}),
])
return Service.of({ load: () => Effect.succeed(instructions) })
})
}),
)
+263 -27
View File
@@ -56,7 +56,10 @@ type SourceModel = {
string,
{
readonly cost?: Cost
readonly provider?: { readonly body?: ProviderV2.Settings; readonly headers?: Readonly<Record<string, string>> }
readonly provider?: {
readonly body?: ProviderV2.Settings
readonly headers?: Readonly<Record<string, string>>
}
}
>
>
@@ -70,7 +73,7 @@ type SourceProvider = {
readonly name: string
readonly env: readonly string[]
readonly id: string
readonly npm?: string
readonly npm: string
readonly models: Readonly<Record<string, SourceModel>>
}
@@ -87,7 +90,7 @@ function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
const info = {
id: providerID,
name: item.name,
package: item.npm ? ProviderV2.aisdk(item.npm) : "",
package: ProviderV2.aisdk(item.npm),
...(item.api ? { settings: { baseURL: item.api } } : {}),
} satisfies ProviderV2.Info
const models: ModelV2.Info[] = []
@@ -185,61 +188,294 @@ function mergeCost(base: ModelV2.Info["cost"], override: SourceModel["cost"] | u
}
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
const OUTPUT_TOKEN_MAX = 32_000
function reasoningVariants(provider: SourceProvider, model: SourceModel): NonNullable<ModelV2.Info["variants"]> {
const npm = model.provider?.npm ?? provider.npm
const options = model.reasoning_options ?? []
const options = model.reasoning_options
if (!options?.length) return []
const toggle = options.some((option) => option.type === "toggle")
const effort = options.find((option) => option.type === "effort")
if (effort?.type === "effort") {
return effort.values.flatMap((value) => {
const raw: unknown = value
const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined
if (id === undefined) return []
const settings = settingsForEffort(npm, id)
return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : []
})
const off = toggle ? toggleVariants(npm, model.id).filter((variant) => variant.id === "none") : []
const variants = [
...off,
...effort.values.flatMap((value) => {
const raw: unknown = value
const id = typeof raw === "string" && raw !== "null" ? raw : undefined
if (id === undefined) return []
if (id === "none" && off.length > 0) return []
const settings = settingsForEffort(npm, model.id, id)
return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : []
}),
]
return [...new Map(variants.map((variant) => [variant.id, variant])).values()]
}
const budget = options.find((option) => option.type === "budget_tokens")
if (budget?.type === "budget_tokens") return budgetVariants(npm, budget)
if (budget?.type === "budget_tokens")
return [
...(toggle ? toggleVariants(npm, model.id).filter((variant) => variant.id === "none") : []),
...budgetVariants(npm, model, budget),
]
if (toggle) return toggleVariants(npm, model.id)
return []
}
function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined {
function settingsForEffort(npm: string, modelID: string, effort: string): ProviderV2.Settings | undefined {
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic")
return { thinking: { type: "adaptive", display: "summarized" }, effort }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
if (anthropicManualThinking(modelID)) return { effort }
return {
thinking: { type: "adaptive", display: "summarized" },
effort,
}
}
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex")
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
if (npm === "@ai-sdk/azure") return { reasoningEffort: effort }
if (npm === "@ai-sdk/openai")
if (npm === "@ai-sdk/amazon-bedrock") {
if (modelID.includes("anthropic"))
return {
reasoningConfig: {
...(anthropicManualThinking(modelID) ? {} : { type: "adaptive", display: "summarized" }),
maxReasoningEffort: effort,
},
}
return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }
}
if (npm === "@ai-sdk/gateway") {
const upstream = gatewayPackage(modelID)
if (upstream) return settingsForEffort(upstream, modelID, effort)
return { reasoningEffort: effort }
}
if (npm === "@ai-sdk/github-copilot") {
if (modelID.includes("gemini")) return
if (modelID.includes("claude")) return { reasoningEffort: effort }
return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING }
if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort }
}
if (npm === "@ai-sdk/openai" || npm === "@ai-sdk/amazon-bedrock/mantle" || npm === "@ai-sdk/azure")
return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING }
if (npm === "@jerome-benoit/sap-ai-provider-v2") {
if (modelID.includes("anthropic"))
return {
modelParams: {
additionalModelRequestFields: {
...(anthropicManualThinking(modelID) ? {} : { thinking: { type: "adaptive", display: "summarized" } }),
output_config: { effort },
},
},
}
if (modelID.includes("gemini"))
return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } }
if (modelID.includes("amazon--nova"))
return { modelParams: { additionalModelRequestFields: { output_config: { effort } } } }
return { modelParams: { reasoning_effort: effort } }
}
if (
[
"@ai-sdk/openai-compatible",
"@ai-sdk/xai",
"@ai-sdk/mistral",
"@ai-sdk/groq",
"@ai-sdk/cerebras",
"@ai-sdk/deepinfra",
"@ai-sdk/togetherai",
"venice-ai-sdk-provider",
"ai-gateway-provider",
].includes(npm)
)
return { reasoningEffort: effort }
}
function budgetVariants(
npm: string | undefined,
npm: string,
model: SourceModel,
option: Extract<NonNullable<SourceModel["reasoning_options"]>[number], { type: "budget_tokens" }>,
): NonNullable<ModelV2.Info["variants"]> {
const max = option.max
const high =
option.max === undefined
? Math.max(option.min ?? 0, 16_000)
: Math.min(Math.max(option.min ?? 0, 16_000), option.max)
const maximum = Math.min(option.max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1)
if (maximum <= 0) return []
const high = Math.min(Math.max(option.min ?? 0, Math.floor((maximum + 1) / 2)), maximum)
return [
{ id: "high", budget: high },
...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]),
{ id: "max", budget: maximum },
].flatMap((item) => {
const settings = settingsForBudget(npm, item.budget)
const settings = settingsForBudget(npm, model.id, item.budget)
return settings ? [{ id: ModelV2.VariantID.make(item.id), settings }] : []
})
}
function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined {
function toggleVariants(npm: string, modelID: string): NonNullable<ModelV2.Info["variants"]> {
if (npm === "@ai-sdk/gateway") {
const upstream = gatewayPackage(modelID)
if (upstream) return toggleVariants(upstream, modelID)
return [
{
id: ModelV2.VariantID.make("none"),
settings: { reasoning: { enabled: false } },
},
{
id: ModelV2.VariantID.make("thinking"),
settings: { reasoning: { enabled: true } },
},
]
}
if (npm === "@openrouter/ai-sdk-provider")
return [
{ id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
]
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic")
return [
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: {
thinking: { type: "adaptive", display: "summarized" },
},
},
]
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex")
return [
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } },
},
]
if (npm === "@ai-sdk/amazon-bedrock") {
const anthropic = modelID.includes("anthropic")
return [
{
id: ModelV2.VariantID.make("none"),
settings: {
additionalModelRequestFields: anthropic
? { thinking: { type: "disabled" } }
: { reasoningConfig: { type: "disabled" } },
},
},
{
id: ModelV2.VariantID.make("thinking"),
settings: {
additionalModelRequestFields: anthropic
? { thinking: { type: "adaptive", display: "summarized" } }
: { reasoningConfig: { type: "enabled" } },
},
},
]
}
if (npm === "@ai-sdk/alibaba")
return [
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } },
]
if (npm === "@ai-sdk/cohere")
return [
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { thinking: { type: "enabled" } } },
]
if (npm === "@jerome-benoit/sap-ai-provider-v2") {
if (modelID.includes("gemini"))
return [
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
},
{
id: ModelV2.VariantID.make("thinking"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } },
},
]
if (modelID.includes("cohere"))
return [
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinking: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("thinking"),
settings: { modelParams: { thinking: { type: "enabled" } } },
},
]
if (modelID.includes("amazon--nova"))
return [
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } } },
},
{
id: ModelV2.VariantID.make("thinking"),
settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "enabled" } } } },
},
]
if (modelID.includes("anthropic"))
return [
{
id: ModelV2.VariantID.make("none"),
settings: {
modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
},
},
{
id: ModelV2.VariantID.make("thinking"),
settings: {
modelParams: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
},
},
},
},
]
}
return []
}
function settingsForBudget(npm: string, modelID: string, budget: number): ProviderV2.Settings | undefined {
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic")
return { thinking: { type: "enabled", budgetTokens: budget } }
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex")
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
if (npm === "@ai-sdk/amazon-bedrock") return { reasoningConfig: { type: "enabled", budgetTokens: budget } }
if (npm === "@ai-sdk/gateway") {
const upstream = gatewayPackage(modelID)
return upstream ? settingsForBudget(upstream, modelID, budget) : { reasoning: { max_tokens: budget } }
}
if (npm === "@ai-sdk/cohere") return { thinking: { type: "enabled", tokenBudget: budget } }
if (npm === "@ai-sdk/alibaba") return { enableThinking: true, thinkingBudget: budget }
if (npm === "@jerome-benoit/sap-ai-provider-v2") {
if (modelID.includes("anthropic"))
return {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: budget } },
},
}
if (modelID.includes("gemini"))
return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }
if (modelID.includes("cohere")) return { modelParams: { thinking: { type: "enabled", token_budget: budget } } }
}
}
function gatewayPackage(modelID: string) {
const separator = modelID.indexOf("/")
if (separator <= 0) return
const prefix = modelID.slice(0, separator)
if (prefix === "anthropic") return "@ai-sdk/anthropic"
if (prefix === "google") return "@ai-sdk/google"
if (prefix === "amazon") return "@ai-sdk/amazon-bedrock"
if (prefix === "alibaba") return "@ai-sdk/alibaba"
}
function anthropicManualThinking(modelID: string) {
const familyFirst = /(?:claude-)?(?:opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?/i.exec(modelID)
const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(?:opus|sonnet|haiku)/i.exec(modelID)
const major = Number(familyFirst?.[1] ?? versionFirst?.[1])
const rawMinor = Number(familyFirst?.[2] ?? versionFirst?.[2] ?? 0)
if (!Number.isFinite(major)) return false
const minor = rawMinor > 9 ? 0 : rawMinor
return major < 4 || (major === 4 && minor < 6)
}
function modeName(model: SourceModel, mode: string) {
+325 -1
View File
@@ -1,10 +1,159 @@
import { Effect } from "effect"
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Clock, Deferred, Effect, Option, Schema } from "effect"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
const issuer = "https://auth.x.ai/oauth2"
const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
const scope = "openid profile email offline_access grok-cli:access api:access"
const callbackHost = "127.0.0.1"
const callbackPort = 56121
const callbackPath = "/callback"
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
const pollingSafetyMargin = 3000
const corsOrigins = new Set(["https://accounts.x.ai", "https://auth.x.ai"])
const browserMethodID = Integration.MethodID.make("browser")
const deviceMethodID = Integration.MethodID.make("device")
type Pkce = {
verifier: string
challenge: string
}
const Token = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
type Token = typeof Token.Type
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri: Schema.String,
verification_uri_complete: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
interval: Schema.optional(Schema.Number),
})
const DeviceError = Schema.Struct({
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
})
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
const browser = {
integrationID: Integration.ID.make("xai"),
method: {
id: browserMethodID,
type: "oauth",
label: "xAI Grok OAuth (SuperGrok Subscription)",
},
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
const state = randomString(32)
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", redirectURI)
const origin = request.headers.origin
if (origin && corsOrigins.has(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin)
response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "Content-Type")
response.setHeader("Access-Control-Allow-Private-Network", "true")
response.setHeader("Vary", "Origin")
}
if (request.method === "OPTIONS") {
response.writeHead(204).end()
return
}
if (url.pathname !== callbackPath) {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "xAI" }))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "xAI" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "xAI" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
return {
mode: "auto" as const,
url: authorizeURL(pkce, state, randomString(32)),
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, pkce)),
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
),
}
}),
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })),
} satisfies IntegrationOAuthMethodRegistration
const device = {
integrationID: Integration.ID.make("xai"),
method: {
id: deviceMethodID,
type: "oauth",
label: "xAI Grok OAuth (Headless / Remote / VPS)",
},
authorize: () =>
request(
`${issuer}/device/code`,
{
method: "POST",
headers: headers(),
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
},
Device,
).pipe(
Effect.map((value) => ({
mode: "auto" as const,
url: value.verification_uri_complete ?? value.verification_uri,
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
})),
),
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
} satisfies IntegrationOAuthMethodRegistration
export const XAIPlugin = define({
id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.update("xai", (integration) => {
integration.name = "xAI"
})
draft.method.update(browser)
draft.method.update(device)
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
@@ -22,3 +171,178 @@ export const XAIPlugin = define({
)
}),
})
function exchange(code: string, pkce: Pkce) {
return request(
`${issuer}/token`,
{
method: "POST",
headers: headers(),
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectURI,
client_id: clientID,
code_verifier: pkce.verifier,
}).toString(),
},
Token,
)
}
function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
return request(
`${issuer}/token`,
{
method: "POST",
headers: headers(),
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID,
}).toString(),
},
Token,
).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
}
function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
return Effect.gen(function* () {
const started = yield* Clock.currentTimeMillis
const expires = started + positiveSeconds(device.expires_in, 300) * 1000
const loop = (interval: number): Effect.Effect<Token, unknown> =>
Effect.gen(function* () {
if ((yield* Clock.currentTimeMillis) >= expires) {
return yield* Effect.fail(new Error("xAI device authorization timed out"))
}
const response = yield* send(`${issuer}/token`, {
method: "POST",
headers: headers(),
body: new URLSearchParams({
grant_type: deviceGrant,
client_id: clientID,
device_code: device.device_code,
}).toString(),
})
if (response.ok) return yield* decode(response, Token)
const error = yield* Effect.promise(() => response.text()).pipe(
Effect.map((body) => Option.getOrUndefined(decodeDeviceError(body))),
Effect.catch(() => Effect.succeed(undefined)),
)
if (error?.error === "authorization_pending") {
return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval)))
}
if (error?.error === "slow_down") {
const next = interval + 5000
return yield* Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(loop(next)))
}
if (error?.error === "access_denied" || error?.error === "authorization_denied") {
return yield* Effect.fail(new Error("xAI device authorization was denied"))
}
if (error?.error === "expired_token") {
return yield* Effect.fail(new Error("xAI device code expired - please re-run login"))
}
const detail = error?.error_description ?? error?.error
return yield* Effect.fail(
new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`),
)
})
return yield* loop(Math.max(positiveSeconds(device.interval, 5) * 1000, 1000))
})
}
function request<S extends Schema.Decoder<unknown>>(url: string, init: RequestInit, schema: S) {
return send(url, init).pipe(
Effect.flatMap((response) => {
if (response.ok) return decode(response, schema)
return Effect.promise(() => response.text()).pipe(
Effect.flatMap((detail) =>
Effect.fail(new Error(`xAI request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
),
)
}),
)
}
function send(url: string, init: RequestInit) {
return Effect.tryPromise({
try: (signal) => fetch(url, { ...init, signal }),
catch: (cause) => cause,
})
}
function decode<S extends Schema.Decoder<unknown>>(response: Response, schema: S) {
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(schema)))
}
function credential(
methodID: Integration.MethodID,
tokens: Token,
currentRefresh?: string,
metadata?: Readonly<Record<string, unknown>>,
) {
const refresh = tokens.refresh_token ?? currentRefresh
if (!refresh) return Effect.fail(new Error("xAI token response is missing refresh_token"))
return Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
refresh,
access: tokens.access_token,
expires: tokenExpiration(tokens),
metadata,
}),
)
}
function tokenExpiration(tokens: Token) {
if (tokens.expires_in) return Date.now() + positiveSeconds(tokens.expires_in, 3600) * 1000
const payload = tokens.access_token.split(".")[1]
if (!payload) return Date.now() + 3600 * 1000
const claims = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Struct({ exp: Schema.optional(Schema.Number) })),
)(Buffer.from(payload, "base64url").toString())
const expiration = Option.getOrUndefined(claims)?.exp
return expiration ? expiration * 1000 : Date.now() + 3600 * 1000
}
function headers() {
return {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
}
}
function positiveSeconds(value: unknown, fallback: number) {
const seconds = Number(value)
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback
}
async function generatePKCE(): Promise<Pkce> {
const verifier = randomString(64)
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
"base64url",
)
return { verifier, challenge }
}
function randomString(length: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
}
function authorizeURL(pkce: Pkce, state: string, nonce: string) {
return `${issuer}/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirectURI,
scope,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
state,
nonce,
plan: "generic",
referrer: "opencode",
}).toString()}`
}
+5 -2
View File
@@ -11,9 +11,12 @@ export const ID = Provider.ID
export type ID = typeof ID.Type
export const AISDK_PREFIX = "aisdk:"
export const isAISDK = (value: string | undefined) => value?.startsWith(AISDK_PREFIX) ?? false
export const isAISDK = (value: string | undefined): value is string => value?.startsWith(AISDK_PREFIX) ?? false
export const aisdk = (value: string) => (isAISDK(value) ? value : `${AISDK_PREFIX}${value}`)
export const packageName = (value: string | undefined) => {
export function packageName(value: string): string
export function packageName(value: undefined): undefined
export function packageName(value: string | undefined): string | undefined
export function packageName(value: string | undefined) {
if (value === undefined || !isAISDK(value)) return value
return value.slice(AISDK_PREFIX.length)
}
+52
View File
@@ -43,6 +43,7 @@ import { SkillV2 } from "./skill"
import { Job } from "./job"
import { CommandV2 } from "./command"
import { Shell } from "./shell"
import { Global } from "./global"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
import { fileURLToPath } from "url"
@@ -146,6 +147,16 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: SkillV2.ID,
}) {}
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
"Session.DestinationNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
"Session.DestinationNotDirectoryError",
{ directory: AbsolutePath },
) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
@@ -159,6 +170,8 @@ export type Error =
| CompactionConflictError
| BusyError
| SkillNotFoundError
| DestinationNotFoundError
| DestinationNotDirectoryError
| CommandV2.NotFoundError
| CommandV2.EvaluationError
| MessageNotFoundError
@@ -215,6 +228,11 @@ export interface Interface {
model: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly move: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -288,6 +306,7 @@ const layer = Layer.effect(
const db = database.db
const events = yield* EventV2.Service
const projects = yield* ProjectV2.Service
const global = yield* Global.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
@@ -673,6 +692,38 @@ const layer = Layer.effect(
title: input.title,
})
}),
move: Effect.fn("V2Session.move")(function* (input) {
const current = yield* result.get(input.sessionID)
const value = input.directory.trim()
const expanded =
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (
current.location.directory === directory &&
current.location.workspaceID === input.workspaceID
)
return
const project = yield* projects.resolve(directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
}
yield* events.publish(SessionEvent.Moved, {
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
@@ -949,5 +1000,6 @@ export const node = makeGlobalNode({
LocationServiceMap.node,
SessionProjector.node,
FSUtil.node,
Global.node,
],
})
+12 -10
View File
@@ -9,6 +9,7 @@ import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
@@ -66,7 +67,7 @@ type Dependencies = {
}
export type AutoInput = {
readonly sessionID: SessionSchema.ID
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly model: Model
}
@@ -78,7 +79,7 @@ export type ManualInput = {
}
type Plan = {
readonly sessionID: SessionSchema.ID
readonly session: SessionSchema.Info
readonly model: Model
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
@@ -230,7 +231,7 @@ const make = (dependencies: Dependencies) => {
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
sessionID: plan.sessionID,
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
@@ -242,6 +243,7 @@ const make = (dependencies: Dependencies) => {
.stream(
LLM.request({
model: plan.model,
http: { headers: SessionModelHeaders.make(plan.session) },
messages: [Message.user(plan.prompt)],
tools: [],
}),
@@ -256,7 +258,7 @@ const make = (dependencies: Dependencies) => {
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
sessionID: plan.sessionID,
sessionID: plan.session.id,
text: event.text,
})
}
@@ -270,7 +272,7 @@ const make = (dependencies: Dependencies) => {
Effect.onInterrupt(() =>
plan.reason === "auto"
? failed({
sessionID: plan.sessionID,
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
@@ -282,14 +284,14 @@ const make = (dependencies: Dependencies) => {
if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
return yield* failed({
sessionID: plan.sessionID,
sessionID: plan.session.id,
reason: plan.reason,
error,
inputID: plan.inputID,
})
}
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: plan.sessionID,
sessionID: plan.session.id,
reason: plan.reason,
text: summary,
recent: plan.recent,
@@ -300,14 +302,14 @@ const make = (dependencies: Dependencies) => {
const content = planContent(input.messages, config.tokens)
if (content)
return yield* execute({
sessionID: input.sessionID,
session: input.session,
model: input.model,
reason: "auto",
...content,
})
const error = { type: "compaction.unavailable" as const, message: "Nothing to compact yet" }
return yield* failed({
sessionID: input.sessionID,
sessionID: input.session.id,
reason: "auto",
error,
})
@@ -348,7 +350,7 @@ const make = (dependencies: Dependencies) => {
)
if ("status" in resolved) return resolved
return yield* execute({
sessionID: input.session.id,
session: input.session,
model: resolved.model,
reason: "manual",
inputID: input.inputID,
@@ -0,0 +1,15 @@
export * as SessionModelHeaders from "./model-headers"
import { Flag } from "../flag/flag"
import { InstallationVersion } from "../installation/version"
import { SessionSchema } from "./schema"
export const make = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">) => ({
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
})
+1
View File
@@ -497,6 +497,7 @@ const layer = Layer.effectDiscard(
.set({
directory: event.data.location.directory,
path: event.data.subpath,
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.created),
})
+5 -9
View File
@@ -51,7 +51,7 @@ import { AgentNotFoundError, StepFailedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
import { PluginSupervisor } from "../../plugin/supervisor"
import { Flag } from "../../flag/flag"
import { SessionModelHeaders } from "../model-headers"
type StepTokens = {
readonly input: number
@@ -145,7 +145,7 @@ const layer = Layer.effect(
const loadInstructions = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
Effect.all(
[
builtins.load(),
builtins.load(sessionID),
discovery.load(),
skillGuidance.load(agent),
referenceGuidance.load(),
@@ -187,7 +187,7 @@ const layer = Layer.effect(
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const history = yield* SessionHistory.entriesForRunner(db, session.id, instructions)
const context = history.entries.map((entry) => entry.message)
const compactionInput = { sessionID: session.id, messages: context, model }
const compactionInput = { session, messages: context, model }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
@@ -199,11 +199,7 @@ const layer = Layer.effect(
const request = LLM.request({
model,
http: {
headers: {
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
},
headers: SessionModelHeaders.make(session),
},
providerOptions: { openai: { promptCacheKey } },
system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), history.initial]
@@ -341,7 +337,7 @@ const layer = Layer.effect(
recoverOverflow &&
!publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
(yield* restore(recoverOverflow({ session, messages: context, model }))).status ===
"completed"
)
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
+2
View File
@@ -9,6 +9,7 @@ import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
@@ -54,6 +55,7 @@ const make = (dependencies: Dependencies) => {
.stream(
LLM.request({
model: resolved.model,
http: { headers: SessionModelHeaders.make(session) },
system: agent.system,
messages: [Message.user(firstUser.text)],
tools: [],
+32 -11
View File
@@ -3,7 +3,7 @@ export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/llm"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { Effect, Fiber, Schedule, Schema, Scope } from "effect"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
@@ -72,7 +72,6 @@ const modelOutput = (output: Output): string | undefined => {
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
// TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
@@ -201,9 +200,18 @@ export const Plugin = {
metadata: { sessionID: context.sessionID },
})
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
const truncated = page.size > page.cursor
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
truncated,
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") {
return {
@@ -215,13 +223,11 @@ export const Plugin = {
}
}
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
const capture = yield* captureShell()
return {
exit: final.exit,
output: `${body}${notice}`,
truncated,
output: capture.output,
truncated: capture.truncated,
status: "completed" as const,
}
})
@@ -250,9 +256,24 @@ export const Plugin = {
}
}
const result = yield* runtime.job
.block({ id: job.id, sessionID: context.sessionID })
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen(
captureShell().pipe(
Effect.flatMap((capture) =>
context.progress({
structured: { truncated: capture.truncated },
content: [{ type: "text", text: capture.output }],
}),
),
),
),
Effect.repeat(Schedule.forever),
Effect.forkIn(scope, { startImmediately: true }),
)
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
Effect.ensuring(Fiber.interrupt(progress)),
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
+99 -1
View File
@@ -86,7 +86,105 @@ it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
)
expect(body).toBeUndefined()
expect(prepared.body.providerOptions).toEqual({ openai: { reasoningMode: "pro" } })
expect(prepared.body.providerOptions).toEqual({
openai: { forceReasoning: true, reasoningMode: "pro" },
})
}),
)
it.effect("maps package-specific AI SDK provider option keys", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const cases = [
["@ai-sdk/github-copilot", "copilot", { reasoningEffort: "high" }],
["@ai-sdk/amazon-bedrock/mantle", "openai", { reasoningEffort: "high", forceReasoning: true }],
["@ai-sdk/openai-compatible", "test-provider", { reasoningEffort: "high" }],
["@jerome-benoit/sap-ai-provider-v2", "sap-ai", { reasoningEffort: "high" }],
["ai-gateway-provider", "openaiCompatible", { reasoningEffort: "high" }],
] as const
for (const [packageName, key, settings] of cases) {
const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" }))
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: resolved, prompt: "Hello" }),
)
expect(prepared.body.providerOptions).toEqual({ [key]: settings })
}
}),
)
it.effect("forces reasoning and projects both Azure AI SDK namespaces", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" }))
const openaiPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: openai, prompt: "Hello" }),
)
expect(openaiPrepared.body.providerOptions).toEqual({
openai: { reasoningEffort: "high", forceReasoning: true },
})
const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" }))
const azurePrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: azure, prompt: "Hello" }),
)
expect(azurePrepared.body.providerOptions).toEqual({
openai: { reasoningEffort: "high", forceReasoning: true },
azure: { reasoningEffort: "high", forceReasoning: true },
})
}),
)
it.effect("routes AI Gateway model options by upstream prefix", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const anthropic = yield* aisdk.model({
...model("@ai-sdk/gateway", {
gateway: { order: ["anthropic"] },
thinking: { type: "adaptive" },
}),
modelID: ModelV2.ID.make("anthropic/claude-sonnet-5"),
})
const anthropicPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: anthropic, prompt: "Hello" }),
)
expect(anthropicPrepared.body.providerOptions).toEqual({
gateway: { order: ["anthropic"] },
anthropic: { thinking: { type: "adaptive" } },
})
const bedrock = yield* aisdk.model({
...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }),
modelID: ModelV2.ID.make("amazon/nova-2-lite"),
})
const bedrockPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: bedrock, prompt: "Hello" }),
)
expect(bedrockPrepared.body.providerOptions).toEqual({
bedrock: { reasoningConfig: { type: "enabled" } },
})
const fallback = yield* aisdk.model({
...model("@ai-sdk/gateway", { reasoningEffort: "high" }),
modelID: ModelV2.ID.make("deepseek/deepseek-v4"),
})
const fallbackPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: fallback, prompt: "Hello" }),
)
expect(fallbackPrepared.body.providerOptions).toEqual({
deepseek: { reasoningEffort: "high" },
})
}),
)
@@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
@@ -14,6 +15,7 @@ import { readInitial, readUpdate } from "../lib/instructions"
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const sessionID = SessionSchema.ID.make("ses_builtin_test")
const localDate = (time: number) => new Date(time).toDateString()
const locationLayer = Layer.succeed(
Location.Service,
@@ -36,12 +38,13 @@ describe("InstructionBuiltIns", () => {
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* readInitial(yield* context.load())
const initialized = yield* readInitial(yield* context.load(sessionID))
expect(initialized.text).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Session ID: ${sessionID}`,
` Working directory: ${directory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
@@ -58,10 +61,10 @@ describe("InstructionBuiltIns", () => {
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* readInitial(yield* context.load())
const initialized = yield* readInitial(yield* context.load(sessionID))
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
const refreshed = yield* readUpdate(yield* context.load(), initialized)
const refreshed = yield* readUpdate(yield* context.load(sessionID), initialized)
expect(refreshed.text).toBe(`Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`)
}),
@@ -71,10 +74,10 @@ describe("InstructionBuiltIns", () => {
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* readInitial(yield* context.load())
const initialized = yield* readInitial(yield* context.load(sessionID))
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
expect((yield* readUpdate(yield* context.load(), initialized)).changed).toBe(false)
expect((yield* readUpdate(yield* context.load(sessionID), initialized)).changed).toBe(false)
}),
)
})
+4 -2
View File
@@ -37,6 +37,7 @@ const fixture = {
id: "acme",
name: "Acme",
env: ["ACME_API_KEY"],
npm: "@ai-sdk/openai-compatible",
models: {
"acme-1": {
id: "acme-1",
@@ -57,7 +58,7 @@ const fixtureSnapshot = [
info: {
id: ProviderV2.ID.make("acme"),
name: "Acme",
package: "",
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
},
models: [
{
@@ -97,6 +98,7 @@ const fixture2 = {
id: "beta",
name: "Beta",
env: ["BETA_API_KEY"],
npm: "@ai-sdk/openai-compatible",
models: {
"beta-1": {
id: "beta-1",
@@ -117,7 +119,7 @@ const fixture2Snapshot = [
info: {
id: ProviderV2.ID.make("beta"),
name: "Beta",
package: "",
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
},
models: [
{
+60 -2
View File
@@ -43,6 +43,7 @@ const it = testEffect(
EventV2.node,
ProjectDirectories.node,
Project.node,
SessionV2.node,
SessionProjector.node,
SessionStore.node,
]),
@@ -137,7 +138,6 @@ describe("MoveSession", () => {
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.mkdir(destination))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
@@ -164,8 +164,14 @@ describe("MoveSession", () => {
.run()
.pipe(Effect.orDie)
const missing = yield* SessionV2.Service.use((service) =>
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
)
expect(missing._tag).toBe("Session.DestinationNotFoundError")
yield* Effect.promise(() => fs.mkdir(destination))
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: destination }, moveChanges: true }),
service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
@@ -180,6 +186,58 @@ describe("MoveSession", () => {
}),
)
it.live("moves a session to another project", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-other-project`)
yield* Effect.acquireRelease(
Effect.promise(() => fs.mkdir(destination, { recursive: true })),
() => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })),
)
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
const sessionID = SessionV2.ID.make("ses_move_project")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-project",
directory: source,
title: "move project",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* SessionV2.Service.use((service) =>
service.move({ sessionID, directory: destination }),
)
expect(
yield* db
.select({ projectID: SessionTable.project_id, directory: SessionTable.directory })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ projectID: destinationProjectID, directory: destination })
}),
)
it.live("moves nested session changes without cleaning unrelated files", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
@@ -13,7 +13,7 @@
"attachment": false,
"reasoning": true,
"reasoning_options": [
{ "type": "effort", "values": ["low", "high"] },
{ "type": "effort", "values": [null, "null", "low", "high"] },
{ "type": "budget_tokens", "min": 1024, "max": 64000 },
{ "type": "toggle" }
],
@@ -27,6 +27,11 @@
"headers": { "x-mode": "high" },
"body": { "service_tier": "priority" }
}
},
"pro": {
"provider": {
"body": { "reasoning": { "mode": "pro" } }
}
}
}
}
@@ -49,18 +54,296 @@
"reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 64000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
"limit": { "context": 128000, "output": 64000 }
},
"claude-effort": {
"id": "claude-effort",
"id": "claude-opus-4.7",
"name": "Claude Effort",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low"] }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
},
"claude-toggle": {
"id": "claude-toggle",
"name": "Claude Toggle",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
},
"claude-opus-4-5": {
"id": "claude-opus-4-5",
"name": "Claude Opus 4.5",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [
{ "type": "effort", "values": ["low", "high"] },
{ "type": "budget_tokens", "min": 1024 }
],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
}
}
},
"xai": {
"id": "xai",
"name": "xAI",
"env": ["XAI_API_KEY"],
"npm": "@ai-sdk/xai",
"models": {
"grok-4.5": {
"id": "grok-4.5",
"name": "Grok 4.5",
"release_date": "2026-07-08",
"attachment": true,
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["low", "medium", "high"] }],
"temperature": true,
"tool_call": true,
"limit": { "context": 500000, "output": 500000 }
}
}
},
"opencode-go": {
"id": "opencode-go",
"name": "OpenCode Go",
"env": ["OPENCODE_API_KEY"],
"npm": "@ai-sdk/openai-compatible",
"models": {
"minimax-m3": {
"id": "minimax-m3",
"name": "MiniMax-M3",
"release_date": "2026-05-31",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }],
"temperature": true,
"tool_call": true,
"limit": { "context": 1000000, "output": 131072 },
"provider": { "npm": "@ai-sdk/anthropic" }
}
}
},
"alibaba": {
"id": "alibaba",
"name": "Alibaba",
"env": ["ALIBABA_API_KEY"],
"npm": "@ai-sdk/alibaba",
"models": {
"toggle-only": {
"id": "toggle-only",
"name": "Toggle Only",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
},
"toggle-budget": {
"id": "toggle-budget",
"name": "Toggle Budget",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "max": 16000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
}
}
},
"vercel": {
"id": "vercel",
"name": "Vercel AI Gateway",
"env": ["AI_GATEWAY_API_KEY"],
"npm": "@ai-sdk/gateway",
"models": {
"alibaba/qwen-toggle": {
"id": "alibaba/qwen-toggle",
"name": "Gateway Alibaba Toggle",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "max": 16000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
},
"amazon/nova-2-lite": {
"id": "amazon/nova-2-lite",
"name": "Gateway Nova 2 Lite",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
},
"deepseek/deepseek-toggle": {
"id": "deepseek/deepseek-toggle",
"name": "Gateway DeepSeek Toggle",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
}
}
},
"openrouter": {
"id": "openrouter",
"name": "OpenRouter",
"env": ["OPENROUTER_API_KEY"],
"npm": "@openrouter/ai-sdk-provider",
"models": {
"openrouter-toggle": {
"id": "openrouter-toggle",
"name": "OpenRouter Toggle",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
}
}
},
"google": {
"id": "google",
"name": "Google",
"env": ["GOOGLE_GENERATIVE_AI_API_KEY"],
"npm": "@ai-sdk/google",
"models": {
"gemini-2.5-flash": {
"id": "gemini-2.5-flash",
"name": "Gemini 2.5 Flash",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 0, "max": 16000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
}
}
},
"google-vertex": {
"id": "google-vertex",
"name": "Google Vertex",
"env": ["GOOGLE_VERTEX_PROJECT"],
"npm": "@ai-sdk/google-vertex",
"models": {
"gemini-2.5-flash-lite": {
"id": "gemini-2.5-flash-lite",
"name": "Gemini 2.5 Flash Lite",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 512, "max": 16000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
}
}
},
"amazon-bedrock": {
"id": "amazon-bedrock",
"name": "Amazon Bedrock",
"env": ["AWS_ACCESS_KEY_ID"],
"npm": "@ai-sdk/amazon-bedrock",
"models": {
"amazon.nova-2-lite-v1:0": {
"id": "amazon.nova-2-lite-v1:0",
"name": "Nova 2 Lite",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
}
}
},
"sap-ai-core": {
"id": "sap-ai-core",
"name": "SAP AI Core",
"env": ["AICORE_SERVICE_KEY"],
"npm": "@jerome-benoit/sap-ai-provider-v2",
"models": {
"gemini-2.5-flash": {
"id": "gemini-2.5-flash",
"name": "Gemini 2.5 Flash",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 0, "max": 16000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
},
"amazon--nova-lite": {
"id": "amazon--nova-lite",
"name": "Nova Lite",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
},
"cohere--command-a-reasoning": {
"id": "cohere--command-a-reasoning",
"name": "Command A Reasoning",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [
{ "type": "toggle" },
{ "type": "effort", "values": ["low", "high"] },
{ "type": "budget_tokens", "min": 1 }
],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
},
"anthropic--claude-4.7-opus": {
"id": "anthropic--claude-4.7-opus",
"name": "Claude 4.7 Opus",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["low"] }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
"limit": { "context": 128000, "output": 20000 }
},
"anthropic--claude-4-sonnet": {
"id": "anthropic--claude-4-sonnet",
"name": "Claude 4 Sonnet",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 16000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 20000 }
}
}
}
+287 -6
View File
@@ -292,6 +292,12 @@ describe("ModelsDevPlugin", () => {
ModelV2.VariantID.make("high"),
])
const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro"))
expect(pro).toMatchObject({
id: "gpt-reasoning-pro",
body: { reasoning: { mode: "pro" } },
})
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
@@ -299,17 +305,292 @@ describe("ModelsDevPlugin", () => {
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 64000 } },
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* catalog.model.get(
ProviderV2.ID.anthropic,
ModelV2.ID.make("claude-effort"),
ModelV2.ID.make("claude-opus-4.7"),
)
expect(anthropicEffortModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
})
expect(anthropicEffortModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
])
const anthropicToggleModel = yield* catalog.model.get(
ProviderV2.ID.anthropic,
ModelV2.ID.make("claude-toggle"),
)
expect(anthropicToggleModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5"))
expect(opus45?.variants).toEqual([
{ id: ModelV2.VariantID.make("low"), settings: { effort: "low" } },
{ id: ModelV2.VariantID.make("high"), settings: { effort: "high" } },
])
const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5"))
expect(grok?.variants).toEqual(
["low", "medium", "high"].map((id) => ({
id: ModelV2.VariantID.make(id),
settings: { reasoningEffort: id },
})),
)
const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3"))
expect(minimax?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only"))
expect(toggle?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } },
])
const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget"))
expect(combined?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle"))
expect(gateway?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gatewayNova = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("amazon/nova-2-lite"),
)
expect(gatewayNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const gatewayFallback = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("deepseek/deepseek-toggle"),
)
expect(gatewayFallback?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { reasoning: { enabled: false } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningEffort: "low" },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningEffort: "high" },
},
])
const openrouter = yield* catalog.model.get(
ProviderV2.ID.make("openrouter"),
ModelV2.ID.make("openrouter-toggle"),
)
expect(openrouter?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
])
const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash"))
expect(google?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const vertex = yield* catalog.model.get(
ProviderV2.ID.make("google-vertex"),
ModelV2.ID.make("gemini-2.5-flash-lite"),
)
expect(vertex?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const bedrock = yield* catalog.model.get(
ProviderV2.ID.make("amazon-bedrock"),
ModelV2.ID.make("amazon.nova-2-lite-v1:0"),
)
expect(bedrock?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const sapGemini = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("gemini-2.5-flash"),
)
expect(sapGemini?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
},
])
const sapNova = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("amazon--nova-lite"),
)
expect(sapNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: {
modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
},
},
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } },
},
},
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } },
},
},
])
const sapCohere = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("cohere--command-a-reasoning"),
)
expect(sapCohere?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinking: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { modelParams: { reasoning_effort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { reasoning_effort: "high" } },
},
])
const sapAnthropicEffort = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4.7-opus"),
)
expect(sapAnthropicEffort?.variants).toEqual([
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
},
},
},
},
])
const sapAnthropicBudget = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4-sonnet"),
)
expect(sapAnthropicBudget?.variants).toEqual([
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } },
},
},
},
{
id: ModelV2.VariantID.make("max"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } },
},
},
},
])
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
+23 -5
View File
@@ -2,6 +2,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -14,7 +15,6 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* XAIPlugin.effect(host)
})
@@ -33,9 +33,30 @@ function fakeSelectorSdk(calls: string[]) {
}
describe("XAIPlugin", () => {
it.effect("registers browser OAuth, device OAuth, and API key methods", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const integration = yield* integrations.get(Integration.ID.make("xai"))
expect(integration?.name).toBe("xAI")
expect(integration?.methods).toEqual([
{
id: Integration.MethodID.make("browser"),
type: "oauth",
label: "xAI Grok OAuth (SuperGrok Subscription)",
},
{
id: Integration.MethodID.make("device"),
type: "oauth",
label: "xAI Grok OAuth (Headless / Remote / VPS)",
},
{ type: "key", label: "Manually enter API Key" },
])
}),
)
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -66,7 +87,6 @@ describe("XAIPlugin", () => {
it.effect("creates xAI SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -86,7 +106,6 @@ describe("XAIPlugin", () => {
it.effect("uses responses with the model modelID for xAI language models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
@@ -108,7 +127,6 @@ describe("XAIPlugin", () => {
it.effect("ignores non-xAI providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
@@ -18,6 +18,8 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
@@ -104,6 +106,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
const events = yield* EventV2.Service
const store = yield* SessionStore.Service
const sessionID = SessionV2.ID.make("ses_manual_compaction")
const parentID = SessionV2.ID.make("ses_manual_compaction_parent")
const userMessage = {
id: SessionMessage.ID.create(),
type: "user" as const,
@@ -121,6 +124,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
.values({
id: sessionID,
project_id: Project.ID.global,
parent_id: parentID,
slug: "manual-compaction",
directory: "/project",
title: "Manual compaction",
@@ -151,6 +155,15 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
expect(requests).toHaveLength(1)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"x-parent-session-id": parentID,
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT,
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(yield* store.context(sessionID)).toMatchObject([
+23
View File
@@ -20,6 +20,7 @@ import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project"
@@ -3086,6 +3087,9 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT,
@@ -3093,6 +3097,25 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("adds the parent session header to child model requests", () =>
Effect.gen(function* () {
const session = yield* setup
const parentID = SessionV2.ID.make("ses_runner_parent")
const { db } = yield* Database.Service
yield* db
.update(SessionTable)
.set({ parent_id: parentID })
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
yield* admit(session, "Run child request")
yield* session.resume(sessionID)
expect(requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
}),
)
it.effect("runs different sessions concurrently", () =>
Effect.gen(function* () {
const session = yield* setup
+10
View File
@@ -17,6 +17,8 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
@@ -117,6 +119,14 @@ it.effect("generates a title from the sole user message and renames the session"
yield* title.generateForFirstPrompt(session)
expect(requests).toHaveLength(1)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT,
})
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID)
expect(renamed?.title).toBe("Generated Title")
+33 -1
View File
@@ -166,6 +166,10 @@ const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const progressOverflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
@@ -413,6 +417,35 @@ describe("ShellTool", () => {
),
)
it.live("reports bounded output progress for a running command", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const progress: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
progress: (update) => Effect.sync(() => progress.push(update)),
})
expect(progress).toHaveLength(1)
expect(progress[0]?.structured).toEqual({ truncated: true })
const content = progress[0]?.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
ShellTool.MAX_CAPTURE_BYTES,
)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -572,7 +605,6 @@ test("keeps locked deferred parity TODOs visible", async () => {
"Replace token-based command-argument external-directory advisories with parser-based detection.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
"Persist job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.",
-4
View File
@@ -115,10 +115,6 @@ Node application:
a process.
- `Service.start()` reuses a compatible service or starts one when needed.
- `Service.stop()` stops the registered service.
- `Service.restart()` explicitly replaces the registered service, including
signaling an unchanged registered PID that no longer answers health checks.
Reserve it for deliberate user recovery; automatic reconnect should use
`Service.start()` to avoid the narrow stale-PID reuse risk.
- `Service.headers(endpoint)` creates the authentication headers for a client.
```sh
+1 -1
View File
@@ -438,7 +438,7 @@ type TuiConfigView = {
markdown?: "source" | "rendered"
grouping?: "auto" | "none"
}
hints?: { tips?: boolean; onboarding?: boolean }
hints?: { onboarding?: boolean }
animations?: boolean
mouse: boolean
keybinds: TuiBindingLookupView
+6 -2
View File
@@ -134,9 +134,13 @@ export interface KeymapCommand {
readonly slash?: {
readonly name: string
readonly aliases?: string[]
/** Keeps the slash command in the prompt and passes its raw input to run. */
readonly arguments?: true
}
/** Promotes the command in discovery UI. */
readonly suggested?: boolean | (() => boolean)
/** Executes the command. Return false to let keymap dispatch continue. */
readonly run: () => void | false | Promise<void>
readonly run: (input?: string) => void | false | Promise<void>
}
export interface KeymapLayer {
@@ -158,7 +162,7 @@ export interface Keymap {
/** Creates a reactive keymap layer owned by the calling component. */
layer(input: () => KeymapLayer): void
/** Dispatches a reachable command by ID. */
dispatch(id: string): void
dispatch(id: string, input?: string): void
/** Returns the formatted shortcut for a registered command. */
shortcut(id: string): string | undefined
/** Controls mutually exclusive OpenCode input modes. */
+1 -4
View File
@@ -272,10 +272,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
params: { sessionID: Session.ID },
payload: Schema.Struct({
destination: Schema.Struct({ directory: AbsolutePath }),
moveChanges: Schema.Boolean.pipe(Schema.optional),
}),
payload: Location.Ref,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, InvalidRequestError],
})
+2
View File
@@ -21,6 +21,7 @@ import { Money } from "./money.js"
import { Snapshot } from "./snapshot.js"
import { TokenUsage } from "./token-usage.js"
import { SessionPending } from "./session-pending.js"
import { Project } from "./project.js"
export { FileAttachment }
@@ -69,6 +70,7 @@ export const Moved = Event.durable({
schema: {
...Base,
location: Location.Ref,
projectID: Project.ID.pipe(optional),
subpath: RelativePath.pipe(optional),
},
})
+30 -7
View File
@@ -179,6 +179,7 @@ import type {
QuestionReplyErrors,
QuestionReplyResponses,
QuestionV2Reply,
ServiceStopRequestV2,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
@@ -291,6 +292,8 @@ import type {
V2GenerateTextResponses,
V2HealthGetErrors,
V2HealthGetResponses,
V2HealthStopErrors,
V2HealthStopResponses,
V2IntegrationAttemptCancelErrors,
V2IntegrationAttemptCancelResponses,
V2IntegrationAttemptCompleteErrors,
@@ -5072,7 +5075,7 @@ export class Health extends HeyApiClient {
/**
* Check server health
*
* Check whether the API server is ready to accept requests.
* Report the owning server process and its application status.
*/
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<V2HealthGetResponses, V2HealthGetErrors, ThrowOnError>({
@@ -5080,6 +5083,30 @@ export class Health extends HeyApiClient {
...options,
})
}
/**
* Stop the managed server
*
* Request graceful shutdown of one exact managed server instance.
*/
public stop<ThrowOnError extends boolean = false>(
parameters: {
serviceStopRequestV2: ServiceStopRequestV2
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequestV2", map: "body" }] }])
return (options?.client ?? this.client).post<V2HealthStopResponses, V2HealthStopErrors, ThrowOnError>({
url: "/api/service/stop",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Server extends HeyApiClient {
@@ -6117,10 +6144,7 @@ export class Session3 extends HeyApiClient {
public move<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
destination?: {
directory: string
}
moveChanges?: boolean | null
locationRefV2: LocationRefV2
},
options?: Options<never, ThrowOnError>,
) {
@@ -6130,8 +6154,7 @@ export class Session3 extends HeyApiClient {
{
args: [
{ in: "path", key: "sessionID" },
{ in: "body", key: "destination" },
{ in: "body", key: "moveChanges" },
{ key: "locationRefV2", map: "body" },
],
},
],
+177 -106
View File
@@ -598,24 +598,6 @@ export type Part =
| RetryPart
| CompactionPart
export type Shell = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
}
export type Pty = {
id: string
title: string
@@ -803,6 +785,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
location: LocationRef
projectID?: string
subpath?: string
}
}
@@ -917,7 +900,7 @@ export type GlobalEvent = {
type: "session.shell.started"
properties: {
sessionID: string
shell: Shell
shell: ShellInfo
}
}
| {
@@ -925,7 +908,7 @@ export type GlobalEvent = {
type: "session.shell.ended"
properties: {
sessionID: string
shell: Shell
shell: ShellInfo
output: {
output: string
cursor: number
@@ -1358,7 +1341,7 @@ export type GlobalEvent = {
id: string
type: "shell.created"
properties: {
info: Shell
info: ShellInfo
}
}
| {
@@ -1366,7 +1349,7 @@ export type GlobalEvent = {
type: "shell.exited"
properties: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -2812,11 +2795,45 @@ export type WorkspaceWarpError = {
}
}
export type ServiceStatus =
| {
type: "starting"
}
| {
type: "ready"
}
| {
type: "stopping"
targetVersion?: string
}
| {
type: "failed"
message: string
action: string
}
export type ServiceHealth = {
healthy: true
version: string
pid: number
instanceID?: string
status?: ServiceStatus
}
export type UnauthorizedError = {
_tag: "UnauthorizedError"
message: string
}
export type ServiceStopRequest = {
instanceID: string
targetVersion?: string
}
export type ServiceStopResponse = {
accepted: boolean
}
export type SessionsResponse = {
data: Array<SessionInfo>
cursor: {
@@ -2890,24 +2907,6 @@ export type InstructionEntryValueTooLargeError = {
message: string
}
export type Shell1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionLogItemStream = string
@@ -3154,24 +3153,6 @@ export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type Shell2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type EventTuiPromptAppend2 = {
id: string
type: "tui.prompt.append"
@@ -3398,6 +3379,24 @@ export type SessionStructuredError = {
message: string
}
export type ShellInfo = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number
metadata: {
[key: string]: unknown
}
time: {
started: number
completed?: number
}
}
export type SessionMessageProviderState = {
[key: string]: unknown
}
@@ -3773,6 +3772,7 @@ export type SyncEventSessionMoved = {
data: {
sessionID: string
location: LocationRef
projectID?: string
subpath?: string
}
}
@@ -3962,7 +3962,7 @@ export type SyncEventSessionShellStarted = {
aggregateID: string
data: {
sessionID: string
shell: Shell
shell: ShellInfo
}
}
}
@@ -3977,7 +3977,7 @@ export type SyncEventSessionShellEnded = {
aggregateID: string
data: {
sessionID: string
shell: Shell
shell: ShellInfo
output: {
output: string
cursor: number
@@ -4828,6 +4828,7 @@ export type SessionMoved = {
data: {
sessionID: string
location: LocationRef
projectID?: string
subpath?: string
}
}
@@ -5083,7 +5084,7 @@ export type SessionShellStarted = {
location?: LocationRef
data: {
sessionID: string
shell: Shell1
shell: ShellInfo
}
}
@@ -5102,7 +5103,7 @@ export type SessionShellEnded = {
location?: LocationRef
data: {
sessionID: string
shell: Shell1
shell: ShellInfo
output: {
output: string
cursor: number
@@ -6461,7 +6462,7 @@ export type ShellCreated = {
type: "shell.created"
location?: LocationRef
data: {
info: Shell1
info: ShellInfo
}
}
@@ -6475,7 +6476,7 @@ export type ShellExited = {
location?: LocationRef
data: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -7158,6 +7159,7 @@ export type EventSessionMoved = {
properties: {
sessionID: string
location: LocationRef
projectID?: string
subpath?: string
}
}
@@ -7285,7 +7287,7 @@ export type EventSessionShellStarted = {
type: "session.shell.started"
properties: {
sessionID: string
shell: Shell2
shell: ShellInfo
}
}
@@ -7294,7 +7296,7 @@ export type EventSessionShellEnded = {
type: "session.shell.ended"
properties: {
sessionID: string
shell: Shell2
shell: ShellInfo
output: {
output: string
cursor: number
@@ -7772,7 +7774,7 @@ export type EventShellCreated = {
id: string
type: "shell.created"
properties: {
info: Shell2
info: ShellInfo
}
}
@@ -7781,7 +7783,7 @@ export type EventShellExited = {
type: "shell.exited"
properties: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -8139,6 +8141,31 @@ export type BadRequestError = {
}
}
export type ServiceStatusV2 =
| {
type: "starting"
}
| {
type: "ready"
}
| {
type: "stopping"
targetVersion?: string | null
}
| {
type: "failed"
message: string
action: string
}
export type ServiceHealthV2 = {
healthy: true
version: string
pid: number
instanceID?: string | null
status?: ServiceStatusV2
}
export type InvalidRequestErrorV2 = {
_tag: "InvalidRequestError"
message: string
@@ -8146,6 +8173,11 @@ export type InvalidRequestErrorV2 = {
field?: string | null
}
export type ServiceStopRequestV2 = {
instanceID: string
targetVersion?: string | null
}
export type SessionsResponseV2 = {
data: Array<SessionInfoV2>
cursor: {
@@ -8179,24 +8211,6 @@ export type UnknownErrorV2 = {
ref?: string | null
}
export type ShellV2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type SessionMessagesResponseV2 = {
data: Array<SessionMessageInfo>
cursor: {
@@ -9064,6 +9078,7 @@ export type SessionMovedV2 = {
data: {
sessionID: string
location: LocationRefV2
projectID?: string
subpath?: string
}
}
@@ -9333,6 +9348,24 @@ export type SessionSkillActivatedV2 = {
}
}
export type ShellInfoV2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number
metadata: {
[key: string]: unknown
}
time: {
started: number
completed?: number
}
}
export type SessionShellStartedV2 = {
id: string
created: number
@@ -9348,7 +9381,7 @@ export type SessionShellStartedV2 = {
location?: LocationRefV2
data: {
sessionID: string
shell: ShellV2
shell: ShellInfoV2
}
}
@@ -9367,7 +9400,7 @@ export type SessionShellEndedV2 = {
location?: LocationRefV2
data: {
sessionID: string
shell: ShellV2
shell: ShellInfoV2
output: {
output: string
cursor: number
@@ -10514,7 +10547,7 @@ export type ShellCreatedV2 = {
type: "shell.created"
location?: LocationRefV2
data: {
info: ShellV2
info: ShellInfoV2
}
}
@@ -10528,7 +10561,7 @@ export type ShellExitedV2 = {
location?: LocationRefV2
data: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -10986,6 +11019,24 @@ export type PtyTicketConnectTokenV2 = {
expires_in: number
}
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number
metadata: {
[key: string]: unknown
}
time: {
started: number
completed?: number
}
}
export type QuestionV2RequestV2 = {
id: string
sessionID: string
@@ -15124,17 +15175,42 @@ export type V2HealthGetError = V2HealthGetErrors[keyof V2HealthGetErrors]
export type V2HealthGetResponses = {
/**
* Success
* ServiceHealth
*/
200: {
healthy: true
version: string
pid: number
}
200: ServiceHealthV2
}
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
export type V2HealthStopData = {
body: ServiceStopRequestV2
path?: never
query?: never
url: "/api/service/stop"
}
export type V2HealthStopErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2HealthStopError = V2HealthStopErrors[keyof V2HealthStopErrors]
export type V2HealthStopResponses = {
/**
* ServiceStopResponse
*/
200: ServiceStopResponse
}
export type V2HealthStopResponse = V2HealthStopResponses[keyof V2HealthStopResponses]
export type V2ServerGetData = {
body?: never
path?: never
@@ -15611,12 +15687,7 @@ export type V2SessionRenameResponses = {
export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses]
export type V2SessionMoveData = {
body: {
destination: {
directory: string
}
moveChanges?: boolean | null
}
body: LocationRefV2
path: {
sessionID: string
}
@@ -18318,7 +18389,7 @@ export type V2ShellListResponses = {
*/
200: {
location: LocationInfoV2
data: Array<ShellV2>
data: Array<ShellInfo1>
}
}
@@ -18362,7 +18433,7 @@ export type V2ShellCreateResponses = {
*/
200: {
location: LocationInfoV2
data: ShellV2
data: ShellInfo1
}
}
@@ -18445,7 +18516,7 @@ export type V2ShellGetResponses = {
*/
200: {
location: LocationInfoV2
data: ShellV2
data: ShellInfo1
}
}
@@ -18490,7 +18561,7 @@ export type V2ShellTimeoutResponses = {
*/
200: {
location: LocationInfoV2
data: ShellV2
data: ShellInfo1
}
}
+8 -21
View File
@@ -1,6 +1,5 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
@@ -25,7 +24,6 @@ const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const moveSession = yield* MoveSession.Service
return handlers
.handle(
@@ -206,11 +204,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.move",
Effect.fn(function* (ctx) {
yield* moveSession
.moveSession({
yield* session
.move({
sessionID: ctx.params.sessionID,
destination: ctx.payload.destination,
moveChanges: ctx.payload.moveChanges,
directory: ctx.payload.directory,
workspaceID: ctx.payload.workspaceID,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
@@ -221,22 +219,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
),
),
Effect.catchTag("MoveSession.DestinationProjectMismatchError", () =>
Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })),
Effect.catchTag("Session.DestinationNotFoundError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Directory does not exist: ${error.directory}` })),
),
Effect.catchTag("MoveSession.ApplyChangesError", () =>
Effect.fail(
new InvalidRequestError({
message:
"Unable to apply your changes in the destination directory. The files may conflict with existing changes.",
}),
),
),
Effect.catchTag("MoveSession.CaptureChangesError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
Effect.catchTag("MoveSession.ResetSourceChangesError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
Effect.catchTag("Session.DestinationNotDirectoryError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Not a directory: ${error.directory}` })),
),
)
return HttpApiSchema.NoContent.make()
-2
View File
@@ -8,7 +8,6 @@ import { Observability } from "@opencode-ai/core/observability"
import { Credential } from "@opencode-ai/core/credential"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Project } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { Job } from "@opencode-ai/core/job"
@@ -38,7 +37,6 @@ const applicationServices = LayerNode.group([
httpClient,
ToolOutputStore.cleanupNode,
Job.node,
MoveSession.node,
Project.node,
SessionV2.node,
PluginRuntime.providerNode,
@@ -110,6 +110,25 @@ export function matches(harness: Pick<Harness, "screen">, text: string) {
return harness.screen().includes(text)
}
export const capture = Effect.fn("SimulationActions.capture")(function* (harness: Harness) {
yield* Effect.tryPromise(() => harness.renderOnce())
const buffer = harness.renderer.currentRenderBuffer
return {
cols: buffer.width,
rows: buffer.height,
cursor: [0, 0] as const,
lines: buffer.getSpanLines().map((line) => ({
spans: line.spans.map((span) => ({
text: span.text,
fg: span.fg.toInts(),
bg: span.bg.toInts(),
attributes: span.attributes,
width: span.width,
})),
})),
} satisfies SimulationProtocol.Frontend.CapturedFrame
})
export const screenshot = Effect.fn("SimulationActions.screenshot")(function* (harness: Harness, name?: string) {
const filename = name ?? `screenshot-${crypto.randomUUID()}`
if (!filename || filename.includes("/") || filename.includes("\\") || extname(filename))
+2
View File
@@ -90,6 +90,8 @@ function drawBlockElement(context: SKRSContext2D, char: string, x: number, y: nu
if (char === "█") context.fillRect(x, y, width, CellHeight)
else if (char === "▀") context.fillRect(x, y, width, CellHeight / 2)
else if (char === "▄") context.fillRect(x, y + CellHeight / 2, width, CellHeight / 2)
else if (char === "┃") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight)
else if (char === "╹") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight / 2)
else return false
return true
}
@@ -6,6 +6,8 @@ import { SimulationRenderer } from "./renderer"
function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) {
switch (request.method) {
case "ui.capture":
return SimulationActions.capture(harness)
case "ui.screenshot":
return SimulationActions.screenshot(harness, request.params?.name)
case "ui.state":
+24
View File
@@ -95,6 +95,29 @@ export namespace Frontend {
export const Screenshot = Schema.String
export type Screenshot = Schema.Schema.Type<typeof Screenshot>
export const Color = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])
export type Color = Schema.Schema.Type<typeof Color>
export const CapturedFrame = Schema.Struct({
cols: Schema.Number,
rows: Schema.Number,
cursor: Schema.Tuple([Schema.Number, Schema.Number]),
lines: Schema.Array(
Schema.Struct({
spans: Schema.Array(
Schema.Struct({
text: Schema.String,
fg: Color,
bg: Color,
attributes: Schema.Number,
width: Schema.Number,
}),
),
}),
),
})
export interface CapturedFrame extends Schema.Schema.Type<typeof CapturedFrame> {}
export const RecordingFinish = Schema.String
export type RecordingFinish = Schema.Schema.Type<typeof RecordingFinish>
@@ -142,6 +165,7 @@ export namespace Frontend {
...JsonRpc.RequestFields,
method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]),
}),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.capture") }),
])
export type Request = Schema.Schema.Type<typeof Request>
export const decodeRequest = Schema.decodeUnknownSync(Request)
@@ -25,6 +25,17 @@ test("scopes the frontend control server and reports malformed JSON", async () =
result: { focused: { editor: false }, elements: [] },
})
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "ui.capture" }))
expect(yield* Queue.take(messages)).toMatchObject({
id: 2,
result: {
cols: 100,
rows: 40,
cursor: [0, 0],
lines: expect.any(Array),
},
})
socket.send("{")
expect(yield* Queue.take(messages)).toMatchObject({
id: null,
+42
View File
@@ -59,3 +59,45 @@ test("fills adjacent block elements without glyph gaps", async () => {
Array.from({ length: image.width }, () => [0, 0, 0, 255]).flat(),
)
})
test("draws heavy vertical box elements on cell boundaries", async () => {
const image = SimulationPng.screenshotFrame({
cols: 1,
rows: 2,
cursor: [0, 0],
lines: [
{
spans: [
{
text: "┃",
width: 1,
fg: RGBA.fromInts(255, 255, 255),
bg: RGBA.fromInts(0, 0, 0),
attributes: 0,
},
],
},
{
spans: [
{
text: "╹",
width: 1,
fg: RGBA.fromInts(255, 255, 255),
bg: RGBA.fromInts(0, 0, 0),
attributes: 0,
},
],
},
],
})
const canvas = createCanvas(image.width, image.height)
const context = canvas.getContext("2d")
context.drawImage(await loadImage(image.data), 0, 0)
expect([...context.getImageData(4, 0, 2, 30).data]).toEqual(
Array.from({ length: 60 }, () => [255, 255, 255, 255]).flat(),
)
expect([...context.getImageData(4, 30, 2, 10).data]).toEqual(
Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat(),
)
})
-1
View File
@@ -20,7 +20,6 @@
"./context/epilogue": "./src/context/epilogue.tsx",
"./context/exit": "./src/context/exit.tsx",
"./context/log": "./src/context/log.tsx",
"./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx",
"./context/client": "./src/context/client.tsx",
"./context/theme": "./src/context/theme.tsx",
+42 -50
View File
@@ -42,14 +42,13 @@ import { DialogProvider, useDialog } from "./ui/dialog"
import { DialogIntegration } from "./component/dialog-integration"
import { ErrorComponent } from "./component/error-component"
import { PluginRouteMissing } from "./component/plugin-route-missing"
import { ProjectProvider, useProject } from "./context/project"
import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event"
import { ClientProvider, useClient } from "./context/client"
import { StartupLoading } from "./component/startup-loading"
import { Reconnecting } from "./component/reconnecting"
import { DataProvider, useData } from "./context/data"
import { LocationProvider } from "./context/location"
import { LocationProvider, useLocation } from "./context/location"
import { LocalProvider, useLocal } from "./context/local"
import { PermissionProvider } from "./context/permission"
import { DialogModel } from "./component/dialog-model"
@@ -69,7 +68,7 @@ import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { ToastProvider, useToast } from "./ui/toast"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isDefaultTitle } from "./util/session"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
@@ -140,7 +139,7 @@ export type TuiInput = {
server: {
endpoint: Service.Endpoint
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
reload?: (signal?: AbortSignal) => Promise<void>
reload?: () => Promise<void>
}
args: Args
config: Config.Interface
@@ -327,40 +326,38 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}>
<PermissionProvider>
<ProjectProvider>
<DataProvider>
<LocationProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ProjectProvider>
<DataProvider>
<LocationProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
@@ -413,7 +410,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
const data = useData()
const project = useProject()
const location = useLocation()
const exit = useExit()
const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime()
@@ -988,12 +985,12 @@ function App(props: { pair?: DialogPairCredentials }) {
}))
event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
keymap.dispatchCommand(evt.data.command)
})
event.on("tui.toast.show", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
toast.show({
title: evt.data.title,
message: evt.data.message,
@@ -1002,14 +999,8 @@ function App(props: { pair?: DialogPairCredentials }) {
})
})
event.on("plugin.updated", (_evt, { directory, workspace }) => {
if (directory !== project.instance.directory()) return
if (workspace !== project.workspace.current()) return
toast.show({ variant: "success", message: "Plugins reloaded" })
})
event.on("tui.session.select", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
route.navigate({
type: "session",
sessionID: evt.data.sessionID,
@@ -1027,7 +1018,7 @@ function App(props: { pair?: DialogPairCredentials }) {
})
event.on("session.error", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
const error = evt.data.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error)
@@ -1117,8 +1108,9 @@ function App(props: { pair?: DialogPairCredentials }) {
<StartupLoading ready={plugins.ready} />
</Show>
<Show when={showReconnecting()}>
<Reconnecting status={client.connection.service()} restart={client.reload} />
<Reconnecting status={client.connection.service()} />
</Show>
<Toast />
</box>
)
}
+21 -49
View File
@@ -1,63 +1,35 @@
import { createMemo } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { type DialogContext } from "../ui/dialog"
import {
COMMAND_PALETTE_COMMAND,
formatKeyBindings,
type OpenTuiKeymap,
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useConfig } from "../config"
import { COMMAND_PALETTE_COMMAND } from "../keymap"
import { Keymap, type KeymapCommand } from "../context/keymap"
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
function isVisiblePaletteCommand(command: PaletteCommandEntry["command"]) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
const suggested = entry.command.suggested
function isSuggestedPaletteCommand(command: KeymapCommand) {
const suggested = command.suggested
if (typeof suggested === "boolean") return suggested
if (typeof suggested === "function") return suggested() === true
return false
}
export function CommandPaletteDialog() {
const config = useConfig().data
const keymap = useOpencodeKeymap()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
const query = {
namespace: "palette",
}
const reachable = keymap.getCommandEntries({
...query,
visibility: "reachable",
filter: isVisiblePaletteCommand,
})
const registeredBindings = keymap.getCommandBindings({
visibility: "registered",
commands: reachable.map((entry) => entry.command.name),
})
return reachable.map((entry) => ({
...entry,
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
}))
})
const commands = Keymap.useCommands()
const shortcuts = Keymap.useShortcuts()
const options = createMemo(() =>
entries().map((entry) => ({
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
footer: formatKeyBindings(entry.bindings, config),
value: entry.command.name,
suggested: isSuggestedPaletteCommand(entry),
onSelect: (dialog: DialogContext) => {
dialog.clear()
keymap.dispatchCommand(entry.command.name)
},
})),
commands().flatMap((command) => {
if (!command.id || !command.palette || command.id === COMMAND_PALETTE_COMMAND) return []
return {
title: command.title ?? command.id,
description: command.description,
category: command.group,
footer: shortcuts.all(command.id),
value: command.id,
suggested: isSuggestedPaletteCommand(command),
onSelect: (dialog: DialogContext) => {
dialog.clear()
command.run()
},
}
}),
)
let ref: DialogSelectRef<string>
@@ -39,14 +39,6 @@ const settings: Setting[] = [
values: [false, true],
labels: ["off", "on"],
},
{
title: "Tips",
category: "Appearance",
path: ["hints", "tips"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Onboarding",
category: "Appearance",
@@ -14,7 +14,6 @@ import { Locale } from "../util/locale"
import { errorMessage } from "../util/error"
import { isRecord } from "../util/record"
import { useToast } from "../ui/toast"
import { useProject } from "../context/project"
import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
@@ -41,11 +40,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const sessionData = useData()
const projectContext = useProject()
const route = useRoute()
const toast = useToast()
const paths = useTuiPaths()
const shortcuts = Keymap.useShortcuts()
const location = createMemo(() => sessionData.location.info())
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving)
@@ -63,15 +62,15 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
// swallow it and let the directory list render without a current marker.
// Once the current project is known, a mismatch is a guaranteed miss.
const [loadedProject] = createResource(
() => (projectContext.project() === undefined ? props.projectID : undefined),
() => (location()?.project.id === props.projectID ? undefined : props.projectID),
(projectID) =>
client.api.project
.current({ location: { directory: projectContext.instance.directory() || paths.cwd } })
.current({ location: { directory: location()?.directory || paths.cwd } })
.then((project) => (project.id === projectID ? project.directory : undefined))
.catch(() => undefined),
)
const currentCheckout = createMemo(() => {
if (projectContext.project() === props.projectID) return projectContext.instance.path().worktree
if (location()?.project.id === props.projectID) return location()?.project.directory
return loadedProject()
})
@@ -79,14 +78,14 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
() => (props.initialRemoving ? undefined : props.projectID),
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
try {
const location = { directory: projectContext.instance.directory() || paths.cwd }
const requestLocation = { directory: location()?.directory || paths.cwd }
await client.api.projectCopy.refresh({
projectID,
location,
location: requestLocation,
})
const directories = await client.api.project.directories({
projectID,
location,
location: requestLocation,
})
setLoadError(undefined)
return directories
@@ -204,7 +203,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
async function removedCurrent(current: boolean) {
if (!current) return false
const fallback = projectContext.data.project.mainDir
const fallback = directoryData()?.findLast((item) => item.strategy === undefined)?.directory
if (fallback) setReplacementCurrent(fallback)
if (route.data.type === "session") {
route.navigate({ type: "home" })
@@ -236,7 +235,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const error = await client.api.projectCopy
.remove({
projectID: props.projectID,
location: { directory: projectContext.instance.directory() || paths.cwd },
location: { directory: location()?.directory || paths.cwd },
directory: selected.directory,
force: false,
})
@@ -263,7 +262,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const forcedError = await client.api.projectCopy
.remove({
projectID: props.projectID,
location: { directory: projectContext.instance.directory() || paths.cwd },
location: { directory: location()?.directory || paths.cwd },
directory: selected.directory,
force: true,
})
@@ -7,7 +7,6 @@ import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { useProject } from "../context/project"
import { useTheme } from "../context/theme"
import { useClient } from "../context/client"
import { useLocal } from "../context/local"
@@ -21,7 +20,6 @@ export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const data = useData()
const project = useProject()
const { theme } = useTheme()
const client = useClient()
const local = useLocal()
@@ -33,15 +31,16 @@ export function DialogSessionList() {
const [searchResults] = createResource(search, async (query) => {
if (!query) return
const location = data.location.default()
try {
if (!data.location.info()) await data.location.sync()
const current = data.location.info()
if (!current) throw new Error("Location unavailable")
const response = await client.api.session.list({
project: current.project.id,
search: query,
limit: 50,
order: "desc",
parentID: null,
directory: location.directory,
workspace: location.workspaceID,
})
return { query, sessions: response.data, error: undefined }
} catch (error) {
@@ -101,7 +100,8 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const footer =
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
const slot = slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
+6 -3
View File
@@ -1,14 +1,14 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useProject } from "../context/project"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const client = useClient()
const dialog = useDialog()
const project = useProject()
const data = useData()
const [store] = createStore({
filter: "",
@@ -22,7 +22,10 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
query: store.filter,
type: "file",
limit: 5,
location: { workspace: project.workspace.current() },
location: {
directory: data.location.default().directory,
workspace: data.location.default().workspaceID,
},
})
.catch(() => undefined)
return result?.data.map((item) => item.path) ?? []
@@ -6,7 +6,6 @@ import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useEditorContext } from "../../context/editor"
import { useProject } from "../../context/project"
import { useClient } from "../../context/client"
import { useData } from "../../context/data"
import { getScrollAcceleration } from "../../util/scroll"
@@ -19,7 +18,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { useBindings, useCommandSlashes } from "../../keymap"
import { useBindings } from "../../keymap"
import { Keymap } from "../../context/keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
@@ -87,9 +86,8 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const client = useClient()
const data = useData()
const project = useProject()
const slashes = useCommandSlashes()
const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
@@ -284,7 +282,7 @@ export function Autocomplete(props: {
})
function normalizeMentionPath(filePath: string) {
const baseDir = location()?.directory || project.instance.directory() || paths.cwd
const baseDir = location.current?.directory || data.location.info()?.directory || paths.cwd
const absolute = path.resolve(filePath)
const relative = path.relative(baseDir, absolute)
@@ -310,7 +308,7 @@ export function Autocomplete(props: {
}
const [files] = createResource(
() => ({ query: search(), location: location(), visible: store.visible }),
() => ({ query: search(), location: location.current, visible: store.visible }),
async (input) => {
if (!input.visible || input.visible === "/") return { options: [], failed: false }
if (referenceMatch()) return { options: [], failed: false }
@@ -322,7 +320,7 @@ export function Autocomplete(props: {
limit: 20,
location: {
directory: input.location?.directory,
workspace: input.location?.workspaceID ?? project.workspace.current(),
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
},
})
.then(
@@ -365,7 +363,7 @@ export function Autocomplete(props: {
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
for (const res of data.location.mcp.resource.list(location()) ?? []) {
for (const res of data.location.mcp.resource.list(location.current) ?? []) {
options.push({
display: Locale.truncateMiddle(res.name, width),
// Match the name only; matching the URI caused unrelated fuzzy hits.
@@ -429,38 +427,43 @@ export function Autocomplete(props: {
),
)
function insertSlash(name: string) {
const newText = `/${name} `
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
}
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...slashes()]
const results: AutocompleteOption[] = keymapCommands().flatMap((command) => {
const slash = command.slash
if (!slash) return []
return {
display: `/${slash.name}`,
description: command.description ?? command.title,
aliases: slash.aliases?.map((alias) => `/${alias}`),
onSelect: slash.arguments ? () => insertSlash(slash.name) : command.run,
}
})
const commandNames = new Set<string>()
for (const serverCommand of data.location.command.list(location()) ?? []) {
for (const serverCommand of data.location.command.list(location.current) ?? []) {
commandNames.add(serverCommand.name)
results.push({
display: "/" + serverCommand.name,
description: serverCommand.description,
onSelect: () => {
const newText = "/" + serverCommand.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
},
onSelect: () => insertSlash(serverCommand.name),
})
}
for (const skill of data.location.skill
.list(location())
.list(location.current)
?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) {
results.push({
display: "/" + skill.id,
description: skill.description,
onSelect: () => {
const newText = "/" + skill.id + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
},
onSelect: () => insertSlash(skill.id),
})
}
+72 -12
View File
@@ -23,7 +23,6 @@ import { useClipboard } from "../../context/clipboard"
import { Spinner } from "../spinner"
import { useClient } from "../../context/client"
import { useRoute } from "../../context/route"
import { useProject } from "../../context/project"
import { useEvent } from "../../context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
import { normalizePromptContent, openEditor } from "../../editor"
@@ -53,7 +52,9 @@ import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { contextUsage } from "../../util/session"
import { abbreviateHome } from "../../runtime"
registerOpencodeSpinner()
@@ -137,6 +138,18 @@ function formatEditorContext(selection: EditorSelection) {
let stashed: { prompt: PromptInfo; cursor: number } | undefined
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
if (!input.startsWith("/")) return
const separator = input.search(/\s/)
const name = input.slice(1, separator === -1 ? undefined : separator)
const command = commands.find(
(command) =>
command.slash?.arguments && (command.slash.name === name || command.slash.aliases?.includes(name) === true),
)
if (!command) return
return { command, input: separator === -1 ? "" : input.slice(separator + 1) }
}
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
@@ -151,8 +164,8 @@ export function Prompt(props: PromptProps) {
const client = useClient()
const editor = useEditorContext()
const route = useRoute()
const project = useProject()
const data = useData()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const config = useConfig().data
const dialog = useDialog()
@@ -165,7 +178,8 @@ export function Prompt(props: PromptProps) {
.filter((id) => id !== props.sessionID && data.session.status(id) === "running").length
})
const runningShells = createMemo(
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
() =>
data.shell.list(currentLocation.current).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
)
const history = usePromptHistory()
const stash = usePromptStash()
@@ -214,9 +228,34 @@ export function Prompt(props: PromptProps) {
const editorContextLabelState = createMemo(() => editor.labelState())
const [auto, setAuto] = createSignal<AutocompleteRef>()
const move = usePromptMove({
projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(),
projectID: () =>
(props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id,
sessionID: () => props.sessionID,
})
Keymap.createLayer(() => ({
mode: "global",
enabled: props.sessionID !== undefined,
commands: [
{
id: "session.cd",
title: "Change working directory",
slash: { name: "cd", arguments: true },
run: async (input) => {
const sessionID = props.sessionID
if (!sessionID) return
if (!input?.trim()) {
toast.show({ message: "Directory is required", variant: "error" })
return
}
await client.api.session
.move({ sessionID, directory: input })
.catch((error) =>
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }),
)
},
},
],
}))
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
@@ -244,7 +283,7 @@ export function Prompt(props: PromptProps) {
const event = useEvent()
event.on("tui.prompt.append", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return
if (workspace !== (currentLocation.current?.workspaceID ?? data.location.default().workspaceID)) return
if (!input || input.isDestroyed) return
input.insertText(evt.data.text)
setTimeout(() => {
@@ -465,8 +504,8 @@ export function Prompt(props: PromptProps) {
renderer,
value,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
(data.location.info()?.project.directory === "/" ? undefined : data.location.info()?.project.directory) ||
data.location.default().directory ||
paths.cwd,
})
if (!content) return
@@ -502,7 +541,7 @@ export function Prompt(props: PromptProps) {
run: () => {
dialog.replace(() => (
<DialogSkill
location={currentLocation()}
location={currentLocation.current}
onSelect={(skill) => {
input.setText(`/${skill} `)
setStore("prompt", {
@@ -947,6 +986,12 @@ export function Prompt(props: PromptProps) {
void exit()
return true
}
const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) {
clearPrompt()
await slash.command.run(slash.input)
return true
}
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()
@@ -1016,7 +1061,7 @@ export function Prompt(props: PromptProps) {
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation()) ?? []).some(
(data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
@@ -1043,7 +1088,7 @@ export function Prompt(props: PromptProps) {
})
} else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation()) ?? []).some(
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
@@ -1293,6 +1338,11 @@ export function Prompt(props: PromptProps) {
if (!list().length) return undefined
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const locationLabel = createMemo(() => {
if (!props.sessionID || status() !== "idle") return
const directory = data.session.get(props.sessionID)?.location.directory
return directory ? abbreviateHome(directory, paths.home) : undefined
})
const spinnerDef = createMemo(() => {
const agent = status() === "running" ? local.agent.current() : local.agent.current()
@@ -1315,7 +1365,6 @@ export function Prompt(props: PromptProps) {
}
})
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
return (
<>
@@ -1515,7 +1564,18 @@ export function Prompt(props: PromptProps) {
<text fg={theme.accent}>(new working copy)</text>
</box>
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
<Match when={true}>
<Show
when={!props.hint && locationLabel()}
fallback={props.hint ?? <text />}
>
{(location) => (
<text fg={theme.textMuted} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
</Show>
</Match>
</Switch>
<box gap={2} flexDirection="row">
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
+7 -14
View File
@@ -6,8 +6,6 @@ import { useDialog } from "../../ui/dialog"
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"
import { useProject } from "../../context/project"
import { useData } from "../../context/data"
function moveReminderText(directory: string) {
@@ -18,7 +16,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const project = useProject()
const data = useData()
const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false)
@@ -34,7 +31,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
try {
const result = await client.api.projectCopy.create({
projectID,
location: { directory: project.instance.directory() || paths.cwd },
location: { directory: data.location.info()?.directory || paths.cwd },
strategy: "git_worktree",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
name,
@@ -77,8 +74,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
: {
type: "directory",
directory: project.instance.directory(),
subdirectory: project.instance.directory() !== project.instance.path().worktree,
directory: data.location.default().directory,
subdirectory: data.location.default().directory !== data.location.info()?.project.directory,
})
}
onCurrentChange={setDestination}
@@ -96,12 +93,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
const session = await resolveSession(sessionID)
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()
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
if (!directory) {
@@ -111,7 +102,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
setProgress("Moving session")
try {
await client.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
await client.api.session.move({ sessionID, directory })
await client.api.session
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
.catch(() => undefined)
@@ -130,8 +121,10 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
if (projectID) return projectID
const sessionID = input.sessionID()
if (sessionID) return (await resolveSession(sessionID))?.projectID
const current = data.location.info()
if (current) return current.project.id
return client.api.project
.current({ location: { directory: project.instance.directory() || paths.cwd } })
.current({ location: { directory: data.location.default().directory || paths.cwd } })
.then((project) => project.id)
.catch(() => undefined)
}
+5 -53
View File
@@ -1,52 +1,11 @@
import type { Service } from "@opencode-ai/client/effect"
import { createSignal, onCleanup, Show } from "solid-js"
import { Keymap } from "../context/keymap"
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
import { Spinner } from "./spinner"
const restartCommand = "service.restart"
export function Reconnecting(props: { status?: Service.Status; restart?: (signal?: AbortSignal) => Promise<void> }) {
export function Reconnecting(props: { status?: Service.Status }) {
const theme = useTheme().theme
const shortcuts = Keymap.useShortcuts()
const [restarting, setRestarting] = createSignal(false)
const [failure, setFailure] = createSignal<string>()
let controller: AbortController | undefined
const copy = () =>
restarting()
? { loading: true, message: "Restarting background service..." }
: reconnectingCopy(props.status, shortcuts.get(restartCommand))
Keymap.createLayer(() => ({
mode: "global",
priority: 1000,
commands: [
{
id: restartCommand,
bind: "r",
title: "Restart service",
enabled: props.status?.type === "unresponsive" && !!props.restart,
run: () => {
if (!props.restart || restarting()) return
controller = new AbortController()
setFailure(undefined)
setRestarting(true)
void props
.restart(controller.signal)
.then(
() => setFailure(undefined),
(error) => {
setFailure(errorMessage(error))
setRestarting(false)
},
)
},
},
],
}))
onCleanup(() => controller?.abort())
const copy = () => reconnectingCopy(props.status)
return (
<box
@@ -78,19 +37,12 @@ export function Reconnecting(props: { status?: Service.Status; restart?: (signal
)}
</Show>
</Show>
<Show when={failure()}>
{(message) => (
<text fg={theme.error} wrapMode="word">
{message()}
</text>
)}
</Show>
</box>
</box>
)
}
export function reconnectingCopy(status?: Service.Status, restart = "r") {
export function reconnectingCopy(status?: Service.Status) {
if (status?.type === "starting")
return {
loading: true,
@@ -107,7 +59,7 @@ export function reconnectingCopy(status?: Service.Status, restart = "r") {
return {
loading: false,
message: "Background service is not responding",
action: `[${restart}] Restart service`,
action: "Run `opencode service restart` to recover it.",
}
return { loading: true, message: "Waiting for background service..." }
}
-1
View File
@@ -123,7 +123,6 @@ export const Info = Schema.Struct({
).annotate({ description: "Session transcript presentation settings" }),
hints: Schema.optional(
Schema.Struct({
tips: Schema.optional(Schema.Boolean).annotate({ description: "Show usage tips on the home screen" }),
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
}),
).annotate({ description: "In-product guidance settings" }),
-2
View File
@@ -215,7 +215,6 @@ export const Definitions = {
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
terminal_title_toggle: keybind("none", "Toggle terminal title"),
tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
plugin_manager: keybind("none", "Open plugin manager dialog"),
plugin_install: keybind("none", "Install plugin"),
@@ -389,7 +388,6 @@ export const CommandMap = {
history_next: "prompt.history.next",
terminal_suspend: "terminal.suspend",
terminal_title_toggle: "terminal.title.toggle",
tips_toggle: "tips.toggle",
plugin_manager: "plugins.list",
plugin_install: "plugins.install",
which_key_toggle: "which-key.toggle",
+2 -15
View File
@@ -28,7 +28,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
api: OpenCodeClient
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
// Stops and starts the managed service; present only in service mode.
reload?: (signal?: AbortSignal) => Promise<void>
reload?: () => Promise<void>
}) => {
const log = useLog({ component: "client" })
const abort = new AbortController()
@@ -141,19 +141,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
events.clear()
})
const reloadService = props.reload
const reload =
reloadService === undefined
? undefined
: async (signal?: AbortSignal) => {
stream?.abort()
try {
await reloadService(signal)
} finally {
if (!abort.signal.aborted) start()
}
}
return {
get api() {
return api
@@ -181,7 +168,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
},
},
},
reload,
reload: props.reload,
}
},
})
+21 -7
View File
@@ -9,6 +9,7 @@ import type {
FormInfo,
IntegrationInfo,
LocationRef,
LocationGetOutput,
McpResource,
McpServer,
ModelInfo,
@@ -43,6 +44,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
type LocationData = {
info?: LocationGetOutput
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
@@ -366,6 +368,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.moved":
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "location", event.data.location)
if (event.data.projectID)
setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
}
break
@@ -1058,6 +1062,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
},
location: {
info(ref?: LocationRef) {
return store.location[locationKey(ref ?? defaultLocation())]?.info
},
default() {
return defaultLocation()
},
@@ -1067,7 +1074,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const location = await client.api.location.get({ location: locationQuery(current) })
const key = locationKey(location)
if (!store.location[key]) setStore("location", key, {})
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
setStore("location", key, "info", location)
if (!ref) {
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
}
})
const location = ref ?? defaultLocation()
await Promise.all([
@@ -1275,12 +1285,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
})
.catch(() => undefined)
void client.api.session
.list({
limit: 50,
order: "desc",
directory: defaultLocation().directory,
workspace: defaultLocation().workspaceID,
void client.api.location
.get({ location: locationQuery(defaultLocation()) })
.then((location) => {
const key = locationKey(location)
setStore("location", key, { ...store.location[key], info: location })
return client.api.session.list({
project: location.project.id,
limit: 50,
order: "desc",
})
})
.then((response) => {
setStore(
+3 -3
View File
@@ -1,13 +1,13 @@
import { createMemo } from "solid-js"
import { useProject } from "./project"
import { useData } from "./data"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
export function useDirectory() {
const project = useProject()
const data = useData()
const paths = useTuiPaths()
return createMemo(() => {
const directory = project.instance.path().directory || paths.cwd
const directory = data.location.info()?.directory ?? data.location.default().directory ?? paths.cwd
return abbreviateHome(directory, paths.home)
})
}
+66 -21
View File
@@ -9,7 +9,7 @@ import {
registerManagedTextareaLayer,
registerTimedLeader,
} from "@opentui/keymap/addons/opentui"
import { formatKeySequence } from "@opentui/keymap/extras"
import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { useRenderer } from "@opentui/solid"
@@ -19,9 +19,11 @@ import { TuiKeybind } from "../config/keybind"
declare module "@opentui/keymap" {
interface Command {
opencode?: KeymapCommand
slash?: {
name: string
aliases?: string[]
arguments?: true
}
}
}
@@ -31,13 +33,28 @@ const MODE = { key: "opencode.mode", base: "base" } as const
type OpenTuiKeymap = Parameters<typeof KeymapProvider>[0]["keymap"]
type Mode = ReturnType<typeof createMode>
const Context = createContext<{ readonly keymap: OpenTuiKeymap; readonly mode: Mode }>()
const Context = createContext<{
readonly keymap: OpenTuiKeymap
readonly mode: Mode
readonly dispatch: (id: string, input?: string) => void
readonly input: (id: string) => string | undefined
}>()
function Provider(props: ParentProps) {
const renderer = useRenderer()
const config = useConfig()
const keymap = createDefaultOpenTuiKeymap(renderer)
const mode = createMode(keymap)
let invocation: { readonly id: string; readonly input?: string } | undefined
const dispatch = (id: string, input?: string) => {
const previous = invocation
invocation = { id, input }
try {
keymap.dispatchCommand(id)
} finally {
invocation = previous
}
}
const dispose = [
registerCommaBindings(keymap),
keymap.appendBindingExpander((context) => {
@@ -113,7 +130,11 @@ function Provider(props: ParentProps) {
})
return (
<KeymapProvider keymap={keymap}>
<Context.Provider value={{ keymap, mode }}>{props.children}</Context.Provider>
<Context.Provider
value={{ keymap, mode, dispatch, input: (id) => (invocation?.id === id ? invocation.input : undefined) }}
>
{props.children}
</Context.Provider>
</KeymapProvider>
)
}
@@ -122,7 +143,7 @@ export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/cont
export interface Keymap {
/** Dispatches a reachable command by ID. */
dispatch(id: string): void
dispatch(id: string, input?: string): void
/** Controls mutually exclusive OpenCode input modes. */
readonly mode: {
/** Returns the active mode. */
@@ -135,15 +156,15 @@ export interface Keymap {
function use(): Keymap {
const value = useValue()
return {
dispatch(id) {
value.keymap.dispatchCommand(id)
dispatch(id, input) {
value.dispatch(id, input)
},
mode: value.mode,
}
}
function createLayer(input: () => KeymapLayer) {
useValue()
const value = useValue()
const config = useConfig()
useBindings(() => {
const layer = input()
@@ -173,10 +194,12 @@ function createLayer(input: () => KeymapLayer) {
...options,
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
commands: grouped.named.map((command) => {
const { id, description, group, palette, bind, ...definition } = command
const { id, description, group, palette, bind, run, ...definition } = command
return {
...definition,
name: id,
opencode: command,
run: () => run(value.input(id)),
...(description === undefined ? {} : { desc: description }),
...(group === undefined ? {} : { category: group }),
...(palette === undefined ? {} : { namespace: "palette" }),
@@ -215,12 +238,21 @@ function useShortcuts() {
const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name)
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
return new Map(
commands.map((id) => [id, formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data))]),
commands.map((id) => [
id,
{
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data)),
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(config.data)),
},
]),
)
})
return {
get(id: string) {
return shortcuts().get(id)
return shortcuts().get(id)?.first
},
all(id: string) {
return shortcuts().get(id)?.all
},
}
}
@@ -232,17 +264,30 @@ function useCommands(): Accessor<readonly KeymapCommand[]> {
.getCommandEntries({
visibility: "reachable",
})
.map((entry) => ({
id: entry.command.name,
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
group: typeof entry.command.category === "string" ? entry.command.category : undefined,
palette: entry.command.namespace === "palette" ? true : undefined,
slash: entry.command.slash,
run: () => {
value.keymap.dispatchCommand(entry.command.name)
},
})),
.map((entry) => {
const command = entry.command.opencode ?? {
id: entry.command.name,
title: typeof entry.command.title === "string" ? entry.command.title : undefined,
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
group: typeof entry.command.category === "string" ? entry.command.category : undefined,
enabled:
typeof entry.command.enabled === "boolean" || typeof entry.command.enabled === "function"
? (entry.command.enabled as boolean | (() => boolean))
: undefined,
palette: entry.command.namespace === "palette" ? true : undefined,
slash: entry.command.slash,
suggested:
typeof entry.command.suggested === "boolean" || typeof entry.command.suggested === "function"
? (entry.command.suggested as boolean | (() => boolean))
: undefined,
}
return {
...command,
run: (input?: string) => {
value.dispatch(entry.command.name, input)
},
}
}),
)
}
+20 -14
View File
@@ -1,17 +1,18 @@
import type { LocationRef } from "@opencode-ai/client"
import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
import type { LocationGetOutput, LocationRef } from "@opencode-ai/client"
import { createContext, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js"
import { useClient } from "./client"
import { useData } from "./data"
const context = createContext<{
current: Accessor<LocationRef | undefined>
readonly current: LocationGetOutput | undefined
set: (location?: LocationRef) => void
}>()
export function LocationProvider(props: ParentProps) {
const client = useClient()
const data = useData()
const [current, setCurrent] = createSignal<LocationRef>()
const [ref, setRef] = createSignal<LocationRef>()
const current = createMemo(() => data.location.info(ref()))
function sync(location?: LocationRef) {
if (!location) return
@@ -24,23 +25,28 @@ export function LocationProvider(props: ParentProps) {
}
function set(location?: LocationRef) {
setCurrent(location)
setRef(location)
if (client.connection.status() === "connected") sync(location)
}
onCleanup(client.event.on("server.connected", () => sync(current())))
onCleanup(client.event.on("server.connected", () => sync(ref())))
return <context.Provider value={{ current, set }}>{props.children}</context.Provider>
return (
<context.Provider
value={{
get current() {
return current()
},
set,
}}
>
{props.children}
</context.Provider>
)
}
export function useLocation() {
const value = useContext(context)
if (!value) throw new Error("Location context must be used within a LocationProvider")
return value.current
}
export function useSetLocation() {
const value = useContext(context)
if (!value) throw new Error("Location context must be used within a LocationProvider")
return value.set
return value
}
+2 -2
View File
@@ -7,8 +7,8 @@ export function usePathFormatter() {
const paths = useTuiPaths()
const location = useLocation()
return {
path: () => location()?.directory || paths.cwd,
format: (input?: string) => formatPath(input, location()?.directory || paths.cwd, paths.home),
path: () => location.current?.directory || paths.cwd,
format: (input?: string) => formatPath(input, location.current?.directory || paths.cwd, paths.home),
}
}
-76
View File
@@ -1,76 +0,0 @@
import { batch } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project",
init: () => {
const client = useClient()
const defaultPath = {
home: "",
state: "",
config: "",
worktree: "",
directory: process.cwd(),
}
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
worktree: undefined as string | undefined,
mainDir: undefined as string | undefined,
},
instance: {
path: defaultPath,
},
workspace: {
current: undefined as string | undefined,
},
})
async function sync() {
const workspace = store.workspace.current
const location = { workspace }
const current = await client.api.location.get({ location })
const directories = await client.api.project.directories({ projectID: current.project.id, location })
batch(() => {
setStore(
"instance",
"path",
reconcile({ ...defaultPath, worktree: current.project.directory, directory: current.directory }),
)
setStore("project", "id", current.project.id)
setStore("project", "worktree", current.project.directory)
setStore("project", "mainDir", directories.findLast((item) => item.strategy === undefined)?.directory)
})
}
return {
data: store,
project() {
return store.project.id
},
instance: {
path() {
return store.instance.path
},
directory() {
return store.instance.path.directory
},
},
workspace: {
current() {
return store.workspace.current
},
set(next?: string | null) {
const workspace = next ?? undefined
if (store.workspace.current === workspace) return
setStore("workspace", "current", workspace)
},
},
sync,
}
},
})
-3
View File
@@ -62,12 +62,9 @@ export {
allThemes,
generateSyntax,
hasTheme,
isTheme,
resolveTheme,
selectedForeground,
upsertTheme,
type Theme,
type ThemeJson,
} from "../theme"
const THEME_REFRESH_DELAYS = [250, 1000] as const
@@ -1,286 +0,0 @@
import { createMemo, For, type Accessor } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "../../context/theme"
import { Keymap } from "../../context/keymap"
const themeCount = Object.keys(DEFAULT_THEMES).length
type TipPart = { text: string; highlight: boolean }
type TipShortcut = Accessor<string | undefined>
type Shortcuts = {
agentCycle: TipShortcut
childFirst: TipShortcut
childNext: TipShortcut
childPrevious: TipShortcut
commandList: TipShortcut
editorOpen: TipShortcut
helpShow: TipShortcut
inputClear: TipShortcut
inputNewline: TipShortcut
inputPaste: TipShortcut
inputUndo: TipShortcut
leader: TipShortcut
messagesCopy: TipShortcut
messagesFirst: TipShortcut
messagesLast: TipShortcut
messagesPageDown: TipShortcut
messagesPageUp: TipShortcut
modelCycleRecent: TipShortcut
modelList: TipShortcut
sessionExport: TipShortcut
sessionInterrupt: TipShortcut
sessionList: TipShortcut
sessionNew: TipShortcut
sessionParent: TipShortcut
sessionPinToggle: TipShortcut
sessionQuickSwitch1: TipShortcut
sessionQuickSwitch9: TipShortcut
sessionSidebarToggle: TipShortcut
sessionTimeline: TipShortcut
statusView: TipShortcut
terminalSuspend: TipShortcut
themeList: TipShortcut
}
type Tip = string | ((shortcuts: Shortcuts) => string | undefined)
function parse(tip: string): TipPart[] {
const parts: TipPart[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
const found = Array.from(tip.matchAll(regex))
const state = found.reduce(
(acc, match) => {
const start = match.index ?? 0
if (start > acc.index) {
acc.parts.push({ text: tip.slice(acc.index, start), highlight: false })
}
acc.parts.push({ text: match[1], highlight: true })
acc.index = start + match[0].length
return acc
},
{ parts, index: 0 },
)
if (state.index < tip.length) {
parts.push({ text: tip.slice(state.index), highlight: false })
}
return parts
}
const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding"
const NO_MODELS_PARTS = parse(NO_MODELS_TIP)
function shortcutText(value: string) {
return `{highlight}${value}{/highlight}`
}
function commandText(command: string, shortcut: string | undefined) {
if (!shortcut) return shortcutText(command)
return `${shortcutText(command)} or ${shortcutText(shortcut)}`
}
function press(shortcut: string | undefined, text: string) {
if (!shortcut) return undefined
return `Press ${shortcutText(shortcut)} ${text}`
}
export function Tips(props: { connected?: boolean }) {
const theme = useTheme().theme
const keymap = Keymap.useShortcuts()
const tipOffset = Math.random()
const shortcut = (id: string) => () => keymap.get(id)
const shortcuts: Shortcuts = {
agentCycle: shortcut("agent.cycle"),
childFirst: shortcut("session.child.first"),
childNext: shortcut("session.child.next"),
childPrevious: shortcut("session.child.previous"),
commandList: shortcut("command.palette.show"),
editorOpen: shortcut("prompt.editor"),
helpShow: shortcut("help.show"),
inputClear: shortcut("prompt.clear"),
inputNewline: shortcut("input.newline"),
inputPaste: shortcut("prompt.paste"),
inputUndo: shortcut("input.undo"),
leader: shortcut("leader"),
messagesCopy: shortcut("messages.copy"),
messagesFirst: shortcut("session.first"),
messagesLast: shortcut("session.last"),
messagesPageDown: shortcut("session.page.down"),
messagesPageUp: shortcut("session.page.up"),
modelCycleRecent: shortcut("model.cycle_recent"),
modelList: shortcut("model.list"),
sessionExport: shortcut("session.export"),
sessionInterrupt: shortcut("session.interrupt"),
sessionList: shortcut("session.list"),
sessionNew: shortcut("session.new"),
sessionParent: shortcut("session.parent"),
sessionPinToggle: shortcut("session.pin.toggle"),
sessionQuickSwitch1: shortcut("session.quick_switch.1"),
sessionQuickSwitch9: shortcut("session.quick_switch.9"),
sessionSidebarToggle: shortcut("session.sidebar.toggle"),
sessionTimeline: shortcut("session.timeline"),
statusView: shortcut("opencode.status"),
terminalSuspend: shortcut("terminal.suspend"),
themeList: shortcut("theme.switch"),
}
const tip = createMemo(() => {
if (props.connected === false) return NO_MODELS_TIP
const tips = [...TIPS, process.platform !== "win32" ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap((item) => {
const value = typeof item === "string" ? item : item(shortcuts)
return value ? [value] : []
})
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
}, NO_MODELS_TIP)
// Solid can expose a memo's initial value while a pure computation is pending.
const parts = createMemo(() => {
const value = tip()
if (typeof value === "string") return parse(value)
return NO_MODELS_PARTS
}, NO_MODELS_PARTS)
return (
<box flexDirection="row" maxWidth="100%">
<text flexShrink={0} style={{ fg: theme.warning }}>
Tip{" "}
</text>
<text flexShrink={1} wrapMode="word">
<For each={parts()}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>
</box>
)
}
const TIPS: Tip[] = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
"Start a message with {highlight}!{/highlight} to run shell commands (e.g., {highlight}!ls -la{/highlight})",
(shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"),
"Use {highlight}/undo{/highlight} to revert the last message and file changes",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes",
"Run {highlight}/share{/highlight} to create a public opencode.ai link",
"Drag and drop images or PDFs into the terminal as context",
(shortcuts) => press(shortcuts.inputPaste(), "to paste images from your clipboard into the prompt"),
(shortcuts) => `Use ${commandText("/editor", shortcuts.editorOpen())} to compose messages in your external editor`,
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase",
(shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to switch between available AI models`,
(shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`,
(shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin one at the top"),
(shortcuts) => {
const first = shortcuts.sessionQuickSwitch1()
const last = shortcuts.sessionQuickSwitch9()
if (!first || !last) return undefined
return `Use ${shortcutText(first)} through ${shortcutText(last)} to switch pinned sessions`
},
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
(shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"),
(shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"),
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers",
(shortcuts) => {
const leader = shortcuts.leader()
if (!leader) return undefined
return `The leader key is ${shortcutText(leader)}; combine with other keys for quick actions`
},
(shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"),
(shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"),
(shortcuts) => {
const up = shortcuts.messagesPageUp()
const down = shortcuts.messagesPageDown()
if (!up || !down) return undefined
return `Use ${shortcutText(up)}/${shortcutText(down)} to navigate through conversation history`
},
(shortcuts) => press(shortcuts.messagesFirst(), "to jump to the beginning of the conversation"),
(shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"),
(shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"),
(shortcuts) => press(shortcuts.inputClear(), "when typing to clear the input field"),
(shortcuts) => press(shortcuts.sessionInterrupt(), "to stop the AI mid-response"),
"Switch to {highlight}Plan{/highlight} agent for suggestions without making changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
(shortcuts) => {
const items = [
shortcuts.sessionParent(),
shortcuts.childFirst(),
shortcuts.childPrevious(),
shortcuts.childNext(),
].filter((item): item is string => Boolean(item))
if (!items.length) return undefined
return `Use ${items.map(shortcutText).join(" / ")} for parent/child sessions`
},
"Create {highlight}opencode.json{/highlight} for server settings, and {highlight}tui.json{/highlight} for TUI",
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model",
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/commands/{/highlight} for reusable prompts",
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
"Use backticks to inject shell output (e.g., {highlight}`git status`{/highlight})",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}shell{/highlight}, and {highlight}webfetch{/highlight} tools",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
'Set {highlight}"formatter": true{/highlight} to enable built-in formatters',
'Set {highlight}"formatter": false{/highlight} to disable inherited formatters',
"Define custom formatter commands with file extensions in config",
'Set {highlight}"lsp": true{/highlight} to enable built-in LSP code analysis',
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools",
"Tool definitions can invoke scripts written in Python, Go, etc",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugins/{/highlight} for event hooks",
"Use plugins to send OS notifications when sessions complete",
"Create a plugin to prevent OpenCode from reading sensitive files",
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
"Use {highlight}opencode --continue{/highlight} to resume the last session",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version",
"Run {highlight}opencode auth list{/highlight} to see all configured providers",
"Run {highlight}opencode agent create{/highlight} for guided agent creation",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory",
"Themes support dark/light variants for both modes",
"Use numeric xterm color codes 0-255 in custom theme JSON",
"Use {highlight}{env:VAR_NAME}{/highlight} for environment variables in config",
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
"Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
"Configure {highlight}steps{/highlight} to limit agentic iterations per request",
'Set {highlight}"tools": {"shell": false}{/highlight} to disable specific tools',
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
"Override global tool settings per agent configuration",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing',
"Run {highlight}/unshare{/highlight} to remove a session from public access",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops",
"Permission {highlight}external_directory{/highlight} protects files outside project",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr",
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
"Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling",
(shortcuts) => {
const commandList = shortcuts.commandList()
return commandList
? `Toggle username display in chat via the command palette (${shortcutText(commandList)})`
: "Toggle username display in chat via the command palette"
},
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} in a container",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
(shortcuts) => `Use ${commandText("/help", shortcuts.helpShow())} to show the help dialog`,
"Use {highlight}/rename{/highlight} to rename the current session",
]
const INPUT_UNDO_TIP: Tip = (shortcuts) => press(shortcuts.inputUndo(), "to undo changes in your prompt")
const TERMINAL_SUSPEND_TIP: Tip = (shortcuts) =>
press(shortcuts.terminalSuspend(), "to suspend the terminal and return to your shell")
@@ -1,51 +0,0 @@
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view"
import { Keymap } from "../../context/keymap"
import { useData } from "../../context/data"
import { hasConnectedProvider } from "../../util/connected-provider"
import { useConfig } from "../../config"
import { useDialog } from "../../ui/dialog"
function View() {
const config = useConfig()
const data = useData()
const dialog = useDialog()
const hidden = createMemo(() => !(config.data.hints?.tips ?? true))
const first = createMemo(() => data.session.list().length === 0)
const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
const show = createMemo(() => (!first() || !connected()) && !hidden())
Keymap.createLayer(() => ({
commands: [
{
id: "tips.toggle",
title: hidden() ? "Show tips" : "Hide tips",
group: "System",
run() {
void config
.update((draft) => {
draft.hints = { ...draft.hints, tips: hidden() }
})
.catch(() => {})
dialog.clear()
},
},
],
}))
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={show()}>
<Tips connected={connected()} />
</Show>
</box>
)
}
export default Plugin.define({
id: "internal:home-tips",
setup(context) {
context.ui.slot("home.bottom", () => <View />)
},
})
@@ -1,14 +1,22 @@
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createMemo, Show } from "solid-js"
import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme"
import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path"
function View() {
function View(props: { context: Plugin.Context }) {
const { theme } = useTheme()
return <text fg={theme.textMuted}>Sidebar footer unavailable</text>
const paths = useTuiPaths()
const directory = createMemo(() =>
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
)
return <Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={theme.textMuted} />}</Show>
}
export default Plugin.define({
id: "opencode.sidebar-footer",
setup(context) {
context.ui.slot("sidebar.footer", () => <View />)
context.ui.slot("sidebar.footer", () => <View context={context} />)
},
})
+4 -41
View File
@@ -13,15 +13,18 @@ import {
formatKeySequence as formatKeySequenceExtra,
} from "@opentui/keymap/extras"
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
import { createMemo, type Accessor } from "solid-js"
import type { Accessor } from "solid-js"
import { useConfig } from "./config"
import { TuiKeybind } from "./config/keybind"
import type { KeymapCommand } from "@opencode-ai/plugin/v2/tui/context"
declare module "@opentui/keymap" {
interface Command {
opencode?: KeymapCommand
slash?: {
name: string
aliases?: string[]
arguments?: true
}
}
}
@@ -39,13 +42,6 @@ export const useOpencodeKeymap = useKeymap
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
type CommandSlashEntry = {
display: string
description?: string
aliases?: string[]
onSelect: () => void
}
type RegisteredCommand = ReturnType<OpenTuiKeymap["getCommands"]>[number]
type BindingLookup = {
get(command: string): readonly Binding<Renderable, KeyEvent>[]
}
@@ -54,10 +50,6 @@ type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | {
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
function isVisiblePaletteCommand(command: RegisteredCommand) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
export function createOpencodeModeStack(keymap: OpenTuiKeymap) {
keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE)
@@ -268,32 +260,3 @@ export function useCommandShortcut(command: string): Accessor<string> {
),
)
}
export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
const keymap = useOpencodeKeymap()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
keymap.getCommandEntries({
visibility: "reachable",
namespace: "palette",
filter: isVisiblePaletteCommand,
}),
)
return createMemo<CommandSlashEntry[]>(() =>
entries().flatMap((entry) => {
const slash = entry.command.slash
if (!slash) return []
return {
display: `/${slash.name}`,
description:
typeof entry.command.desc === "string"
? entry.command.desc
: typeof entry.command.title === "string"
? entry.command.title
: undefined,
aliases: slash.aliases?.map((alias) => `/${alias}`),
onSelect: () => keymap.dispatchCommand(entry.command.name),
}
}),
)
}
-2
View File
@@ -1,5 +1,4 @@
import HomeFooter from "../feature-plugins/home/footer"
import HomeTips from "../feature-plugins/home/tips"
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
@@ -9,7 +8,6 @@ import Scrap from "../feature-plugins/system/scrap"
export const builtins = [
HomeFooter,
HomeTips,
SidebarContext,
SidebarMcp,
SidebarLsp,
+1 -1
View File
@@ -85,7 +85,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const context: Context = {
options: item.options ?? {},
get location() {
return location()
return location.current
},
client: client.api,
data,
+3 -5
View File
@@ -1,7 +1,6 @@
import { Prompt, type PromptRef } from "../component/prompt"
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { Logo } from "../component/logo"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
import { usePromptRef } from "../context/prompt"
@@ -9,7 +8,7 @@ import { useLocal } from "../context/local"
import { usePluginRuntime } from "../plugin/runtime"
import { useEditorContext } from "../context/editor"
import { useData } from "../context/data"
import { useSetLocation } from "../context/location"
import { useLocation } from "../context/location"
import { FormPrompt } from "./session/form"
import { PluginSlot } from "../plugin/context"
@@ -28,12 +27,12 @@ export function Home() {
const local = useLocal()
const editor = useEditorContext()
const data = useData()
const setLocation = useSetLocation()
const location = useLocation()
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
let sent = false
createEffect(() => setLocation(data.location.default()))
createEffect(() => location.set(data.location.default()))
onMount(() => {
editor.clearSelection()
@@ -88,7 +87,6 @@ export function Home() {
</box>
<PluginSlot name="home.bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
<box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" />
@@ -18,9 +18,7 @@ export function ShellTab(props: { sessionID: string }) {
const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() =>
data.shell
.list()
.filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"),
data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"),
)
const [store, setStore] = createStore({ selected: 0 })
@@ -47,8 +45,7 @@ export function ShellTab(props: { sessionID: string }) {
const cleanup = composer.register({
id: "shell",
label: "Shell",
hints: () =>
selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : [],
hints: () => (selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : []),
})
onCleanup(cleanup)
})
@@ -87,7 +84,7 @@ export function ShellTab(props: { sessionID: string }) {
run() {
const entry = selectedEntry()
if (!entry) return
const ref = location()
const ref = location.current
void client.api.shell.remove({
id: entry.id,
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
@@ -99,11 +96,7 @@ export function ShellTab(props: { sessionID: string }) {
return (
<Show when={composer.active("shell")}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No shell commands</text>}>
<For each={entries()}>
{(shell, index) => {
+37 -12
View File
@@ -18,7 +18,6 @@ import { EOL, tmpdir } from "node:os"
import { mkdir, writeFile } from "node:fs/promises"
import { useRoute, useRouteData } from "../../context/route"
import { createStore } from "solid-js/store"
import { useProject } from "../../context/project"
import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
@@ -53,7 +52,7 @@ import { Composer } from "./composer"
import { filetype } from "../../util/filetype"
import parsers from "../../parsers-config"
import { errorMessage } from "../../util/error"
import { Toast, useToast } from "../../ui/toast"
import { useToast } from "../../ui/toast"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { useEpilogue } from "../../context/epilogue"
@@ -71,7 +70,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { useSetLocation } from "../../context/location"
import { useLocation } from "../../context/location"
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
@@ -106,7 +105,6 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const data = useData()
const project = useProject()
const paths = useTuiPaths()
const configState = useConfig()
const config = configState.data
@@ -115,9 +113,9 @@ export function Session() {
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
const location = createMemo(() => session()?.location)
const setLocation = useSetLocation()
const currentLocation = useLocation()
createEffect(() => setLocation(location()))
createEffect(() => currentLocation.set(location()))
createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50)
@@ -211,7 +209,6 @@ export function Session() {
navigate({ type: "home" })
return
}
project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
@@ -919,7 +916,6 @@ export function Session() {
</Switch>
</box>
</Show>
<Toast />
</box>
<Show when={sidebarVisible()}>
<Switch>
@@ -2322,6 +2318,7 @@ function BlockTool(props: {
function Shell(props: ToolProps) {
const { theme } = useTheme()
const ctx = use()
const client = useClient()
const data = useData()
const permission = createMemo(() => {
const request = data.session.permission.list(ctx.sessionID)?.[0]
@@ -2335,13 +2332,35 @@ function Shell(props: ToolProps) {
})
const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning())
const command = createMemo(() => stringValue(props.input.command))
const [expanded, setExpanded] = createSignal(false)
const [backgroundOutput, setBackgroundOutput] = createSignal("")
let loading = false
const loadBackgroundOutput = async () => {
const id = shellID()
if (!id || loading) return
loading = true
const location = data.session.get(ctx.sessionID)?.location
await client.api.shell
.output({
id,
limit: 1024 * 1024,
location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined,
})
.then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim())))
.catch(() => undefined)
loading = false
}
createEffect(() => {
if (!expanded() || !backgroundRunning()) return
const interval = setInterval(() => void loadBackgroundOutput(), 1_000)
onCleanup(() => clearInterval(interval))
})
const output = createMemo(() => {
if (props.part.state.status === "streaming") return ""
if (shellID()) return ""
if (shellID()) return expanded() ? backgroundOutput() : ""
const content = props.part.state.content[0]
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
})
const [expanded, setExpanded] = createSignal(false)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${command()}` : ""))
@@ -2351,9 +2370,15 @@ function Shell(props: ToolProps) {
if (expanded() || !collapsed().overflow) return content()
return collapsed().output
})
const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow)
const toggle = () => {
const next = !expanded()
setExpanded(next)
if (next) void loadBackgroundOutput()
}
return (
<BlockTool part={props.part} onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}>
<BlockTool part={props.part} onClick={expandable() ? toggle : undefined}>
<box gap={1}>
<Show
when={command()}
@@ -2383,7 +2408,7 @@ function Shell(props: ToolProps) {
<Show when={shellID()}>
<StatusBadge>Background</StatusBadge>
</Show>
<Show when={collapsed().overflow}>
<Show when={expandable()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
+3 -665
View File
@@ -1,167 +1,8 @@
import { SyntaxStyle, RGBA } from "@opentui/core"
import { 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" }
import catppuccinFrappe from "./assets/catppuccin-frappe.json" with { type: "json" }
import catppuccinMacchiato from "./assets/catppuccin-macchiato.json" with { type: "json" }
import catppuccin from "./assets/catppuccin.json" with { type: "json" }
import cobalt2 from "./assets/cobalt2.json" with { type: "json" }
import cursor from "./assets/cursor.json" with { type: "json" }
import dracula from "./assets/dracula.json" with { type: "json" }
import everforest from "./assets/everforest.json" with { type: "json" }
import flexoki from "./assets/flexoki.json" with { type: "json" }
import github from "./assets/github.json" with { type: "json" }
import gruvbox from "./assets/gruvbox.json" with { type: "json" }
import kanagawa from "./assets/kanagawa.json" with { type: "json" }
import lucentOrng from "./assets/lucent-orng.json" with { type: "json" }
import material from "./assets/material.json" with { type: "json" }
import matrix from "./assets/matrix.json" with { type: "json" }
import mercury from "./assets/mercury.json" with { type: "json" }
import monokai from "./assets/monokai.json" with { type: "json" }
import nightowl from "./assets/nightowl.json" with { type: "json" }
import nord from "./assets/nord.json" with { type: "json" }
import onedark from "./assets/one-dark.json" with { type: "json" }
import opencode from "./assets/opencode.json" with { type: "json" }
import orng from "./assets/orng.json" with { type: "json" }
import osakaJade from "./assets/osaka-jade.json" with { type: "json" }
import palenight from "./assets/palenight.json" with { type: "json" }
import rosepine from "./assets/rosepine.json" with { type: "json" }
import solarized from "./assets/solarized.json" with { type: "json" }
import synthwave84 from "./assets/synthwave84.json" with { type: "json" }
import tokyonight from "./assets/tokyonight.json" with { type: "json" }
import vercel from "./assets/vercel.json" with { type: "json" }
import vesper from "./assets/vesper.json" with { type: "json" }
import zenburn from "./assets/zenburn.json" with { type: "json" }
import { DEFAULT_THEMES, type ColorValue, type Theme, type ThemeColor, type ThemeJson } from "./v1"
export type Theme = {
readonly primary: RGBA
readonly secondary: RGBA
readonly accent: RGBA
readonly error: RGBA
readonly warning: RGBA
readonly success: RGBA
readonly info: RGBA
readonly text: RGBA
readonly textMuted: RGBA
readonly selectedListItemText: RGBA
readonly background: RGBA
readonly backgroundPanel: RGBA
readonly backgroundElement: RGBA
readonly backgroundMenu: RGBA
readonly border: RGBA
readonly borderActive: RGBA
readonly borderSubtle: RGBA
readonly diffAdded: RGBA
readonly diffRemoved: RGBA
readonly diffContext: RGBA
readonly diffHunkHeader: RGBA
readonly diffHighlightAdded: RGBA
readonly diffHighlightRemoved: RGBA
readonly diffAddedBg: RGBA
readonly diffRemovedBg: RGBA
readonly diffContextBg: RGBA
readonly diffLineNumber: RGBA
readonly diffAddedLineNumberBg: RGBA
readonly diffRemovedLineNumberBg: RGBA
readonly markdownText: RGBA
readonly markdownHeading: RGBA
readonly markdownLink: RGBA
readonly markdownLinkText: RGBA
readonly markdownCode: RGBA
readonly markdownBlockQuote: RGBA
readonly markdownEmph: RGBA
readonly markdownStrong: RGBA
readonly markdownHorizontalRule: RGBA
readonly markdownListItem: RGBA
readonly markdownListEnumeration: RGBA
readonly markdownImage: RGBA
readonly markdownImageText: RGBA
readonly markdownCodeBlock: RGBA
readonly syntaxComment: RGBA
readonly syntaxKeyword: RGBA
readonly syntaxFunction: RGBA
readonly syntaxVariable: RGBA
readonly syntaxString: RGBA
readonly syntaxNumber: RGBA
readonly syntaxType: RGBA
readonly syntaxOperator: RGBA
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
_hasSelectedListItemText: boolean
}
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
// If theme explicitly defines selectedListItemText, use it
if (theme._hasSelectedListItemText) {
return theme.selectedListItemText
}
// For transparent backgrounds, calculate contrast based on the actual bg (or fallback to primary)
if (theme.background.a === 0) {
const targetColor = bg ?? theme.primary
const { r, g, b } = targetColor
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
}
// Fall back to background color
return theme.background
}
type HexColor = `#${string}`
type RefName = string
type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
type ColorValue = HexColor | RefName | Variant | RGBA
export type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
aura,
ayu,
catppuccin,
["catppuccin-frappe"]: catppuccinFrappe,
["catppuccin-macchiato"]: catppuccinMacchiato,
cobalt2,
cursor,
dracula,
everforest,
flexoki,
github,
gruvbox,
kanagawa,
material,
matrix,
mercury,
monokai,
nightowl,
nord,
["one-dark"]: onedark,
["osaka-jade"]: osakaJade,
opencode,
orng,
["lucent-orng"]: lucentOrng,
palenight,
rosepine,
solarized,
synthwave84,
tokyonight,
vesper,
vercel,
zenburn,
carbonfox,
}
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeJson } from "./v1"
const pluginThemes: Record<string, ThemeJson> = {}
let customThemes: Record<string, ThemeJson> = {}
@@ -297,506 +138,3 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
thinkingOpacity,
} as Theme
}
export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
function getSyntaxRules(theme: Theme) {
return [
{
scope: ["default"],
style: {
foreground: theme.text,
},
},
{
scope: ["prompt"],
style: {
foreground: theme.accent,
},
},
{
scope: ["extmark.file"],
style: {
foreground: theme.warning,
bold: true,
},
},
{
scope: ["extmark.agent"],
style: {
foreground: theme.secondary,
bold: true,
},
},
{
scope: ["extmark.paste"],
style: {
foreground: selectedForeground(theme, theme.warning),
background: theme.warning,
bold: true,
},
},
{
scope: ["comment"],
style: {
foreground: theme.syntaxComment,
italic: true,
},
},
{
scope: ["comment.documentation"],
style: {
foreground: theme.syntaxComment,
italic: true,
},
},
{
scope: ["string", "symbol"],
style: {
foreground: theme.syntaxString,
},
},
{
scope: ["number", "boolean"],
style: {
foreground: theme.syntaxNumber,
},
},
{
scope: ["character.special"],
style: {
foreground: theme.syntaxString,
},
},
{
scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["keyword.type"],
style: {
foreground: theme.syntaxType,
bold: true,
italic: true,
},
},
{
scope: ["keyword.function", "function.method"],
style: {
foreground: theme.syntaxFunction,
},
},
{
scope: ["keyword"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["keyword.import"],
style: {
foreground: theme.syntaxKeyword,
},
},
{
scope: ["operator", "keyword.operator", "punctuation.delimiter"],
style: {
foreground: theme.syntaxOperator,
},
},
{
scope: ["keyword.conditional.ternary"],
style: {
foreground: theme.syntaxOperator,
},
},
{
scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
style: {
foreground: theme.syntaxVariable,
},
},
{
scope: ["variable.member", "function", "constructor"],
style: {
foreground: theme.syntaxFunction,
},
},
{
scope: ["type", "module"],
style: {
foreground: theme.syntaxType,
},
},
{
scope: ["constant"],
style: {
foreground: theme.syntaxNumber,
},
},
{
scope: ["property"],
style: {
foreground: theme.syntaxVariable,
},
},
{
scope: ["class"],
style: {
foreground: theme.syntaxType,
},
},
{
scope: ["parameter"],
style: {
foreground: theme.syntaxVariable,
},
},
{
scope: ["punctuation", "punctuation.bracket"],
style: {
foreground: theme.syntaxPunctuation,
},
},
{
scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin"],
style: {
foreground: theme.error,
},
},
{
scope: ["variable.super"],
style: {
foreground: theme.error,
},
},
{
scope: ["string.escape", "string.regexp"],
style: {
foreground: theme.syntaxKeyword,
},
},
{
scope: ["keyword.directive"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["punctuation.special"],
style: {
foreground: theme.syntaxOperator,
},
},
{
scope: ["keyword.modifier"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["keyword.exception"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
// Markdown specific styles
{
scope: ["markup.heading"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.1"],
style: {
foreground: theme.markdownHeading,
bold: true,
underline: true,
},
},
{
scope: ["markup.heading.2"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.3"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.4"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.5"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.6"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.bold", "markup.strong"],
style: {
foreground: theme.markdownStrong,
bold: true,
},
},
{
scope: ["markup.italic"],
style: {
foreground: theme.markdownEmph,
italic: true,
},
},
{
scope: ["markup.list"],
style: {
foreground: theme.markdownListItem,
},
},
{
scope: ["markup.quote"],
style: {
foreground: theme.markdownBlockQuote,
italic: true,
},
},
{
scope: ["markup.raw", "markup.raw.block"],
style: {
foreground: theme.markdownCode,
},
},
{
scope: ["markup.raw.inline"],
style: {
foreground: theme.markdownCode,
background: theme.background,
},
},
{
scope: ["markup.link"],
style: {
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["markup.link.label"],
style: {
foreground: theme.markdownLinkText,
underline: true,
},
},
{
scope: ["markup.link.url"],
style: {
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["label"],
style: {
foreground: theme.markdownLinkText,
},
},
{
scope: ["spell", "nospell"],
style: {
foreground: theme.text,
},
},
// Additional common highlight groups
{
scope: ["string.special", "string.special.url"],
style: {
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["character"],
style: {
foreground: theme.syntaxString,
},
},
{
scope: ["float"],
style: {
foreground: theme.syntaxNumber,
},
},
{
scope: ["comment.error"],
style: {
foreground: theme.error,
italic: true,
bold: true,
},
},
{
scope: ["comment.warning"],
style: {
foreground: theme.warning,
italic: true,
bold: true,
},
},
{
scope: ["comment.todo", "comment.note"],
style: {
foreground: theme.info,
italic: true,
bold: true,
},
},
{
scope: ["namespace"],
style: {
foreground: theme.syntaxType,
},
},
{
scope: ["field"],
style: {
foreground: theme.syntaxVariable,
},
},
{
scope: ["type.definition"],
style: {
foreground: theme.syntaxType,
bold: true,
},
},
{
scope: ["keyword.export"],
style: {
foreground: theme.syntaxKeyword,
},
},
{
scope: ["attribute", "annotation"],
style: {
foreground: theme.warning,
},
},
{
scope: ["tag"],
style: {
foreground: theme.error,
},
},
{
scope: ["tag.attribute"],
style: {
foreground: theme.syntaxKeyword,
},
},
{
scope: ["tag.delimiter"],
style: {
foreground: theme.syntaxOperator,
},
},
{
scope: ["markup.strikethrough"],
style: {
foreground: theme.textMuted,
},
},
{
scope: ["markup.underline"],
style: {
foreground: theme.text,
underline: true,
},
},
{
scope: ["markup.list.checked"],
style: {
foreground: theme.success,
},
},
{
scope: ["markup.list.unchecked"],
style: {
foreground: theme.textMuted,
},
},
{
scope: ["diff.plus"],
style: {
foreground: theme.diffAdded,
background: theme.diffAddedBg,
},
},
{
scope: ["diff.minus"],
style: {
foreground: theme.diffRemoved,
background: theme.diffRemovedBg,
},
},
{
scope: ["diff.delta"],
style: {
foreground: theme.diffContext,
background: theme.diffContextBg,
},
},
{
scope: ["error"],
style: {
foreground: theme.error,
bold: true,
},
},
{
scope: ["warning"],
style: {
foreground: theme.warning,
bold: true,
},
},
{
scope: ["info"],
style: {
foreground: theme.info,
},
},
{
scope: ["debug"],
style: {
foreground: theme.textMuted,
},
},
]
}
+661
View File
@@ -0,0 +1,661 @@
import { RGBA, SyntaxStyle } from "@opentui/core"
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" }
import catppuccinFrappe from "./assets/catppuccin-frappe.json" with { type: "json" }
import catppuccinMacchiato from "./assets/catppuccin-macchiato.json" with { type: "json" }
import catppuccin from "./assets/catppuccin.json" with { type: "json" }
import cobalt2 from "./assets/cobalt2.json" with { type: "json" }
import cursor from "./assets/cursor.json" with { type: "json" }
import dracula from "./assets/dracula.json" with { type: "json" }
import everforest from "./assets/everforest.json" with { type: "json" }
import flexoki from "./assets/flexoki.json" with { type: "json" }
import github from "./assets/github.json" with { type: "json" }
import gruvbox from "./assets/gruvbox.json" with { type: "json" }
import kanagawa from "./assets/kanagawa.json" with { type: "json" }
import lucentOrng from "./assets/lucent-orng.json" with { type: "json" }
import material from "./assets/material.json" with { type: "json" }
import matrix from "./assets/matrix.json" with { type: "json" }
import mercury from "./assets/mercury.json" with { type: "json" }
import monokai from "./assets/monokai.json" with { type: "json" }
import nightowl from "./assets/nightowl.json" with { type: "json" }
import nord from "./assets/nord.json" with { type: "json" }
import onedark from "./assets/one-dark.json" with { type: "json" }
import opencode from "./assets/opencode.json" with { type: "json" }
import orng from "./assets/orng.json" with { type: "json" }
import osakaJade from "./assets/osaka-jade.json" with { type: "json" }
import palenight from "./assets/palenight.json" with { type: "json" }
import rosepine from "./assets/rosepine.json" with { type: "json" }
import solarized from "./assets/solarized.json" with { type: "json" }
import synthwave84 from "./assets/synthwave84.json" with { type: "json" }
import tokyonight from "./assets/tokyonight.json" with { type: "json" }
import vercel from "./assets/vercel.json" with { type: "json" }
import vesper from "./assets/vesper.json" with { type: "json" }
import zenburn from "./assets/zenburn.json" with { type: "json" }
export type Theme = {
readonly primary: RGBA
readonly secondary: RGBA
readonly accent: RGBA
readonly error: RGBA
readonly warning: RGBA
readonly success: RGBA
readonly info: RGBA
readonly text: RGBA
readonly textMuted: RGBA
readonly selectedListItemText: RGBA
readonly background: RGBA
readonly backgroundPanel: RGBA
readonly backgroundElement: RGBA
readonly backgroundMenu: RGBA
readonly border: RGBA
readonly borderActive: RGBA
readonly borderSubtle: RGBA
readonly diffAdded: RGBA
readonly diffRemoved: RGBA
readonly diffContext: RGBA
readonly diffHunkHeader: RGBA
readonly diffHighlightAdded: RGBA
readonly diffHighlightRemoved: RGBA
readonly diffAddedBg: RGBA
readonly diffRemovedBg: RGBA
readonly diffContextBg: RGBA
readonly diffLineNumber: RGBA
readonly diffAddedLineNumberBg: RGBA
readonly diffRemovedLineNumberBg: RGBA
readonly markdownText: RGBA
readonly markdownHeading: RGBA
readonly markdownLink: RGBA
readonly markdownLinkText: RGBA
readonly markdownCode: RGBA
readonly markdownBlockQuote: RGBA
readonly markdownEmph: RGBA
readonly markdownStrong: RGBA
readonly markdownHorizontalRule: RGBA
readonly markdownListItem: RGBA
readonly markdownListEnumeration: RGBA
readonly markdownImage: RGBA
readonly markdownImageText: RGBA
readonly markdownCodeBlock: RGBA
readonly syntaxComment: RGBA
readonly syntaxKeyword: RGBA
readonly syntaxFunction: RGBA
readonly syntaxVariable: RGBA
readonly syntaxString: RGBA
readonly syntaxNumber: RGBA
readonly syntaxType: RGBA
readonly syntaxOperator: RGBA
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
_hasSelectedListItemText: boolean
}
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type HexColor = `#${string}`
export type RefName = string
export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA
export type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
aura,
ayu,
catppuccin,
["catppuccin-frappe"]: catppuccinFrappe,
["catppuccin-macchiato"]: catppuccinMacchiato,
cobalt2,
cursor,
dracula,
everforest,
flexoki,
github,
gruvbox,
kanagawa,
material,
matrix,
mercury,
monokai,
nightowl,
nord,
["one-dark"]: onedark,
["osaka-jade"]: osakaJade,
opencode,
orng,
["lucent-orng"]: lucentOrng,
palenight,
rosepine,
solarized,
synthwave84,
tokyonight,
vesper,
vercel,
zenburn,
carbonfox,
}
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
if (theme._hasSelectedListItemText) return theme.selectedListItemText
if (theme.background.a === 0) {
const targetColor = bg ?? theme.primary
const { r, g, b } = targetColor
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
}
return theme.background
}
export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
function getSyntaxRules(theme: Theme) {
return [
{
scope: ["default"],
style: {
foreground: theme.text,
},
},
{
scope: ["prompt"],
style: {
foreground: theme.accent,
},
},
{
scope: ["extmark.file"],
style: {
foreground: theme.warning,
bold: true,
},
},
{
scope: ["extmark.agent"],
style: {
foreground: theme.secondary,
bold: true,
},
},
{
scope: ["extmark.paste"],
style: {
foreground: selectedForeground(theme, theme.warning),
background: theme.warning,
bold: true,
},
},
{
scope: ["comment"],
style: {
foreground: theme.syntaxComment,
italic: true,
},
},
{
scope: ["comment.documentation"],
style: {
foreground: theme.syntaxComment,
italic: true,
},
},
{
scope: ["string", "symbol"],
style: {
foreground: theme.syntaxString,
},
},
{
scope: ["number", "boolean"],
style: {
foreground: theme.syntaxNumber,
},
},
{
scope: ["character.special"],
style: {
foreground: theme.syntaxString,
},
},
{
scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["keyword.type"],
style: {
foreground: theme.syntaxType,
bold: true,
italic: true,
},
},
{
scope: ["keyword.function", "function.method"],
style: {
foreground: theme.syntaxFunction,
},
},
{
scope: ["keyword"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["keyword.import"],
style: {
foreground: theme.syntaxKeyword,
},
},
{
scope: ["operator", "keyword.operator", "punctuation.delimiter"],
style: {
foreground: theme.syntaxOperator,
},
},
{
scope: ["keyword.conditional.ternary"],
style: {
foreground: theme.syntaxOperator,
},
},
{
scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
style: {
foreground: theme.syntaxVariable,
},
},
{
scope: ["variable.member", "function", "constructor"],
style: {
foreground: theme.syntaxFunction,
},
},
{
scope: ["type", "module"],
style: {
foreground: theme.syntaxType,
},
},
{
scope: ["constant"],
style: {
foreground: theme.syntaxNumber,
},
},
{
scope: ["property"],
style: {
foreground: theme.syntaxVariable,
},
},
{
scope: ["class"],
style: {
foreground: theme.syntaxType,
},
},
{
scope: ["parameter"],
style: {
foreground: theme.syntaxVariable,
},
},
{
scope: ["punctuation", "punctuation.bracket"],
style: {
foreground: theme.syntaxPunctuation,
},
},
{
scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin"],
style: {
foreground: theme.error,
},
},
{
scope: ["variable.super"],
style: {
foreground: theme.error,
},
},
{
scope: ["string.escape", "string.regexp"],
style: {
foreground: theme.syntaxKeyword,
},
},
{
scope: ["keyword.directive"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["punctuation.special"],
style: {
foreground: theme.syntaxOperator,
},
},
{
scope: ["keyword.modifier"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["keyword.exception"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
// Markdown specific styles
{
scope: ["markup.heading"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.1"],
style: {
foreground: theme.markdownHeading,
bold: true,
underline: true,
},
},
{
scope: ["markup.heading.2"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.3"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.4"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.5"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.heading.6"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.bold", "markup.strong"],
style: {
foreground: theme.markdownStrong,
bold: true,
},
},
{
scope: ["markup.italic"],
style: {
foreground: theme.markdownEmph,
italic: true,
},
},
{
scope: ["markup.list"],
style: {
foreground: theme.markdownListItem,
},
},
{
scope: ["markup.quote"],
style: {
foreground: theme.markdownBlockQuote,
italic: true,
},
},
{
scope: ["markup.raw", "markup.raw.block"],
style: {
foreground: theme.markdownCode,
},
},
{
scope: ["markup.raw.inline"],
style: {
foreground: theme.markdownCode,
background: theme.background,
},
},
{
scope: ["markup.link"],
style: {
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["markup.link.label"],
style: {
foreground: theme.markdownLinkText,
underline: true,
},
},
{
scope: ["markup.link.url"],
style: {
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["label"],
style: {
foreground: theme.markdownLinkText,
},
},
{
scope: ["spell", "nospell"],
style: {
foreground: theme.text,
},
},
// Additional common highlight groups
{
scope: ["string.special", "string.special.url"],
style: {
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["character"],
style: {
foreground: theme.syntaxString,
},
},
{
scope: ["float"],
style: {
foreground: theme.syntaxNumber,
},
},
{
scope: ["comment.error"],
style: {
foreground: theme.error,
italic: true,
bold: true,
},
},
{
scope: ["comment.warning"],
style: {
foreground: theme.warning,
italic: true,
bold: true,
},
},
{
scope: ["comment.todo", "comment.note"],
style: {
foreground: theme.info,
italic: true,
bold: true,
},
},
{
scope: ["namespace"],
style: {
foreground: theme.syntaxType,
},
},
{
scope: ["field"],
style: {
foreground: theme.syntaxVariable,
},
},
{
scope: ["type.definition"],
style: {
foreground: theme.syntaxType,
bold: true,
},
},
{
scope: ["keyword.export"],
style: {
foreground: theme.syntaxKeyword,
},
},
{
scope: ["attribute", "annotation"],
style: {
foreground: theme.warning,
},
},
{
scope: ["tag"],
style: {
foreground: theme.error,
},
},
{
scope: ["tag.attribute"],
style: {
foreground: theme.syntaxKeyword,
},
},
{
scope: ["tag.delimiter"],
style: {
foreground: theme.syntaxOperator,
},
},
{
scope: ["markup.strikethrough"],
style: {
foreground: theme.textMuted,
},
},
{
scope: ["markup.underline"],
style: {
foreground: theme.text,
underline: true,
},
},
{
scope: ["markup.list.checked"],
style: {
foreground: theme.success,
},
},
{
scope: ["markup.list.unchecked"],
style: {
foreground: theme.textMuted,
},
},
{
scope: ["diff.plus"],
style: {
foreground: theme.diffAdded,
background: theme.diffAddedBg,
},
},
{
scope: ["diff.minus"],
style: {
foreground: theme.diffRemoved,
background: theme.diffRemovedBg,
},
},
{
scope: ["diff.delta"],
style: {
foreground: theme.diffContext,
background: theme.diffContextBg,
},
},
{
scope: ["error"],
style: {
foreground: theme.error,
bold: true,
},
},
{
scope: ["warning"],
style: {
foreground: theme.warning,
bold: true,
},
},
{
scope: ["info"],
style: {
foreground: theme.info,
},
},
{
scope: ["debug"],
style: {
foreground: theme.textMuted,
},
},
]
}
+106
View File
@@ -0,0 +1,106 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { ActionState, ActionVariant, ResolvedActionState, ResolvedThemeView } from "./index"
export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
const textAction = actions((variant, state) => current().color.text.action[variant][state])
const backgroundAction = actions((variant, state) => current().color.background.action[variant][state])
const text = Object.assign(() => current().color.text.default, {
subdued: () => current().color.text.subdued,
action: textAction,
feedback: {
error: feedbackText("error"),
warning: feedbackText("warning"),
success: feedbackText("success"),
info: feedbackText("info"),
},
})
const background = Object.assign(() => current().color.background.default, {
action: backgroundAction,
feedback: {
error: () => current().color.background.feedback.error.default,
warning: () => current().color.background.feedback.warning.default,
success: () => current().color.background.feedback.success.default,
info: () => current().color.background.feedback.info.default,
},
})
const markdown = Object.assign(() => current().color.markdown.text, {
heading: () => current().color.markdown.heading,
link: () => current().color.markdown.link,
linkText: () => current().color.markdown.linkText,
code: () => current().color.markdown.code,
blockQuote: () => current().color.markdown.blockQuote,
emphasis: () => current().color.markdown.emphasis,
strong: () => current().color.markdown.strong,
horizontalRule: () => current().color.markdown.horizontalRule,
listItem: () => current().color.markdown.listItem,
listEnumeration: () => current().color.markdown.listEnumeration,
image: () => current().color.markdown.image,
imageText: () => current().color.markdown.imageText,
codeBlock: () => current().color.markdown.codeBlock,
})
function feedbackText(kind: "error" | "warning" | "success" | "info") {
return Object.assign(() => current().color.text.feedback[kind].default, {
subdued: () => current().color.text.feedback[kind].subdued,
})
}
return {
hue: () => current().hue,
color: {
text,
background,
border: () => current().color.border.default,
scrollbar: () => current().color.scrollbar.default,
diff: {
text: {
added: () => current().color.diff.text.added,
removed: () => current().color.diff.text.removed,
context: () => current().color.diff.text.context,
hunkHeader: () => current().color.diff.text.hunkHeader,
},
background: {
added: () => current().color.diff.background.added,
removed: () => current().color.diff.background.removed,
context: () => current().color.diff.background.context,
},
highlight: {
added: () => current().color.diff.highlight.added,
removed: () => current().color.diff.highlight.removed,
},
lineNumber: {
text: () => current().color.diff.lineNumber.text,
background: {
added: () => current().color.diff.lineNumber.background.added,
removed: () => current().color.diff.lineNumber.background.removed,
},
},
},
syntax: {
comment: () => current().color.syntax.comment,
keyword: () => current().color.syntax.keyword,
function: () => current().color.syntax.function,
variable: () => current().color.syntax.variable,
string: () => current().color.syntax.string,
number: () => current().color.syntax.number,
type: () => current().color.syntax.type,
operator: () => current().color.syntax.operator,
punctuation: () => current().color.syntax.punctuation,
},
markdown,
},
}
}
function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) {
const action = (variant: ActionVariant) => (state: ActionState | "default" = "default") => get(variant, state)
const primary = action("primary")
return Object.assign(primary, {
primary,
secondary: action("secondary"),
destructive: action("destructive"),
})
}
export type ComponentTheme = ReturnType<typeof createComponentTheme>
+391
View File
@@ -0,0 +1,391 @@
import type { ThemeFile } from "./index"
export const DEFAULT_THEME = {
version: 2,
light: {
hue: {
gray: {
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
red: {
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
orange: {
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#f97316",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
},
yellow: {
100: "#fef9c3",
200: "#fef08a",
300: "#fde047",
400: "#facc15",
500: "#eab308",
600: "#ca8a04",
700: "#a16207",
800: "#854d0e",
900: "#713f12",
},
green: {
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
},
cyan: {
100: "#cffafe",
200: "#a5f3fc",
300: "#67e8f9",
400: "#22d3ee",
500: "#06b6d4",
600: "#0891b2",
700: "#0e7490",
800: "#155e75",
900: "#164e63",
},
blue: {
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
purple: {
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
accent: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
},
},
background: {
default: "$hue.neutral.100",
action: {
primary: {
default: "$hue.accent.600", $hovered: "$hue.accent.700", $pressed: "$hue.accent.800",
$selected: "$hue.accent.700", $disabled: "$hue.neutral.300",
},
secondary: {
default: "$hue.neutral.200", $hovered: "$hue.neutral.300", $pressed: "$hue.neutral.400",
$selected: "$hue.neutral.300", $disabled: "$hue.neutral.200",
},
destructive: {
default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
$selected: "$hue.red.700", $disabled: "$hue.neutral.300",
},
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
},
},
border: { default: "$hue.neutral.300" },
scrollbar: { default: "$hue.neutral.400" },
diff: {
text: {
added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900",
hunkHeader: "$hue.purple.600",
},
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
},
syntax: {
comment: "$hue.neutral.600",
keyword: "$hue.purple.600",
function: "$hue.accent.600",
variable: "$hue.neutral.900",
string: "$hue.green.700",
number: "$hue.yellow.800",
type: "$hue.yellow.500",
operator: "$hue.cyan.600",
punctuation: "$hue.neutral.900",
},
markdown: {
text: "$hue.neutral.900",
heading: "$hue.purple.600",
link: "$hue.accent.600",
linkText: "$hue.cyan.600",
code: "$hue.green.700",
blockQuote: "$hue.neutral.600",
emphasis: "$hue.yellow.500",
strong: "$hue.neutral.900",
horizontalRule: "$hue.neutral.300",
listItem: "$hue.accent.600",
listEnumeration: "$hue.cyan.600",
image: "$hue.accent.600",
imageText: "$hue.cyan.600",
codeBlock: "$hue.neutral.900",
},
},
"@context:elevated": {
color: {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$hue.neutral.200",
action: { primary: { default: "$hue.accent.500" } },
},
},
},
"@context:overlay": {
color: {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$hue.neutral.300",
action: { primary: { default: "$hue.accent.500" } },
},
},
},
},
dark: {
hue: {
gray: {
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
red: {
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
orange: {
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#f97316",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
},
yellow: {
100: "#fef9c3",
200: "#fef08a",
300: "#fde047",
400: "#facc15",
500: "#eab308",
600: "#ca8a04",
700: "#a16207",
800: "#854d0e",
900: "#713f12",
},
green: {
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
},
cyan: {
100: "#cffafe",
200: "#a5f3fc",
300: "#67e8f9",
400: "#22d3ee",
500: "#06b6d4",
600: "#0891b2",
700: "#0e7490",
800: "#155e75",
900: "#164e63",
},
blue: {
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
purple: {
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
accent: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
default: "$hue.neutral.100",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
},
},
background: {
default: "$hue.neutral.900",
action: {
primary: {
default: "$hue.accent.500", $hovered: "$hue.accent.600", $pressed: "$hue.accent.800",
$selected: "$hue.accent.600", $disabled: "$hue.neutral.800",
},
secondary: {
default: "$hue.neutral.800", $hovered: "$hue.neutral.700", $pressed: "$hue.neutral.900",
$selected: "$hue.neutral.700", $disabled: "$hue.neutral.900",
},
destructive: {
default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
$selected: "$hue.red.700", $disabled: "$hue.neutral.800",
},
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
},
},
border: { default: "$hue.neutral.700" },
scrollbar: { default: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
},
syntax: {
comment: "$hue.neutral.400",
keyword: "$hue.purple.400",
function: "$hue.accent.400",
variable: "$hue.neutral.100",
string: "$hue.green.300",
number: "$hue.yellow.200",
type: "$hue.yellow.500",
operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100",
},
markdown: {
text: "$hue.neutral.100",
heading: "$hue.purple.400",
link: "$hue.accent.400",
linkText: "$hue.cyan.400",
code: "$hue.green.300",
blockQuote: "$hue.neutral.400",
emphasis: "$hue.yellow.500",
strong: "$hue.neutral.100",
horizontalRule: "$hue.neutral.700",
listItem: "$hue.accent.400",
listEnumeration: "$hue.cyan.400",
image: "$hue.accent.400",
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
},
"@context:elevated": {
color: {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$hue.neutral.800",
action: { primary: { default: "$hue.accent.400" } },
},
},
},
"@context:overlay": {
color: {
text: { action: { primary: { default: "$hue.neutral.900" } } },
background: {
default: "$hue.neutral.700",
action: { primary: { default: "$hue.accent.400" } },
},
},
},
},
} satisfies ThemeFile

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