Compare commits

...
Sign in to create a new pull request.

9 commits

Author SHA1 Message Date
Kit Langton
f0e207eb01 feat(tui): add terminal notifications
Surface response-ready and attention-needed TUI events through configurable OSC or bell signals so users can opt into built-in terminal notifications without plugins.
2026-04-17 20:47:06 -04:00
opencode-agent[bot]
6b7f34df20 chore: generate 2026-04-17 23:56:50 +00:00
Kit Langton
f3d1fd9ce8
feat(effect-zod): transform support + walk memoization + flattened checks (#23203) 2026-04-17 23:55:55 +00:00
opencode-agent[bot]
280b9d4c80 chore: generate 2026-04-17 23:30:51 +00:00
Kit Langton
0c1ffc6fa9
refactor(config): migrate provider (Model + Info) to Effect Schema (#23197) 2026-04-17 23:29:53 +00:00
Kit Langton
8d2d871a58
refactor(server): align route-span attrs with OTel semantic conventions (#23198) 2026-04-17 23:29:33 +00:00
Kit Langton
1eafb2160a
feat(effect-zod): add catchall (StructWithRest) support to the walker (#23186) 2026-04-17 19:10:34 -04:00
Kit Langton
2b73a08916
feat(tui): show session ID in sidebar on non-prod channels (#23185) 2026-04-17 22:47:48 +00:00
Kit Langton
11c0ad24aa
feat(server): auto-tag route spans with route params (session.id, message.id, …) (#23189) 2026-04-17 22:43:10 +00:00
16 changed files with 729 additions and 153 deletions

View file

@ -1,5 +1,6 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard" import * as Clipboard from "@tui/util/clipboard"
import * as Notify from "@tui/util/notify"
import * as Selection from "@tui/util/selection" import * as Selection from "@tui/util/selection"
import * as Terminal from "@tui/util/terminal" import * as Terminal from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
@ -58,8 +59,11 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Permission } from "@/permission"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { Question } from "@/question"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
import { SessionStatus } from "@/session/status"
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
@ -781,6 +785,31 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}) })
}) })
const notificationMethod = tuiConfig.notification_method ?? "off"
const notifySession = (sessionID: string, prefix: string) => {
const session = sync.session.get(sessionID)
if (session?.parentID) return
Notify.notifyTerminal({
method: notificationMethod,
title: "OpenCode",
body: `${prefix}: ${session?.title ?? sessionID}`,
})
}
event.subscribe((evt) => {
if (notificationMethod === "off") return
if (evt.type === SessionStatus.Event.Idle.type) {
notifySession(evt.properties.sessionID, "Response ready")
return
}
if (evt.type === Permission.Event.Asked.type) {
notifySession(evt.properties.sessionID, "Permission required")
return
}
if (evt.type !== Question.Event.Asked.type) return
notifySession(evt.properties.sessionID, "Question asked")
})
event.on("installation.update-available", async (evt) => { event.on("installation.update-available", async (evt) => {
const version = evt.properties.version const version = evt.properties.version

View file

@ -20,6 +20,7 @@ const TuiLegacy = z
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined), scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined), scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
diff_style: TuiOptions.shape.diff_style.catch(undefined), diff_style: TuiOptions.shape.diff_style.catch(undefined),
notification_method: TuiOptions.shape.notification_method.catch(undefined),
}) })
.strip() .strip()
@ -89,7 +90,8 @@ function normalizeTui(data: Record<string, unknown>) {
if ( if (
parsed.scroll_speed === undefined && parsed.scroll_speed === undefined &&
parsed.diff_style === undefined && parsed.diff_style === undefined &&
parsed.scroll_acceleration === undefined parsed.scroll_acceleration === undefined &&
parsed.notification_method === undefined
) { ) {
return return
} }

View file

@ -1,6 +1,7 @@
import z from "zod" import z from "zod"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds" import { ConfigKeybinds } from "@/config/keybinds"
import { NOTIFICATION_METHODS } from "../util/notify"
const KeybindOverride = z const KeybindOverride = z
.object( .object(
@ -24,6 +25,10 @@ export const TuiOptions = z.object({
.optional() .optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"), mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
notification_method: z
.enum(NOTIFICATION_METHODS)
.optional()
.describe("Select how terminal notifications are emitted for response-ready and attention-needed events"),
}) })
export const TuiInfo = z export const TuiInfo = z

View file

@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
import { InstallationVersion } from "@/installation/version" import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin" import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
@ -62,6 +62,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}> <text fg={theme.text}>
<b>{session()!.title}</b> <b>{session()!.title}</b>
</text> </text>
<Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<Show when={session()!.workspaceID}> <Show when={session()!.workspaceID}>
<text fg={theme.textMuted}> <text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "} <span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}

View file

@ -5,6 +5,7 @@ import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import * as Filesystem from "../../../../util/filesystem" import * as Filesystem from "../../../../util/filesystem"
import * as Process from "../../../../util/process" import * as Process from "../../../../util/process"
import { wrapOscSequence } from "./osc"
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup // Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
const getWhich = lazy(async () => { const getWhich = lazy(async () => {
@ -25,10 +26,7 @@ const getClipboardy = lazy(async () => {
function writeOsc52(text: string): void { function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64") const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07` process.stdout.write(wrapOscSequence(`\x1b]52;c;${base64}\x07`))
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
} }
export interface Content { export interface Content {

View file

@ -0,0 +1,71 @@
import { wrapOscSequence } from "./osc"
const MAX_LENGTH = 180
export const NOTIFICATION_METHODS = ["auto", "osc9", "osc777", "bell", "off"] as const
export type NotificationMethod = (typeof NOTIFICATION_METHODS)[number]
export function resolveNotificationMethod(
method: NotificationMethod | undefined,
env: NodeJS.ProcessEnv = process.env,
): Exclude<NotificationMethod, "auto"> {
if (method && method !== "auto") return method
if (env.TERM_PROGRAM === "vscode") return "bell"
if (env.KITTY_WINDOW_ID || env.TERM === "xterm-kitty") return "osc777"
if (env.TERM_PROGRAM === "WezTerm") return "osc777"
if (env.VTE_VERSION || env.TERM?.startsWith("foot")) return "osc777"
if (env.TERM_PROGRAM === "iTerm.app") return "osc9"
if (env.TERM_PROGRAM === "ghostty") return "osc9"
if (env.TERM_PROGRAM === "Apple_Terminal") return "osc9"
if (env.TERM_PROGRAM === "WarpTerminal") return "osc9"
if (env.WT_SESSION) return "bell"
return "bell"
}
export function sanitizeNotificationText(value: string) {
return value
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
.replace(/;/g, ":")
.replace(/\s+/g, " ")
.trim()
.slice(0, MAX_LENGTH)
}
export function formatNotificationSequence(input: {
method: Exclude<NotificationMethod, "auto">
title: string
body?: string
}) {
if (input.method === "off") return ""
if (input.method === "bell") return "\x07"
if (input.method === "osc9") {
return `\x1b]9;${sanitizeNotificationText([input.title, input.body].filter(Boolean).join(": "))}\x07`
}
return `\x1b]777;notify;${sanitizeNotificationText(input.title)};${sanitizeNotificationText(input.body ?? "")}\x07`
}
export function notifyTerminal(input: {
title: string
body?: string
method?: NotificationMethod
env?: NodeJS.ProcessEnv
write?: (chunk: string) => void
}) {
const env = input.env ?? process.env
const method = resolveNotificationMethod(input.method, env)
const sequence = wrapOscSequence(
formatNotificationSequence({
method,
title: input.title,
body: input.body,
}),
env,
)
if (!sequence) return false
const write =
input.write ??
((chunk: string) => (process.stderr.isTTY ? process.stderr.write(chunk) : process.stdout.write(chunk)))
write(sequence)
return true
}

View file

@ -0,0 +1,5 @@
export function wrapOscSequence(sequence: string, env: NodeJS.ProcessEnv = process.env) {
if (!sequence) return sequence
if (!env.TMUX && !env.STY) return sequence
return `\x1bPtmux;${sequence.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`
}

View file

@ -171,7 +171,7 @@ export const Info = z
.optional() .optional()
.describe("Agent configuration, see https://opencode.ai/docs/agents"), .describe("Agent configuration, see https://opencode.ai/docs/agents"),
provider: z provider: z
.record(z.string(), ConfigProvider.Info) .record(z.string(), ConfigProvider.Info.zod)
.optional() .optional()
.describe("Custom provider configurations and model overrides"), .describe("Custom provider configurations and model overrides"),
mcp: z mcp: z

View file

@ -1,120 +1,118 @@
import { Schema } from "effect"
import z from "zod" import z from "zod"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Model = z // Positive integer preserving exact Zod JSON Schema (type: integer, exclusiveMinimum: 0).
.object({ const PositiveInt = Schema.Number.annotate({
id: z.string(), [ZodOverride]: z.number().int().positive(),
name: z.string(), })
family: z.string().optional(),
release_date: z.string(), export const Model = Schema.Struct({
attachment: z.boolean(), id: Schema.optional(Schema.String),
reasoning: z.boolean(), name: Schema.optional(Schema.String),
temperature: z.boolean(), family: Schema.optional(Schema.String),
tool_call: z.boolean(), release_date: Schema.optional(Schema.String),
interleaved: z attachment: Schema.optional(Schema.Boolean),
.union([ reasoning: Schema.optional(Schema.Boolean),
z.literal(true), temperature: Schema.optional(Schema.Boolean),
z tool_call: Schema.optional(Schema.Boolean),
.object({ interleaved: Schema.optional(
field: z.enum(["reasoning_content", "reasoning_details"]), Schema.Union([
}) Schema.Literal(true),
.strict(), Schema.Struct({
]) field: Schema.Literals(["reasoning_content", "reasoning_details"]),
.optional(), }),
cost: z ]),
.object({ ),
input: z.number(), cost: Schema.optional(
output: z.number(), Schema.Struct({
cache_read: z.number().optional(), input: Schema.Number,
cache_write: z.number().optional(), output: Schema.Number,
context_over_200k: z cache_read: Schema.optional(Schema.Number),
.object({ cache_write: Schema.optional(Schema.Number),
input: z.number(), context_over_200k: Schema.optional(
output: z.number(), Schema.Struct({
cache_read: z.number().optional(), input: Schema.Number,
cache_write: z.number().optional(), output: Schema.Number,
}) cache_read: Schema.optional(Schema.Number),
.optional(), cache_write: Schema.optional(Schema.Number),
}) }),
.optional(), ),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
}), }),
modalities: z ),
.object({ limit: Schema.optional(
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), Schema.Struct({
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), context: Schema.Number,
}) input: Schema.optional(Schema.Number),
.optional(), output: Schema.Number,
experimental: z.boolean().optional(), }),
status: z.enum(["alpha", "beta", "deprecated"]).optional(), ),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), modalities: Schema.optional(
options: z.record(z.string(), z.any()), Schema.Struct({
headers: z.record(z.string(), z.string()).optional(), input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
variants: z output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
.record( }),
z.string(), ),
z experimental: Schema.optional(Schema.Boolean),
.object({ status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
disabled: z.boolean().optional().describe("Disable this variant for the model"), provider: Schema.optional(
}) Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
.catchall(z.any()), ),
) options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
.optional() headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
.describe("Variant-specific configuration"), variants: Schema.optional(
}) Schema.Record(
.partial() Schema.String,
Schema.StructWithRest(
Schema.Struct({
disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }),
}),
[Schema.Record(Schema.String, Schema.Any)],
),
).annotate({ description: "Variant-specific configuration" }),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Info = z export class Info extends Schema.Class<Info>("ProviderConfig")({
.object({ api: Schema.optional(Schema.String),
api: z.string().optional(), name: Schema.optional(Schema.String),
name: z.string(), env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
env: z.array(z.string()), id: Schema.optional(Schema.String),
id: z.string(), npm: Schema.optional(Schema.String),
npm: z.string().optional(), whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
whitelist: z.array(z.string()).optional(), blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
blacklist: z.array(z.string()).optional(), options: Schema.optional(
options: z Schema.StructWithRest(
.object({ Schema.Struct({
apiKey: z.string().optional(), apiKey: Schema.optional(Schema.String),
baseURL: z.string().optional(), baseURL: Schema.optional(Schema.String),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), enterpriseUrl: Schema.optional(Schema.String).annotate({
setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), description: "GitHub Enterprise URL for copilot authentication",
timeout: z }),
.union([ setCacheKey: Schema.optional(Schema.Boolean).annotate({
z description: "Enable promptCacheKey for this provider (default false)",
.number() }),
.int() timeout: Schema.optional(
.positive() Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
.describe( description:
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
), }),
z.literal(false).describe("Disable timeout for this provider entirely."), ).annotate({
]) description:
.optional()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
), }),
chunkTimeout: z chunkTimeout: Schema.optional(PositiveInt).annotate({
.number() description:
.int()
.positive()
.optional()
.describe(
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
), }),
}) }),
.catchall(z.any()) [Schema.Record(Schema.String, Schema.Any)],
.optional(), ),
models: z.record(z.string(), Model).optional(), ),
}) models: Schema.optional(Schema.Record(Schema.String, Model)),
.partial() }) {
.strict() static readonly zod = zod(this)
.meta({ }
ref: "ProviderConfig",
})
export type Info = z.infer<typeof Info>
export * as ConfigProvider from "./provider" export * as ConfigProvider from "./provider"

View file

@ -4,18 +4,44 @@ import { AppRuntime } from "@/effect/app-runtime"
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
// Build the base span attributes for an HTTP handler: method, path, and every
// matched route param. Names follow OTel attribute-naming guidance:
// domain-first (`session.id`, `message.id`, …) so they match the existing
// OTel `session.id` semantic convention and the bare `message.id` we
// already emit from Tool.execute. Non-standard route params fall back to
// `opencode.<name>` since those are internal implementation details
// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
export interface RequestLike {
readonly req: {
readonly method: string
readonly url: string
param(): Record<string, string>
}
}
// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
// other param is namespaced under `opencode.` to avoid colliding with
// standard conventions.
export function paramToAttributeKey(key: string): string {
const m = key.match(/^(.+)ID$/)
if (m) return `${m[1].toLowerCase()}.id`
return `opencode.${key}`
}
export function requestAttributes(c: RequestLike): Record<string, string> {
const attributes: Record<string, string> = {
"http.method": c.req.method,
"http.path": new URL(c.req.url).pathname,
}
for (const [key, value] of Object.entries(c.req.param())) {
attributes[paramToAttributeKey(key)] = value
}
return attributes
}
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) { export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
const url = new URL(c.req.url) return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
return AppRuntime.runPromise(
effect.pipe(
Effect.withSpan(name, {
attributes: {
"http.method": c.req.method,
"http.path": url.pathname,
},
}),
),
)
} }
export async function jsonRequest<C extends Context, A, E>( export async function jsonRequest<C extends Context, A, E>(

View file

@ -1,4 +1,4 @@
import { Schema, SchemaAST } from "effect" import { Effect, Option, Schema, SchemaAST } from "effect"
import z from "zod" import z from "zod"
/** /**
@ -8,33 +8,90 @@ import z from "zod"
*/ */
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
// AST nodes are immutable and frequently shared across schemas (e.g. a single
// Schema.Class embedded in multiple parents). Memoizing by node identity
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
// by reference across callers.
const walkCache = new WeakMap<SchemaAST.AST, z.ZodTypeAny>()
// Shared empty ParseOptions for the rare callers that need one — avoids
// allocating a fresh object per parse inside refinements and transforms.
const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> { export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>> return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
} }
function walk(ast: SchemaAST.AST): z.ZodTypeAny { function walk(ast: SchemaAST.AST): z.ZodTypeAny {
const cached = walkCache.get(ast)
if (cached) return cached
const result = walkUncached(ast)
walkCache.set(ast, result)
return result
}
function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
if (override) return override if (override) return override
let out = body(ast) // Schema.Class wraps its fields in a Declaration AST plus an encoding that
for (const check of ast.checks ?? []) { // constructs the class instance. For the Zod derivation we want the plain
out = applyCheck(out, check, ast) // field shape (the decoded/consumer view), not the class instance — so
} // Declarations fall through to body(), not encoded(). User-level
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
// nodes, where we do apply the transform.
const hasTransform = ast.encoding?.length && ast._tag !== "Declaration"
const base = hasTransform ? encoded(ast) : body(ast)
const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
const desc = SchemaAST.resolveDescription(ast) const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast) const ref = SchemaAST.resolveIdentifier(ast)
const next = desc ? out.describe(desc) : out const described = desc ? out.describe(desc) : out
return ref ? next.meta({ ref }) : next return ref ? described.meta({ ref }) : described
} }
function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check<any>, ast: SchemaAST.AST): z.ZodTypeAny { // Walk the encoded side and apply each link's decode to produce the decoded
if (check._tag === "FilterGroup") { // shape. A node `Target` produced by `from.decodeTo(Target)` carries
return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out) // `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls
// nest the encoding via `Link.to` so walking it recursively threads all
// prior transforms — typical encoding.length is 1.
function encoded(ast: SchemaAST.AST): z.ZodTypeAny {
const encoding = ast.encoding!
return encoding.reduce<z.ZodTypeAny>(
(acc, link) => acc.transform((v) => decode(link.transformation, v)),
walk(encoding[0].to),
)
}
// Transformations built via pure `SchemaGetter.transform(fn)` (the common
// decodeTo case) resolve synchronously, so running with no services is safe.
// Effectful / middleware-based transforms will surface as Effect defects.
function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown {
const exit = Effect.runSyncExit(
(transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect<
Option.Option<unknown>
>,
)
if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`)
return Option.getOrElse(exit.value, () => value)
}
// Flatten FilterGroups and any nested variants into a linear list of Filters
// so we can run all of them inside a single Zod .superRefine wrapper instead
// of stacking N wrapper layers (one per check).
function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
const filters: SchemaAST.Filter<unknown>[] = []
const collect = (c: SchemaAST.Check<unknown>) => {
if (c._tag === "FilterGroup") c.checks.forEach(collect)
else filters.push(c)
} }
checks.forEach(collect)
return out.superRefine((value, ctx) => { return out.superRefine((value, ctx) => {
const issue = check.run(value, ast, {} as any) for (const filter of filters) {
if (!issue) return const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed" if (!issue) continue
ctx.addIssue({ code: "custom", message }) const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
ctx.addIssue({ code: "custom", message })
}
}) })
} }
@ -107,15 +164,27 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
} }
function object(ast: SchemaAST.Objects): z.ZodTypeAny { function object(ast: SchemaAST.Objects): z.ZodTypeAny {
// Pure record: { [k: string]: V }
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) { if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
const sig = ast.indexSignatures[0] const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast) if (sig.parameter._tag !== "String") return fail(ast)
return z.record(z.string(), walk(sig.type)) return z.record(z.string(), walk(sig.type))
} }
if (ast.indexSignatures.length > 0) return fail(ast) // Pure object with known fields and no index signatures.
if (ast.indexSignatures.length === 0) {
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
}
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) // Struct with a catchall (StructWithRest): known fields + index signature.
// Only supports a single string-keyed index signature; multi-signature or
// symbol/number keys fall through to fail.
if (ast.indexSignatures.length !== 1) return fail(ast)
const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast)
return z
.object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
.catchall(walk(sig.type))
} }
function array(ast: SchemaAST.Arrays): z.ZodTypeAny { function array(ast: SchemaAST.Arrays): z.ZodTypeAny {

View file

@ -0,0 +1,47 @@
import { expect, test } from "bun:test"
const { formatNotificationSequence, notifyTerminal, resolveNotificationMethod, sanitizeNotificationText } =
await import("../../../src/cli/cmd/tui/util/notify")
const { wrapOscSequence } = await import("../../../src/cli/cmd/tui/util/osc")
test("resolveNotificationMethod picks osc9 for iTerm", () => {
expect(resolveNotificationMethod("auto", { TERM_PROGRAM: "iTerm.app" })).toBe("osc9")
})
test("resolveNotificationMethod picks osc777 for kitty and bell for vscode", () => {
expect(resolveNotificationMethod("auto", { KITTY_WINDOW_ID: "1" })).toBe("osc777")
expect(resolveNotificationMethod("auto", { TERM_PROGRAM: "vscode" })).toBe("bell")
})
test("sanitizeNotificationText removes controls and semicolons", () => {
expect(sanitizeNotificationText("hello;\nworld\x07")).toBe("hello: world")
})
test("formatNotificationSequence emits osc9 and osc777 payloads", () => {
expect(formatNotificationSequence({ method: "osc9", title: "OpenCode", body: "Response ready" })).toBe(
"\x1b]9;OpenCode: Response ready\x07",
)
expect(formatNotificationSequence({ method: "osc777", title: "OpenCode", body: "Permission required" })).toBe(
"\x1b]777;notify;OpenCode;Permission required\x07",
)
})
test("wrapOscSequence escapes OSC sequences for passthrough", () => {
expect(wrapOscSequence("\x1b]9;done\x07", { TMUX: "/tmp/tmux" })).toBe("\x1bPtmux;\x1b\x1b]9;done\x07\x1b\\")
})
test("notifyTerminal writes the resolved sequence", () => {
let output = ""
expect(
notifyTerminal({
title: "OpenCode",
body: "Question asked",
method: "auto",
env: { TERM_PROGRAM: "ghostty" },
write: (chunk) => {
output += chunk
},
}),
).toBe(true)
expect(output).toBe("\x1b]9;OpenCode: Question asked\x07")
})

View file

@ -624,3 +624,39 @@ test("merges plugin_enabled flags across config layers", async () => {
"local.plugin": true, "local.plugin": true,
}) })
}) })
test("loads notification config from tui.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ notification_method: "osc777" }, null, 2))
},
})
const config = await getTuiConfig(tmp.path)
expect(config.notification_method).toBe("osc777")
})
test("migrates legacy notification settings from opencode.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
tui: { notification_method: "bell" },
},
null,
2,
),
)
},
})
const config = await getTuiConfig(tmp.path)
expect(config.notification_method).toBe("bell")
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
expect(JSON.parse(text)).toMatchObject({
notification_method: "bell",
})
})

View file

@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test"
import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace"
function fakeContext(method: string, url: string, params: Record<string, string>) {
return {
req: {
method,
url,
param: () => params,
},
}
}
describe("paramToAttributeKey", () => {
test("converts fooID to foo.id", () => {
expect(paramToAttributeKey("sessionID")).toBe("session.id")
expect(paramToAttributeKey("messageID")).toBe("message.id")
expect(paramToAttributeKey("partID")).toBe("part.id")
expect(paramToAttributeKey("projectID")).toBe("project.id")
expect(paramToAttributeKey("providerID")).toBe("provider.id")
expect(paramToAttributeKey("ptyID")).toBe("pty.id")
expect(paramToAttributeKey("permissionID")).toBe("permission.id")
expect(paramToAttributeKey("requestID")).toBe("request.id")
expect(paramToAttributeKey("workspaceID")).toBe("workspace.id")
})
test("namespaces non-ID params under opencode.", () => {
expect(paramToAttributeKey("name")).toBe("opencode.name")
expect(paramToAttributeKey("slug")).toBe("opencode.slug")
})
})
describe("requestAttributes", () => {
test("includes http method and path", () => {
const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {}))
expect(attrs["http.method"]).toBe("GET")
expect(attrs["http.path"]).toBe("/session")
})
test("strips query string from path", () => {
const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {}))
expect(attrs["http.path"]).toBe("/file/search")
})
test("emits OTel-style <domain>.id for ID-shaped route params", () => {
const attrs = requestAttributes(
fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", {
sessionID: "ses_abc",
messageID: "msg_def",
partID: "prt_ghi",
}),
)
expect(attrs["session.id"]).toBe("ses_abc")
expect(attrs["message.id"]).toBe("msg_def")
expect(attrs["part.id"]).toBe("prt_ghi")
// No camelCase leftovers:
expect(attrs["opencode.sessionID"]).toBeUndefined()
expect(attrs["opencode.messageID"]).toBeUndefined()
expect(attrs["opencode.partID"]).toBeUndefined()
})
test("produces no param attributes when no params are matched", () => {
const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {}))
expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([])
})
test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => {
const attrs = requestAttributes(
fakeContext("POST", "http://localhost/mcp/exa/connect", {
name: "exa",
}),
)
expect(attrs["opencode.name"]).toBe("exa")
expect(attrs["name"]).toBeUndefined()
})
})

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Schema } from "effect" import { Schema, SchemaGetter } from "effect"
import z from "zod" import z from "zod"
import { zod, ZodOverride } from "../../src/util/effect-zod" import { zod, ZodOverride } from "../../src/util/effect-zod"
@ -263,4 +263,219 @@ describe("util.effect-zod", () => {
expect(result.error!.issues[0].message).toBe("missing 'required' key") expect(result.error!.issues[0].message).toBe("missing 'required' key")
}) })
}) })
describe("StructWithRest / catchall", () => {
test("struct with a string-keyed record rest parses known AND extra keys", () => {
const schema = zod(
Schema.StructWithRest(
Schema.Struct({
apiKey: Schema.optional(Schema.String),
baseURL: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
)
// Known fields come through as declared
expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" })
// Extra keys are preserved (catchall)
expect(
schema.parse({
apiKey: "sk-x",
baseURL: "https://api.example.com",
customField: "anything",
nested: { foo: 1 },
}),
).toEqual({
apiKey: "sk-x",
baseURL: "https://api.example.com",
customField: "anything",
nested: { foo: 1 },
})
})
test("catchall value type constrains the extras", () => {
const schema = zod(
Schema.StructWithRest(
Schema.Struct({
count: Schema.Number,
}),
[Schema.Record(Schema.String, Schema.Number)],
),
)
// Known field + numeric extras
expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 })
// Non-numeric extra is rejected
expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false)
})
test("JSON schema output marks additionalProperties appropriately", () => {
const schema = zod(
Schema.StructWithRest(
Schema.Struct({
id: Schema.String,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
)
const shape = json(schema) as { additionalProperties?: unknown }
// Presence of `additionalProperties` (truthy or a schema) signals catchall.
expect(shape.additionalProperties).not.toBe(false)
expect(shape.additionalProperties).toBeDefined()
})
test("plain struct without rest still emits additionalProperties unchanged (regression)", () => {
const schema = zod(Schema.Struct({ id: Schema.String }))
expect(schema.parse({ id: "x" })).toEqual({ id: "x" })
})
})
describe("transforms (Schema.decodeTo)", () => {
test("Number -> pseudo-Duration (seconds) applies the decode function", () => {
// Models the account/account.ts DurationFromSeconds pattern.
const SecondsToMs = Schema.Number.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((n: number) => n * 1000),
encode: SchemaGetter.transform((ms: number) => ms / 1000),
}),
)
const schema = zod(SecondsToMs)
expect(schema.parse(3)).toBe(3000)
expect(schema.parse(0)).toBe(0)
})
test("String -> Number via parseInt decode", () => {
const ParsedInt = Schema.String.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
encode: SchemaGetter.transform((n: number) => String(n)),
}),
)
const schema = zod(ParsedInt)
expect(schema.parse("42")).toBe(42)
expect(schema.parse("0")).toBe(0)
})
test("transform inside a struct field applies per-field", () => {
const Field = Schema.Number.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((n: number) => n + 1),
encode: SchemaGetter.transform((n: number) => n - 1),
}),
)
const schema = zod(
Schema.Struct({
plain: Schema.Number,
bumped: Field,
}),
)
expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 })
})
test("chained decodeTo composes transforms in order", () => {
// String -> Number (parseInt) -> Number (doubled).
// Exercises the encoded() reduce, not just a single link.
const Chained = Schema.String.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
encode: SchemaGetter.transform((n: number) => String(n)),
}),
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((n: number) => n * 2),
encode: SchemaGetter.transform((n: number) => n / 2),
}),
)
const schema = zod(Chained)
expect(schema.parse("21")).toBe(42)
expect(schema.parse("0")).toBe(0)
})
test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => {
// Schema.Class uses Declaration + encoding under the hood to construct
// class instances. The walker must NOT apply that transform, or zod
// parsing would return class instances instead of plain objects.
class Method extends Schema.Class<Method>("TxTestMethod")({
type: Schema.String,
value: Schema.Number,
}) {}
const schema = zod(Method)
const parsed = schema.parse({ type: "oauth", value: 1 })
expect(parsed).toEqual({ type: "oauth", value: 1 })
// Guardrail: ensure we didn't get back a Method instance.
expect(parsed).not.toBeInstanceOf(Method)
})
})
describe("optimizations", () => {
test("walk() memoizes by AST identity — same AST node returns same Zod", () => {
const shared = Schema.Struct({ id: Schema.String, name: Schema.String })
const left = zod(shared)
const right = zod(shared)
expect(left).toBe(right)
})
test("nested reuse of the same AST reuses the cached Zod child", () => {
// Two different parents embed the same inner schema. The inner zod
// child should be identical by reference inside both parents.
class Inner extends Schema.Class<Inner>("MemoTestInner")({
value: Schema.String,
}) {}
class OuterA extends Schema.Class<OuterA>("MemoTestOuterA")({
inner: Inner,
}) {}
class OuterB extends Schema.Class<OuterB>("MemoTestOuterB")({
inner: Inner,
}) {}
const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.()
const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.()
expect(shapeA.inner).toBe(shapeB.inner)
})
test("multiple checks run in a single refinement layer (all fire on one value)", () => {
// Three checks attached to the same schema. All three must run and
// report — asserting that no check silently got dropped when we
// flattened into one superRefine.
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
const schema = zod(Schema.Number.check(positive).check(even).check(under100))
const neg = schema.safeParse(-3)
expect(neg.success).toBe(false)
expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
const big = schema.safeParse(101)
expect(big.success).toBe(false)
expect(big.error!.issues.map((i) => i.message)).toContain("too big")
// Passing value satisfies all three
expect(schema.parse(42)).toBe(42)
})
test("FilterGroup flattens into the single refinement layer alongside its siblings", () => {
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
const group = Schema.makeFilterGroup([positive, even])
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
const schema = zod(Schema.Number.check(group).check(under100))
const bad = schema.safeParse(-3)
expect(bad.success).toBe(false)
expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
})
})
}) })

View file

@ -11180,13 +11180,11 @@
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"anyOf": [ "anyOf": [
{ {
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"type": "integer", "type": "integer",
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
"maximum": 9007199254740991 "maximum": 9007199254740991
}, },
{ {
"description": "Disable timeout for this provider entirely.",
"type": "boolean", "type": "boolean",
"const": false "const": false
} }
@ -11247,8 +11245,7 @@
"enum": ["reasoning_content", "reasoning_details"] "enum": ["reasoning_content", "reasoning_details"]
} }
}, },
"required": ["field"], "required": ["field"]
"additionalProperties": false
} }
] ]
}, },
@ -11377,8 +11374,7 @@
} }
} }
} }
}, }
"additionalProperties": false
}, },
"McpLocalConfig": { "McpLocalConfig": {
"type": "object", "type": "object",