From 34cf8f4975a414404b8e80ee454e021265d15214 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Sat, 26 Sep 2026 18:59:16 -0500 Subject: [PATCH] fix: prepare beta19 footer rows, JEV gateway and computer capture --- CHANGELOG.md | 14 +++ .../cli/cmd/tui/component/prompt/index.tsx | 91 +++++++------------ .../src/mend/adaptive-reasoning/jev.ts | 36 ++++++-- .../src/mend/adaptive-reasoning/policy.ts | 2 +- .../opencode/src/mend/tui/prompt-status.ts | 21 ++++- .../packages/opencode/src/tool/computer.ts | 17 ++-- .../test/mend/adaptive-reasoning-jev.test.ts | 15 +++ .../test/mend/tui-prompt-chrome.test.ts | 15 +++ .../opencode/test/tool/computer.test.ts | 17 ++++ 9 files changed, 154 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 354a302c..59223299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.1.44-beta.19 - 2026-09-26 + +### Added + +- Allow prompt status scripts to render multiline footers by returning explicit + `rows` (up to four); legacy JSON and text output stays single-line. +- Support Vercel AI Gateway as an adaptive reasoning evaluator provider next to + OpenRouter. + +### Fixed + +- Accept `null` for the `computer_capture` `region` and `windowID` arguments so + strict tool transports that require every key can capture the main display. + ## 0.1.44-beta.18 - 2026-09-23 ### Fixed diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 6f3c7a14..4e52f773 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -3393,6 +3393,34 @@ export function Prompt(props: PromptProps) { render?: JSX.Element bold?: boolean separatorBefore?: boolean + breakBefore?: boolean + } + + function PromptStatusLines(props: { segments: PromptStatusSegment[] }) { + const rows = createMemo(() => { + const result: PromptStatusSegment[][] = [] + for (const segment of props.segments) { + if (!result.length || segment.breakBefore) result.push([]) + result[result.length - 1].push(segment) + } + return result + }) + return ( + + {(row) => ( + + {(segment) => ( + <> + + {promptStatusSeparator()} + + + + )} + + )} + + ) } function PromptStatusSegmentText(props: { segment: PromptStatusSegment }) { @@ -3682,13 +3710,14 @@ export function Prompt(props: PromptProps) { if (scriptOutput?.segments?.length) { const separatorBefore = base.length > 0 const next = scriptOutput.segments - .filter((item): item is { text: string; fg?: string; bold?: boolean } => + .filter((item) => Boolean(item.text.trim() && !isTransientPromptStatusText(item.text)), ) .map((item, index: number) => ({ text: item.text, fg: resolvePromptStatusScriptColor(item.fg), bold: item.bold, + breakBefore: item.breakBefore, separatorBefore: index === 0 ? separatorBefore : false, })) if (script?.prepend) base.unshift(...next) @@ -4570,38 +4599,12 @@ export function Prompt(props: PromptProps) { > }> - - - {(segment, index) => ( - <> - - - {promptStatusSeparator()} - - - - - )} - - + - - - {(segment, index) => ( - <> - - - {promptStatusSeparator()} - - - - - )} - - + @@ -4703,20 +4706,7 @@ export function Prompt(props: PromptProps) { - - - {(segment, index) => ( - <> - - - {promptStatusSeparator()} - - - - - )} - - + {props.hint ?? } @@ -4738,20 +4728,7 @@ export function Prompt(props: PromptProps) { - - - {(segment, index) => ( - <> - - - {promptStatusSeparator()} - - - - - )} - - + {(item) => item.render() as any} diff --git a/src/mendcode/packages/opencode/src/mend/adaptive-reasoning/jev.ts b/src/mendcode/packages/opencode/src/mend/adaptive-reasoning/jev.ts index 33d6a551..085e90b5 100644 --- a/src/mendcode/packages/opencode/src/mend/adaptive-reasoning/jev.ts +++ b/src/mendcode/packages/opencode/src/mend/adaptive-reasoning/jev.ts @@ -3,8 +3,10 @@ import { z } from "zod" import { AdaptivePolicySchema, type AdaptivePolicy } from "./policy" import { redactEvaluatorText, type EvaluatorContext } from "./context" -const endpoint = "https://openrouter.ai/api/alpha/decisions" -const model = "typesafe/jev-1.13" +const bindings = { + openrouter: { endpoint: "https://openrouter.ai/api/alpha/decisions", model: "typesafe/jev-1.13" }, + "vercel-ai-gateway": { endpoint: "https://ai-gateway.vercel.sh/v1/evaluate", model: "typesafe-ai/jev" }, +} as const const efforts = { low: "A clear next step with little uncertainty or comparison.", medium: "Focused analysis of connected facts and a bounded decision.", @@ -29,6 +31,20 @@ const responseSchema = z.object({ }).optional(), }) +const gatewayResponseSchema = z.object({ + model: z.literal("typesafe-ai/jev"), + answers: z.object({ effort: answer, lease: answer }), + usage: z.object({ + inputTokens: z.number().int().nonnegative().optional(), + outputTokens: z.number().int().nonnegative().optional(), + cost: z.number().finite().nonnegative().optional(), + }).optional(), +}).transform((data) => ({ + model: data.model, + answers: data.answers, + usage: data.usage && { input_tokens: data.usage.inputTokens, output_tokens: data.usage.outputTokens, cost: data.usage.cost }, +})) + export type JevDecision = { effort: string leaseSteps: 1 | 2 | 5 @@ -44,7 +60,7 @@ export class JevError extends Error { } } -export function jevRequest(context: EvaluatorContext, supportedEfforts: readonly string[], maxLeaseSteps: AdaptivePolicy["maxLeaseSteps"]) { +export function jevRequest(context: EvaluatorContext, supportedEfforts: readonly string[], maxLeaseSteps: AdaptivePolicy["maxLeaseSteps"], provider: AdaptivePolicy["provider"] = "openrouter") { const parsed = contextSchema.safeParse(context) if (!parsed.success || Buffer.byteLength(context.goal) > 8_192 || Buffer.byteLength(context.progress) > 4_096 || context.tools.some((tool) => Buffer.byteLength(tool.name) > 128 || Buffer.byteLength(tool.result) > 2_048)) throw new JevError("context") @@ -55,8 +71,10 @@ export function jevRequest(context: EvaluatorContext, supportedEfforts: readonly tools: parsed.data.tools.map((tool) => ({ name: redactEvaluatorText(tool.name), result: redactEvaluatorText(tool.result) })), } const body = JSON.stringify({ - model, state, - provider: { only: ["typesafe"], allow_fallbacks: false }, + model: bindings[provider].model, state, + ...(provider === "vercel-ai-gateway" + ? { providerOptions: { gateway: { only: ["typesafe-ai"] } } } + : { provider: { only: ["typesafe"], allow_fallbacks: false } }), questions: { effort: { type: "choice", @@ -129,7 +147,8 @@ export async function evaluateJev(input: { if (!policy.success || policy.data.mode === "off" || !policy.data.remoteProcessing) throw new JevError("consent") if (!input.apiKey || /\s/.test(input.apiKey)) throw new JevError("credential") const supportedEfforts = [...input.supportedEfforts] - const body = jevRequest(input.context, supportedEfforts, policy.data.maxLeaseSteps) + const binding = bindings[policy.data.provider] + const body = jevRequest(input.context, supportedEfforts, policy.data.maxLeaseSteps, policy.data.provider) // A known credential cannot cross the boundary even when a caller bypasses projection. if (body.includes(input.apiKey)) throw new JevError("context") const controller = new AbortController() @@ -146,7 +165,7 @@ export async function evaluateJev(input: { await current() if (!await withAbort(input.admitAttempt(signal), signal)) throw new JevError("budget") await current() - const response = await (input.fetch ?? fetch)(endpoint, { + const response = await (input.fetch ?? fetch)(binding.endpoint, { method: "POST", headers: { authorization: `Bearer ${input.apiKey}`, "content-type": "application/json" }, body, signal, redirect: "error", }) @@ -160,7 +179,8 @@ export async function evaluateJev(input: { await delay(ms, undefined, { signal }) continue } - const parsed = responseSchema.safeParse(await readResponse(response)) + const schema = policy.data.provider === "vercel-ai-gateway" ? gatewayResponseSchema : responseSchema + const parsed = schema.safeParse(await readResponse(response)) await current() if (!parsed.success) throw new JevError("response") const effort = parsed.data.answers.effort.choice diff --git a/src/mendcode/packages/opencode/src/mend/adaptive-reasoning/policy.ts b/src/mendcode/packages/opencode/src/mend/adaptive-reasoning/policy.ts index da0429e2..873e435e 100644 --- a/src/mendcode/packages/opencode/src/mend/adaptive-reasoning/policy.ts +++ b/src/mendcode/packages/opencode/src/mend/adaptive-reasoning/policy.ts @@ -10,7 +10,7 @@ export const AdaptivePolicySchema = z.object({ version: z.literal(1), mode: z.enum(["off", "shadow", "adaptive"]), remoteProcessing: z.boolean(), - provider: z.literal("openrouter"), + provider: z.enum(["openrouter", "vercel-ai-gateway"]), maxLeaseSteps: lease, maxDecisionsPerTurn: z.number().int().min(1).max(20), failureMode: z.enum(["pause", "baseline"]), diff --git a/src/mendcode/packages/opencode/src/mend/tui/prompt-status.ts b/src/mendcode/packages/opencode/src/mend/tui/prompt-status.ts index 3058fc18..d56e72dc 100644 --- a/src/mendcode/packages/opencode/src/mend/tui/prompt-status.ts +++ b/src/mendcode/packages/opencode/src/mend/tui/prompt-status.ts @@ -96,6 +96,7 @@ export type MendPromptStatusScriptSegment = { text: string fg?: string bold?: boolean + breakBefore?: boolean } export type MendPromptStatusScriptOutput = { @@ -398,6 +399,23 @@ function parseScriptOutput(text: string): MendPromptStatusScriptOutput { if (trimmed.startsWith("[") || trimmed.startsWith("{")) { try { const parsed = JSON.parse(trimmed) as unknown + // Explicit rows opt into multiline output; legacy JSON/TSV stays single-line. + if (parsed && typeof parsed === "object" && "rows" in parsed && Array.isArray(parsed.rows)) { + const segments: MendPromptStatusScriptSegment[] = [] + for (const row of parsed.rows.slice(0, 4)) { + if (!Array.isArray(row)) continue + const items = row.slice(0, 32).flatMap((item): MendPromptStatusScriptSegment[] => { + if (!item || typeof item !== "object" || typeof item.text !== "string") return [] + const text = item.text.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 240) + if (!text.trim()) return [] + return [{ text, fg: typeof item.fg === "string" ? item.fg : undefined, bold: item.bold === true }] + }) + if (!items.length) continue + if (segments.length) items[0].breakBefore = true + segments.push(...items) + } + return { text: segments.map((item) => `${item.breakBefore ? "\n" : ""}${item.text}`).join(""), segments } + } const rawSegments = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && "segments" in parsed && Array.isArray((parsed as any).segments) @@ -437,7 +455,7 @@ function parseScriptOutput(text: string): MendPromptStatusScriptOutput { return { text: normalizeScriptText(trimmed) } } -export async function readPromptStatusScript(input: MendPromptStatusScriptInput) { +export async function readPromptStatusScript(input: MendPromptStatusScriptInput): Promise { const key = JSON.stringify(input) const warmKey = warmCacheKey(input) const now = Date.now() @@ -478,6 +496,7 @@ export async function readPromptStatusScript(input: MendPromptStatusScriptInput) MEND_TUI_AGENTS_HINT: input.agentsHint || "", MEND_TUI_PROMPT_PRESET: input.preset, MEND_TUI_STATUS_SIDE: input.side, + MEND_TUI_STATUS_FORMAT: "rows-v1", MEND_TUI_STATUS_PREPEND: input.prepend ? "1" : "0", MEND_TUI_STATUS_REFRESH_KEY: String(input.refreshKey ?? 0), }, diff --git a/src/mendcode/packages/opencode/src/tool/computer.ts b/src/mendcode/packages/opencode/src/tool/computer.ts index e2d029fa..15ca560a 100644 --- a/src/mendcode/packages/opencode/src/tool/computer.ts +++ b/src/mendcode/packages/opencode/src/tool/computer.ts @@ -24,18 +24,21 @@ export async function nativeComputerCommand(command: string[], signal: AbortSign } const Parameters = Schema.Struct({ - region: Schema.optional(Schema.Struct({ x: Schema.Number, y: Schema.Number, width: Schema.Number, height: Schema.Number })).annotate({ description: "Optional crop in native screen points; cannot be combined with windowID." }), - windowID: Schema.optional(Schema.Number).annotate({ description: "Optional known macOS window ID; omit to capture the main display." }), + region: Schema.optional(Schema.NullOr(Schema.Struct({ x: Schema.Number, y: Schema.Number, width: Schema.Number, height: Schema.Number }))).annotate({ description: "Crop in native screen points, or null/omitted. Use null when capturing a window; never combine two non-null selectors." }), + windowID: Schema.optional(Schema.NullOr(Schema.Number)).annotate({ description: "Known positive macOS window ID, or null/omitted. For the main display, set both region and windowID to null. Never use 0 as an absent ID." }), }) export const ComputerCaptureTool = Tool.define("computer_capture", Effect.succeed({ description: "Take and immediately view a native screenshot on macOS. Captures the main display or a known window ID, returns a PNG attachment plus an absolute file path for read. Resizes the preview to at most 1600 pixels. Requires OS Screen Recording permission; does not grant it automatically. Other operating systems are currently unsupported.", parameters: Parameters, execute: (args: typeof Parameters.Type, ctx: Tool.Context) => Effect.gen(function* () { + // Strict tool transports may require every key: null represents absence. + const region = args.region ?? undefined + const windowID = args.windowID ?? undefined if (process.platform !== "darwin") throw new Error("Native computer capture is currently supported only on macOS. Use a configured computer/browser MCP service on this platform.") - if (args.windowID !== undefined && (!Number.isSafeInteger(args.windowID) || args.windowID <= 0)) throw new Error("windowID must be a positive integer") - if (args.region && (args.windowID !== undefined || !Object.values(args.region).every(Number.isSafeInteger) || args.region.width < 1 || args.region.height < 1 || args.region.width > 16384 || args.region.height > 16384)) throw new Error("Use an integer crop with positive dimensions up to 16384, or a window ID, not both") - yield* ctx.ask({ permission: "computer_capture", patterns: [args.windowID ? `window:${args.windowID}` : "main-display"], always: ["*"], metadata: { surface: args.windowID ?? "main-display" } }) + if (windowID !== undefined && (!Number.isSafeInteger(windowID) || windowID <= 0)) throw new Error("windowID must be a positive integer") + if (region && (windowID !== undefined || !Object.values(region).every(Number.isSafeInteger) || region.width < 1 || region.height < 1 || region.width > 16384 || region.height > 16384)) throw new Error("Use an integer crop with positive dimensions up to 16384, or a window ID, not both") + yield* ctx.ask({ permission: "computer_capture", patterns: [windowID ? `window:${windowID}` : "main-display"], always: ["*"], metadata: { surface: windowID ?? "main-display" } }) const directory = path.join(Global.Path.cache, "computer", ctx.sessionID) const id = crypto.randomUUID() const filePath = path.join(directory, `${id}.png`) @@ -43,13 +46,13 @@ export const ComputerCaptureTool = Tool.define("computer_capture", Effect.succee yield* Effect.promise(async () => { const displays = await nativeComputerCommand(["/usr/sbin/system_profiler", "SPDisplaysDataType", "-json"], ctx.abort).catch(() => "{}") const displayCount = (JSON.parse(displays).SPDisplaysDataType ?? []).reduce((sum: number, gpu: { spdisplays_ndrvs?: unknown[] }) => sum + (gpu.spdisplays_ndrvs?.length ?? 0), 0) - if (args.windowID === undefined && !args.region && displayCount === 1) { + if (windowID === undefined && !region && displayCount === 1) { const value = await nativeComputerCommand(["/usr/bin/osascript", "-e", FRONTMOST], ctx.abort).catch(() => "") if (/^\d+$/.test(value)) observedPID = Number(value) } await mkdir(directory, { recursive: true, mode: 0o700 }) if ((await readdir(directory)).filter((name) => name.endsWith(".png")).length >= 64) throw new Error("This session reached its 64 screenshot artifact limit. Archive or remove its cached captures before continuing.") - await nativeComputerCommand(["/usr/sbin/screencapture", "-x", ...(args.region ? ["-R", `${args.region.x},${args.region.y},${args.region.width},${args.region.height}`] : args.windowID ? ["-l", String(args.windowID)] : ["-m"]), filePath], ctx.abort) + await nativeComputerCommand(["/usr/sbin/screencapture", "-x", ...(region ? ["-R", `${region.x},${region.y},${region.width},${region.height}`] : windowID ? ["-l", String(windowID)] : ["-m"]), filePath], ctx.abort) await nativeComputerCommand(["/usr/bin/sips", "-Z", "1600", filePath], ctx.abort) if (observedPID !== undefined) { const after = await nativeComputerCommand(["/usr/bin/osascript", "-e", FRONTMOST], ctx.abort).catch(() => "") diff --git a/src/mendcode/packages/opencode/test/mend/adaptive-reasoning-jev.test.ts b/src/mendcode/packages/opencode/test/mend/adaptive-reasoning-jev.test.ts index a5cece6d..cc26d2fa 100644 --- a/src/mendcode/packages/opencode/test/mend/adaptive-reasoning-jev.test.ts +++ b/src/mendcode/packages/opencode/test/mend/adaptive-reasoning-jev.test.ts @@ -41,6 +41,21 @@ describe("Jev isolated HTTP contract", () => { } finally { await server.stop(true) } }) + test("uses the Pi Gateway binding without OpenRouter routing or model fallback", async () => { + const input = { ...base(), policy: { ...base().policy, provider: "vercel-ai-gateway" as const } } + const decision = await evaluateJev({ ...input, fetch: async (url, init) => { + expect(url).toBe("https://ai-gateway.vercel.sh/v1/evaluate") + const body = JSON.parse(String(init.body)) + expect(body.model).toBe("typesafe-ai/jev") + expect(body.provider).toBeUndefined() + expect(body.providerOptions).toEqual({ gateway: { only: ["typesafe-ai"] } }) + return Response.json({ model: "typesafe-ai/jev", answers: result.answers, usage: { inputTokens: 8, outputTokens: 2 } }) + } }) + expect(decision.effort).toBe("high") + expect(decision.usage).toEqual({ inputTokens: 8, outputTokens: 2, cost: undefined }) + await expect(evaluateJev({ ...input, fetch: async () => Response.json(result) })).rejects.toMatchObject({ code: "response" }) + }) + test("off, missing consent, missing credential, stale revision and denied budget send nothing", async () => { let calls = 0 const transport = async () => { calls++; return Response.json(result) } diff --git a/src/mendcode/packages/opencode/test/mend/tui-prompt-chrome.test.ts b/src/mendcode/packages/opencode/test/mend/tui-prompt-chrome.test.ts index eb227cbe..35ce37bd 100644 --- a/src/mendcode/packages/opencode/test/mend/tui-prompt-chrome.test.ts +++ b/src/mendcode/packages/opencode/test/mend/tui-prompt-chrome.test.ts @@ -399,6 +399,21 @@ describe("mend tui prompt chrome", () => { expect(result.text).toBe("Build") }) + test("preserves explicit status rows, colors and bounded content", async () => { + const rows = [[{ text: "ASTRA", fg: "accent", bold: true }], [], [{ text: "quota\nā–ˆ" }], [{ text: "GRAFT" }], [{ text: "hidden" }]] + const result = await readPromptStatusScript({ + command: `printf '%s' '${JSON.stringify({ rows })}'`, + root: process.cwd(), promptMode: "custom", model: "", provider: "", + preset: "top-bottom", side: "left", prepend: false, timeoutMs: 1000, + }) + expect(result.text).toBe("ASTRA\nquota ā–ˆ\nGRAFT") + expect(result.segments).toEqual([ + { text: "ASTRA", fg: "accent", bold: true }, + { text: "quota ā–ˆ", fg: undefined, bold: false, breakBefore: true }, + { text: "GRAFT", fg: undefined, bold: false, breakBefore: true }, + ]) + }) + test("calculates the current prompt cache ratio and hides cache misses", () => { expect(resolvePromptCachePercent({ input: 5000, cache: { read: 5000, write: 0 } })).toBe(50) expect(resolvePromptCachePercent({ input: 100, cache: { read: 100, write: 50 } })).toBe(40) diff --git a/src/mendcode/packages/opencode/test/tool/computer.test.ts b/src/mendcode/packages/opencode/test/tool/computer.test.ts index 3465e19d..e494c429 100644 --- a/src/mendcode/packages/opencode/test/tool/computer.test.ts +++ b/src/mendcode/packages/opencode/test/tool/computer.test.ts @@ -65,6 +65,23 @@ describe("native computer boundary", () => { expect(os.state.commands).toHaveLength(0) })) + it.instance("accepts nullable selectors from strict tool transports", () => Effect.gen(function* () { + if (process.platform !== "darwin") return + const capture = yield* Tool.init(yield* ComputerCaptureTool) + using os = desktop() + const ctx = context() + yield* capture.execute({ region: null, windowID: null }, ctx) + yield* capture.execute({ region: null, windowID: 42 }, ctx) + yield* capture.execute({ region: { x: 0, y: 0, width: 100, height: 80 }, windowID: null }, ctx) + const commands = os.state.commands.filter((command) => command[0] === "/usr/sbin/screencapture") + expect(commands[0]).toContain("-m") + expect(commands[1]).toContain("42") + expect(commands[2]).toContain("0,0,100,80") + const count = os.state.commands.length + expect((yield* Effect.exit(capture.execute({ region: null, windowID: 0 }, ctx)))._tag).toBe("Failure") + expect(os.state.commands).toHaveLength(count) + })) + it.instance("binds capture to session, consumes once and rejects foreground changes", () => Effect.gen(function* () { if (process.platform !== "darwin") return const capture = yield* Tool.init(yield* ComputerCaptureTool)