mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25a4f833cf | |||
| 04fdf59db8 | |||
| 83cfafc884 | |||
| 09cd7445f1 | |||
| 39f1336621 | |||
| ce636daf04 | |||
| c1d2d7aba3 | |||
| e8964ce672 | |||
| 00093c70c1 | |||
| 7eb7fe0763 | |||
| 2cb2f9d538 | |||
| 94a23b8537 | |||
| ff3442ce34 | |||
| 72cf7f12f3 | |||
| ba1f3d3d32 | |||
| 05fdbcce04 | |||
| 387bff8fd9 | |||
| 9a25673c37 | |||
| 563f6a65de | |||
| fca3bca19d | |||
| d88faeb6da |
@@ -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.
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -218,7 +218,7 @@ function shellCommit(
|
||||
kind: "tool",
|
||||
source: "tool",
|
||||
partID: `shell:${callID}`,
|
||||
tool: "bash",
|
||||
tool: "shell",
|
||||
shell: { callID, command },
|
||||
...next,
|
||||
}
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Definition } from "./tool.js"
|
||||
|
||||
export type Tools<R = never> = {
|
||||
readonly [name: string]: Definition<R> | Tools<R>
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()}`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Vendored
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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..." }
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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} />)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user