Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<box flexDirection="column" gap={0} flexShrink={1}>
<For each={rows()}>{(row) => (
<box flexDirection="row" gap={0}>
<For each={row}>{(segment) => (
<>
<Show when={segment.separatorBefore && !segment.breakBefore}>
<text fg={theme.textMuted} wrapMode="none">{promptStatusSeparator()}</text>
</Show>
<PromptStatusSegmentText segment={segment} />
</>
)}</For>
</box>
)}</For>
</box>
)
}

function PromptStatusSegmentText(props: { segment: PromptStatusSegment }) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -4570,38 +4599,12 @@ export function Prompt(props: PromptProps) {
>
<box flexDirection="row" gap={1} flexShrink={1}>
<Show when={promptStatusLeftSegments().length} fallback={<box height={1} />}>
<box flexDirection="row" gap={0}>
<For each={promptStatusLeftSegments()}>
{(segment, index) => (
<>
<Show when={segment.separatorBefore}>
<text fg={theme.textMuted} wrapMode="none">
{promptStatusSeparator()}
</text>
</Show>
<PromptStatusSegmentText segment={segment} />
</>
)}
</For>
</box>
<PromptStatusLines segments={promptStatusLeftSegments()} />
</Show>
</box>
<box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
<Show when={promptStatusRightSegments().length}>
<box flexDirection="row" gap={0}>
<For each={promptStatusRightSegments()}>
{(segment, index) => (
<>
<Show when={segment.separatorBefore}>
<text fg={theme.textMuted} wrapMode="none">
{promptStatusSeparator()}
</text>
</Show>
<PromptStatusSegmentText segment={segment} />
</>
)}
</For>
</box>
<PromptStatusLines segments={promptStatusRightSegments()} />
</Show>
<Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center">
Expand Down Expand Up @@ -4703,20 +4706,7 @@ export function Prompt(props: PromptProps) {
</Match>
<Match when={promptStatusUsesOuterMeta() && promptStatusLeftSegments().length}>
<box paddingLeft={promptOuterMetaPadLeft()} flexDirection="row" gap={1}>
<box flexDirection="row" gap={0}>
<For each={promptStatusLeftSegments()}>
{(segment, index) => (
<>
<Show when={segment.separatorBefore}>
<text fg={theme.textMuted} wrapMode="none">
{promptStatusSeparator()}
</text>
</Show>
<PromptStatusSegmentText segment={segment} />
</>
)}
</For>
</box>
<PromptStatusLines segments={promptStatusLeftSegments()} />
</box>
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
Expand All @@ -4738,20 +4728,7 @@ export function Prompt(props: PromptProps) {
<Switch>
<Match when={store.mode === "normal" && promptStatusUsesOuterMeta()}>
<Show when={promptStatusOuterRightSegments().length}>
<box flexDirection="row" gap={0}>
<For each={promptStatusOuterRightSegments()}>
{(segment, index) => (
<>
<Show when={segment.separatorBefore}>
<text fg={theme.textMuted} wrapMode="none">
{promptStatusSeparator()}
</text>
</Show>
<PromptStatusSegmentText segment={segment} />
</>
)}
</For>
</box>
<PromptStatusLines segments={promptStatusOuterRightSegments()} />
</Show>
<For each={footerEntries()}>{(item) => item.render() as any}</For>
</Match>
Expand Down
36 changes: 28 additions & 8 deletions src/mendcode/packages/opencode/src/mend/adaptive-reasoning/jev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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",
Expand Down Expand Up @@ -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()
Expand All @@ -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",
})
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
21 changes: 20 additions & 1 deletion src/mendcode/packages/opencode/src/mend/tui/prompt-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export type MendPromptStatusScriptSegment = {
text: string
fg?: string
bold?: boolean
breakBefore?: boolean
}

export type MendPromptStatusScriptOutput = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<MendPromptStatusScriptOutput> {
const key = JSON.stringify(input)
const warmKey = warmCacheKey(input)
const now = Date.now()
Expand Down Expand Up @@ -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),
},
Expand Down
17 changes: 10 additions & 7 deletions src/mendcode/packages/opencode/src/tool/computer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,32 +24,35 @@ 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`)
let observedPID: number | undefined
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(() => "")
Expand Down
Loading
Loading