From e8ce5df414070c174239fbc90e1c4e8a6be5ee0f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 7 May 2026 22:08:29 -0400 Subject: [PATCH 001/689] fix(tui): retain cleared prompt drafts (#26258) --- .../cli/cmd/tui/component/prompt/index.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 898d14e979..e165f75ac0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -94,6 +94,8 @@ const money = new Intl.NumberFormat("en-US", { currency: "USD", }) +const DRAFT_RETENTION_MIN_CHARS = 20 + function randomIndex(count: number) { if (count <= 0) return 0 return Math.floor(Math.random() * count) @@ -412,13 +414,7 @@ export function Prompt(props: PromptProps) { category: "Prompt", hidden: true, run: () => { - input.clear() - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + clearPrompt() dialog.clear() }, }, @@ -1356,6 +1352,22 @@ export function Prompt(props: PromptProps) { return } + function clearPrompt() { + if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) { + history.append({ + ...store.prompt, + mode: store.mode, + }) + } + input.clear() + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + } + const highlight = createMemo(() => { if (leader()) return theme.border if (store.mode === "shell") return theme.primary From 5c401673b2486bd3743fb27ab172051e0f336758 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 8 May 2026 04:39:42 +0200 Subject: [PATCH 002/689] improve go sub animation perf (#26251) --- .../cli/cmd/tui/component/bg-pulse-render.ts | 429 ++++++++++++++++++ .../src/cli/cmd/tui/component/bg-pulse.tsx | 189 ++++---- .../cmd/tui/component/dialog-retry-action.tsx | 98 ++-- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 2 + 4 files changed, 542 insertions(+), 176 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts new file mode 100644 index 0000000000..25b728f1e5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts @@ -0,0 +1,429 @@ +import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core" +import { go } from "@/cli/logo" + +const PERIOD = 4600 +const RINGS = 3 +const WIDTH = 3.8 +const TAIL = 9.5 +const AMP = 0.55 +const TAIL_AMP = 0.16 +const BREATH_AMP = 0.05 +const BREATH_SPEED = 0.0008 +// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks. +const PHASE_OFFSET = 0.29 +const LOGO_GAP = 1 +const LOGO_TOP_BIAS = -1 +const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0 +const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index]) +const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0 +const LOGO_HEIGHT = LOGO_LINES.length +const SPACE = " ".codePointAt(0)! +const TOP_HALF = "▀".codePointAt(0)! +const FULL_BLOCK = "█".codePointAt(0)! +const RING_SCALE = 1 / RINGS +const TAIL_SCALE = 1 / TAIL +const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3 + +const enum LogoCellKind { + Background, + Top, + ShadowTop, + Solid, + Char, +} + +type LogoTemplateCell = { + x: number + y: number + kind: LogoCellKind + charCode: number + attributes: number + topDist: number + bottomDist: number +} + +const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) => + Array.from(line) + .map((char, x) => { + if (char === " ") return + const kind = + char === "_" + ? LogoCellKind.Background + : char === "^" + ? LogoCellKind.Top + : char === "~" + ? LogoCellKind.ShadowTop + : char === "█" + ? LogoCellKind.Solid + : LogoCellKind.Char + return { + x, + y, + kind, + charCode: char.codePointAt(0) ?? SPACE, + attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0, + topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT), + bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT), + } + }) + .filter((cell): cell is LogoTemplateCell => !!cell), +) + +export type Rgb = [number, number, number] + +export type GoUpsellArtRenderOptions = { + deltaTime?: number + rgb?: boolean + cache?: boolean +} + +const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30)) +const CACHE_FRAMES_PER_RENDER = 1 + +export function toRgb(color: RGBA): Rgb { + const [r, g, b] = color.toInts() + return [r, g, b] +} + +function clamp(n: number) { + return Math.max(0, Math.min(1, n)) +} + +function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) { + buffer[offset] = r + buffer[offset + 1] = g + buffer[offset + 2] = b + buffer[offset + 3] = a +} + +function mixChannel(base: number, overlay: number, alpha: number) { + return Math.round(base + (overlay - base) * clamp(alpha)) +} + +function writeLogoTint(buffer: Uint16Array, offset: number, base: Rgb, primary: Rgb, primaryMix: number, peakMix: number) { + const p = clamp(primaryMix) + const q = clamp(peakMix) + const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q) + const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q) + const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q) + writeRgb(buffer, offset, r, g, b) +} + +function sameRgb(a: Rgb, b: Rgb) { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +} + +export class GoUpsellArtPainter { + private panelRgb: Rgb = [0, 0, 0] + private primaryRgb: Rgb = [255, 255, 255] + private logoBaseRgb: Rgb = [180, 180, 180] + private elapsed = 0 + private distances = new Float32Array(0) + private edgeFalloff = new Float32Array(0) + private geometryWidth = 0 + private geometryHeight = 0 + private reach = 1 + private logoX = 0 + private logoY = 0 + private logoIndexes = new Int32Array(0) + private logoRgb: boolean | undefined + private pulsePeak = 0 + private pulsePrimary = 0 + private cacheDirty = true + private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = [] + private cacheBuildIndex = 0 + + setBackgroundPanel(value: RGBA | Rgb | undefined) { + if (!value) return false + const next = value instanceof RGBA ? toRgb(value) : value + if (sameRgb(this.panelRgb, next)) return false + this.panelRgb = next + this.invalidateCache() + return true + } + + setLogoBase(value: RGBA | Rgb | undefined) { + if (!value) return false + const next = value instanceof RGBA ? toRgb(value) : value + if (sameRgb(this.logoBaseRgb, next)) return false + this.logoBaseRgb = next + this.invalidateCache() + return true + } + + setPrimary(value: RGBA | Rgb | undefined) { + if (!value) return false + const next = value instanceof RGBA ? toRgb(value) : value + if (sameRgb(this.primaryRgb, next)) return false + this.primaryRgb = next + this.invalidateCache() + return true + } + + render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) { + const rgb = options.rgb === true + this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD + this.rebuildGeometry(frameBuffer, rgb) + if (options.cache !== false) { + this.drawCached(frameBuffer, rgb) + return + } + this.drawBackground(frameBuffer, this.elapsed) + this.drawLogo(frameBuffer, this.elapsed, rgb) + } + + private invalidateCache() { + this.cacheDirty = true + this.cacheBuildIndex = 0 + this.frameCache = [] + } + + private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) { + const width = frameBuffer.width + const height = frameBuffer.height + const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight + const logoTemplateChanged = this.logoRgb !== rgb + if (!geometryChanged && !logoTemplateChanged) return + + if (geometryChanged) { + this.geometryWidth = width + this.geometryHeight = height + this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2)) + this.logoY = Math.max( + 0, + Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS), + ) + + const centerX = this.logoX + LOGO_WIDTH / 2 + const centerY = this.logoY + LOGO_HEIGHT / 2 + this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL + this.distances = new Float32Array(width * height) + this.edgeFalloff = new Float32Array(width * height) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = y * width + x + const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2) + this.distances[index] = dist + this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2) + } + } + } + + this.logoRgb = rgb + this.invalidateCache() + this.rebuildCellTemplate(frameBuffer, rgb) + } + + private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) { + if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb) + if (this.cacheBuildIndex < CACHE_FRAME_COUNT) { + this.buildFrameCache(frameBuffer, rgb) + this.drawBackground(frameBuffer, this.elapsed) + this.drawLogo(frameBuffer, this.elapsed, rgb) + return + } + + const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT] + if (frame) { + frameBuffer.buffers.fg.set(frame.fg) + frameBuffer.buffers.bg.set(frame.bg) + } + } + + private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) { + this.frameCache = [] + this.cacheBuildIndex = 0 + this.rebuildCellTemplate(frameBuffer, rgb) + this.cacheDirty = false + } + + private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) { + const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER) + for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) { + const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD + this.drawBackground(frameBuffer, t) + this.drawLogo(frameBuffer, t, rgb) + this.frameCache.push({ + fg: new Uint16Array(frameBuffer.buffers.fg), + bg: new Uint16Array(frameBuffer.buffers.bg), + }) + } + } + + private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) { + const buffers = frameBuffer.buffers + buffers.char.fill(SPACE) + buffers.attributes.fill(0) + + if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) { + this.logoIndexes = new Int32Array(0) + return + } + + this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length) + for (let i = 0; i < LOGO_TEMPLATE.length; i++) { + const cell = LOGO_TEMPLATE[i]! + const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x + this.logoIndexes[i] = index + buffers.attributes[index] = cell.attributes + buffers.char[index] = + cell.kind === LogoCellKind.Background + ? SPACE + : cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop + ? TOP_HALF + : cell.kind === LogoCellKind.Solid + ? rgb + ? TOP_HALF + : FULL_BLOCK + : cell.charCode + } + } + + private drawBackground(frameBuffer: OptimizedBuffer, t: number) { + const buffers = frameBuffer.buffers + const fg = buffers.fg + const bg = buffers.bg + const distances = this.distances + const edgeFalloff = this.edgeFalloff + const baseR = this.panelRgb[0] + const baseG = this.panelRgb[1] + const baseB = this.panelRgb[2] + const deltaR = this.primaryRgb[0] - baseR + const deltaG = this.primaryRgb[1] - baseG + const deltaB = this.primaryRgb[2] - baseB + const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP + + const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1 + const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1 + const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1 + const envelope0 = Math.sin(phase0 * Math.PI) + const envelope1 = Math.sin(phase1 * Math.PI) + const envelope2 = Math.sin(phase2 * Math.PI) + const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0) + const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1) + const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2) + const head0 = phase0 * this.reach + const head1 = phase1 * this.reach + const head2 = phase2 * this.reach + + for (let index = 0; index < distances.length; index++) { + const dist = distances[index] + const delta0 = dist - head0 + const abs0 = delta0 < 0 ? -delta0 : delta0 + const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0 + const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0 + + const delta1 = dist - head1 + const abs1 = delta1 < 0 ? -delta1 : delta1 + const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0 + const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0 + + const delta2 = dist - head2 + const abs2 = delta2 < 0 ? -delta2 : delta2 + const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0 + const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0 + + const level = + (crest0 * AMP + tail0 * TAIL_AMP) * eased0 + + (crest1 * AMP + tail1 * TAIL_AMP) * eased1 + + (crest2 * AMP + tail2 * TAIL_AMP) * eased2 + const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index] + const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7 + const offset = index * 4 + const r = Math.round(baseR + deltaR * strength) + const g = Math.round(baseG + deltaG * strength) + const b = Math.round(baseB + deltaB * strength) + bg[offset] = fg[offset] = r + bg[offset + 1] = fg[offset + 1] = g + bg[offset + 2] = fg[offset + 2] = b + bg[offset + 3] = fg[offset + 3] = 255 + } + } + + private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) { + let peak = 0.04 + let primary = 0 + + const delta0 = dist - head0 + const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8)) + const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6)) + const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0 + peak += core0 * 0.65 * eased0 + primary += (soft0 * 0.16 + tail0 * 0.22) * eased0 + + const delta1 = dist - head1 + const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8)) + const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6)) + const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0 + peak += core1 * 0.65 * eased1 + primary += (soft1 * 0.16 + tail1 * 0.22) * eased1 + + this.pulsePeak = peak > 1 ? 1 : peak + this.pulsePrimary = primary > 1 ? 1 : primary + } + + private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) { + if (this.logoIndexes.length === 0) return + + const buffers = frameBuffer.buffers + const fg = buffers.fg + const bg = buffers.bg + const shadow: Rgb = [ + mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25), + mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25), + mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25), + ] + const phase0 = (t / PERIOD) % 1 + const phase1 = (t / PERIOD + 0.5) % 1 + const envelope0 = Math.sin(phase0 * Math.PI) + const envelope1 = Math.sin(phase1 * Math.PI) + const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0) + const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1) + const head0 = phase0 * LOGO_REACH + const head1 = phase1 * LOGO_REACH + + for (let i = 0; i < LOGO_TEMPLATE.length; i++) { + const cell = LOGO_TEMPLATE[i]! + const index = this.logoIndexes[i]! + const offset = index * 4 + this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1) + const topPeak = this.pulsePeak + const topPrimary = this.pulsePrimary + this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1) + const bottomPeak = this.pulsePeak + const bottomPrimary = this.pulsePrimary + + if (cell.kind === LogoCellKind.Background) { + writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18) + continue + } + + if (cell.kind === LogoCellKind.Top) { + writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak) + writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18) + continue + } + + if (cell.kind === LogoCellKind.ShadowTop) { + writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18) + continue + } + + if (cell.kind === LogoCellKind.Solid && rgb) { + writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak) + writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak) + continue + } + + writeLogoTint( + fg, + offset, + this.logoBaseRgb, + this.primaryRgb, + (topPrimary + bottomPrimary) / 2, + (topPeak + bottomPeak) / 2, + ) + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx index 541ecea4e1..0482adea33 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx @@ -1,130 +1,93 @@ -import { BoxRenderable, RGBA } from "@opentui/core" -import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js" +import { FrameBufferRenderable, RGBA, type OptimizedBuffer, type RenderContext, type RenderableOptions } from "@opentui/core" +import { extend, useRenderer } from "@opentui/solid" +import { onCleanup, onMount } from "solid-js" import { tint, useTheme } from "@tui/context/theme" +import { GoUpsellArtPainter } from "./bg-pulse-render" -const PERIOD = 4600 -const RINGS = 3 -const WIDTH = 3.8 -const TAIL = 9.5 -const AMP = 0.55 -const TAIL_AMP = 0.16 -const BREATH_AMP = 0.05 -const BREATH_SPEED = 0.0008 -// Offset so bg ring emits from GO center at the moment the logo pulse peaks. -const PHASE_OFFSET = 0.29 - -export type BgPulseMask = { - x: number - y: number - width: number - height: number - pad?: number - strength?: number +type GoUpsellArtOptions = RenderableOptions & { + backgroundPanel?: RGBA + primary?: RGBA + logoBase?: RGBA } -export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) { - const { theme } = useTheme() - const [now, setNow] = createSignal(performance.now()) - const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 }) - let box: BoxRenderable | undefined +class GoUpsellArtRenderable extends FrameBufferRenderable { + private painter = new GoUpsellArtPainter() - const timer = setInterval(() => setNow(performance.now()), 50) - onCleanup(() => clearInterval(timer)) + constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) { + const width = typeof options.width === "number" ? options.width : 1 + const height = typeof options.height === "number" ? options.height : 1 + super(ctx, { + ...options, + width, + height, + live: options.live ?? true, + respectAlpha: false, + }) - const sync = () => { - if (!box) return - setSize({ width: box.width, height: box.height }) + if (options.width !== undefined && typeof options.width !== "number") this.width = options.width + if (options.height !== undefined && typeof options.height !== "number") this.height = options.height + this.painter.setBackgroundPanel(options.backgroundPanel) + this.painter.setPrimary(options.primary) + this.painter.setLogoBase(options.logoBase) } + set backgroundPanel(value: RGBA | undefined) { + if (this.painter.setBackgroundPanel(value)) this.requestRender() + } + + set logoBase(value: RGBA | undefined) { + if (this.painter.setLogoBase(value)) this.requestRender() + } + + set primary(value: RGBA | undefined) { + if (this.painter.setPrimary(value)) this.requestRender() + } + + protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void { + if (!this.visible || this.isDestroyed) return + + this.painter.render(this.frameBuffer, { + deltaTime, + rgb: this._ctx.capabilities?.rgb === true, + }) + super.renderSelf(buffer) + } +} + +declare module "@opentui/solid" { + interface OpenTUIComponents { + go_upsell_art: typeof GoUpsellArtRenderable + } +} + +extend({ go_upsell_art: GoUpsellArtRenderable }) + +export function BgPulse() { + const { theme } = useTheme() + const renderer = useRenderer() + let targetFps = renderer.targetFps + let maxFps = renderer.maxFps + onMount(() => { - sync() - box?.on("resize", sync) + targetFps = renderer.targetFps + maxFps = renderer.maxFps + renderer.targetFps = 30 + renderer.maxFps = 30 }) onCleanup(() => { - box?.off("resize", sync) - }) - - const grid = createMemo(() => { - const t = now() - const w = size().width - const h = size().height - if (w === 0 || h === 0) return [] as RGBA[][] - const cxv = props.centerX ?? w / 2 - const cyv = props.centerY ?? h / 2 - const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL - const ringStates = Array.from({ length: RINGS }, (_, i) => { - const offset = i / RINGS - const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1 - const envelope = Math.sin(phase * Math.PI) - const eased = envelope * envelope * (3 - 2 * envelope) - return { - head: phase * reach, - eased, - } - }) - const normalizedMasks = props.masks?.map((m) => { - const pad = m.pad ?? 2 - return { - left: m.x - pad, - right: m.x + m.width + pad, - top: m.y - pad, - bottom: m.y + m.height + pad, - pad, - strength: m.strength ?? 0.85, - } - }) - const rows = [] as RGBA[][] - for (let y = 0; y < h; y++) { - const row = [] as RGBA[] - for (let x = 0; x < w; x++) { - const dx = x + 0.5 - cxv - const dy = (y + 0.5 - cyv) * 2 - const dist = Math.hypot(dx, dy) - let level = 0 - for (const ring of ringStates) { - const delta = dist - ring.head - const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0 - const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0 - level += (crest * AMP + tail * TAIL_AMP) * ring.eased - } - const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2) - const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP - let maskAtten = 1 - if (normalizedMasks) { - for (const m of normalizedMasks) { - if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue - const inX = Math.min(x - m.left, m.right - x) - const inY = Math.min(y - m.top, m.bottom - y) - const edge = Math.min(inX / m.pad, inY / m.pad, 1) - const eased = edge * edge * (3 - 2 * edge) - const reduce = 1 - m.strength * eased - if (reduce < maskAtten) maskAtten = reduce - } - } - const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten) - row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7)) - } - rows.push(row) - } - return rows + renderer.targetFps = targetFps + renderer.maxFps = maxFps }) return ( - (box = item)} width="100%" height="100%"> - - {(row) => ( - - - {(color) => ( - - {" "} - - )} - - - )} - - + ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx index 9dad1b4561..cbc8f0ef08 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx @@ -1,15 +1,16 @@ -import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core" +import { RGBA, TextAttributes } from "@opentui/core" import open from "open" -import { createSignal, onCleanup, onMount } from "solid-js" +import { createSignal } from "solid-js" import { selectedForeground, useTheme } from "@tui/context/theme" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { Link } from "@tui/ui/link" -import { GoLogo } from "./logo" -import { BgPulse, type BgPulseMask } from "./bg-pulse" +import { BgPulse } from "./bg-pulse" import { useBindings } from "../keymap" +const GO_URL = "https://opencode.ai/go" const PAD_X = 3 const PAD_TOP_OUTER = 1 +const FOREGROUND_ALPHA = 186 export type DialogRetryActionProps = { title: string @@ -30,52 +31,18 @@ function dismiss(props: DialogRetryActionProps, dialog: ReturnType props.link === GO_URL + const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined) const [selected, setSelected] = createSignal<"dismiss" | "action">("action") - const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>() - const [masks, setMasks] = createSignal([]) - const showGoTreatment = () => props.link === "https://opencode.ai/go" - let content: BoxRenderable | undefined - let logoBox: BoxRenderable | undefined - let headingBox: BoxRenderable | undefined - let descBox: BoxRenderable | undefined - let buttonsBox: BoxRenderable | undefined - - const sync = () => { - if (!content) return - if (logoBox) { - setCenter({ - x: logoBox.x - content.x + logoBox.width / 2, - y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, - }) - } - const next: BgPulseMask[] = [] - const baseY = PAD_TOP_OUTER - for (const b of [headingBox, descBox, buttonsBox]) { - if (!b) continue - next.push({ - x: b.x - content.x, - y: b.y - content.y + baseY, - width: b.width, - height: b.height, - pad: 2, - strength: 0.78, - }) - } - setMasks(next) - } - - onMount(() => { - sync() - for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync) - }) - - onCleanup(() => { - for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) - }) useBindings(() => ({ bindings: [ @@ -102,37 +69,40 @@ export function DialogRetryAction(props: DialogRetryActionProps) { })) return ( - (content = item)}> + {showGoTreatment() ? ( - + ) : null} - - (headingBox = item)} flexDirection="row" justifyContent="space-between"> - + + + {props.title} - dialog.clear()}> + dialog.clear()}> esc - (descBox = item)} gap={0}> - {props.message} + + + {props.message} + - - {showGoTreatment() ? ( - (logoBox = item)} alignItems="center"> - + {props.link ? ( + showGoTreatment() ? ( + + - ) : null} - {props.link ? ( - + ) : ( + - ) : null} - - (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> + ) + ) : ( + + )} + don't show again @@ -156,6 +127,7 @@ export function DialogRetryAction(props: DialogRetryActionProps) { > {props.label} diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 01c4b6e713..cfd78bc333 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -6,6 +6,7 @@ export interface LinkProps { href: string children?: JSX.Element | string fg?: RGBA + bg?: RGBA width?: number | "auto" | `${number}%` wrapMode?: "word" | "none" } @@ -20,6 +21,7 @@ export function Link(props: LinkProps) { return ( { From 6ff833a22bed0f087103e7969bfbb94736e62cb9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 02:40:47 +0000 Subject: [PATCH 003/689] chore: generate --- .../src/cli/cmd/tui/component/bg-pulse-render.ts | 9 ++++++++- packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts index 25b728f1e5..09a50ebe45 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts @@ -100,7 +100,14 @@ function mixChannel(base: number, overlay: number, alpha: number) { return Math.round(base + (overlay - base) * clamp(alpha)) } -function writeLogoTint(buffer: Uint16Array, offset: number, base: Rgb, primary: Rgb, primaryMix: number, peakMix: number) { +function writeLogoTint( + buffer: Uint16Array, + offset: number, + base: Rgb, + primary: Rgb, + primaryMix: number, + peakMix: number, +) { const p = clamp(primaryMix) const q = clamp(peakMix) const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q) diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx index 0482adea33..e7b02f8ee3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx @@ -1,4 +1,10 @@ -import { FrameBufferRenderable, RGBA, type OptimizedBuffer, type RenderContext, type RenderableOptions } from "@opentui/core" +import { + FrameBufferRenderable, + RGBA, + type OptimizedBuffer, + type RenderContext, + type RenderableOptions, +} from "@opentui/core" import { extend, useRenderer } from "@opentui/solid" import { onCleanup, onMount } from "solid-js" import { tint, useTheme } from "@tui/context/theme" From db6a03882954f9f3a54cc14c20a8315c07e2a685 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 7 May 2026 22:55:48 -0400 Subject: [PATCH 004/689] sync --- packages/console/app/src/routes/zen/util/error.ts | 10 ++++++---- packages/console/app/src/routes/zen/util/handler.ts | 12 ++++++++---- packages/console/core/sst-env.d.ts | 4 ++-- packages/console/function/sst-env.d.ts | 4 ++-- packages/console/resource/sst-env.d.ts | 4 ++-- packages/enterprise/sst-env.d.ts | 4 ++-- packages/function/sst-env.d.ts | 4 ++-- sst-env.d.ts | 4 ++-- 8 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index 216b6564e7..d17741ff70 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -13,13 +13,15 @@ class LimitError extends Error { } export class RateLimitError extends LimitError {} export class FreeUsageLimitError extends LimitError {} +export class BlackUsageLimitError extends LimitError {} -class SubscriptionUsageLimitError extends LimitError { +type LimitName = "5 hour" | "weekly" | "monthly" +export class GoUsageLimitError extends LimitError { workspace: string - constructor(message: string, workspace: string, retryAfter?: number) { + limitName: LimitName + constructor(message: string, workspace: string, limitName: LimitName, retryAfter?: number) { super(message, retryAfter) this.workspace = workspace + this.limitName = limitName } } -export class GoUsageLimitError extends SubscriptionUsageLimitError {} -export class BlackUsageLimitError extends SubscriptionUsageLimitError {} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 7cee86b47e..4b6fe5feb8 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -415,8 +415,11 @@ export async function handler( message: error.message, }, metadata: - error instanceof GoUsageLimitError || error instanceof BlackUsageLimitError - ? { workspace: error.workspace } + error instanceof GoUsageLimitError + ? { + workspace: error.workspace, + limitName: error.limitName, + } : {}, }), { status: 429, headers }, @@ -710,7 +713,6 @@ export async function handler( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), - authInfo.workspaceID, result.resetInSec, ) } @@ -729,7 +731,6 @@ export async function handler( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), - authInfo.workspaceID, result.resetInSec, ) } @@ -757,6 +758,7 @@ export async function handler( throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), authInfo.workspaceID, + "weekly", result.resetInSec, ) } @@ -773,6 +775,7 @@ export async function handler( throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), authInfo.workspaceID, + "monthly", result.resetInSec, ) } @@ -789,6 +792,7 @@ export async function handler( throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), authInfo.workspaceID, + "5 hour", result.resetInSec, ) } diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/sst-env.d.ts b/sst-env.d.ts index 52702acd7c..e75c54d056 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -114,8 +114,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "LogProcessor": { From 1cf8123bc6e17d759d651ef3ca493145adb23741 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 7 May 2026 23:32:47 -0400 Subject: [PATCH 005/689] fix(provider): align GPT-5 reasoning variants (#26268) --- packages/opencode/src/provider/transform.ts | 92 ++++++++-- .../opencode/test/provider/transform.test.ts | 171 +++++++++++++++--- 2 files changed, 216 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cd29e40822..7c0eaced26 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -500,6 +500,13 @@ export function topK(model: Provider.Model) { const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_1_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS] +const OPENAI_GPT5_2_PLUS_EFFORTS = [...OPENAI_GPT5_1_EFFORTS, "xhigh"] +const OPENAI_GPT5_PRO_EFFORTS = ["high"] +const OPENAI_GPT5_PRO_2_PLUS_EFFORTS = ["medium", "high", "xhigh"] +const OPENAI_GPT5_CHAT_EFFORTS = ["medium"] +const OPENAI_GPT5_CODEX_XHIGH_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_CODEX_3_PLUS_EFFORTS = ["none", ...OPENAI_GPT5_CODEX_XHIGH_EFFORTS] // OpenAI rolled out the `none` reasoning_effort tier on this date (Responses API). // Models released before it 400 on `reasoning_effort: "none"`, so we only expose @@ -513,17 +520,49 @@ const OPENAI_XHIGH_EFFORT_RELEASE_DATE = "2025-12-04" // "gpt-5", "gpt-5-nano", "gpt-5.4", "openai/gpt-5.4-codex". // Anchored to start-of-string or "/" so it doesn't false-match "gpt-50" or "gpt-5o". const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/ +const GPT5_VERSION_RE = /(?:^|\/)gpt-5[.-](\d+)(?:[.-]|$)/ +const GPT5_PRO_RE = /(?:^|\/)gpt-5[.-]?pro(?:[.-]|$)/ +const GPT5_VERSIONED_PRO_RE = /(?:^|\/)gpt-5[.-]\d+[.-]pro(?:[.-]|$)/ + +function gpt5Version(apiId: string) { + return Number(GPT5_VERSION_RE.exec(apiId)?.[1]) || undefined +} + +function versionedGpt5ReasoningEfforts(apiId: string) { + if (GPT5_VERSIONED_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_2_PLUS_EFFORTS + const version = gpt5Version(apiId) + if (version === undefined) return undefined + if (version === 1) return OPENAI_GPT5_1_EFFORTS + return OPENAI_GPT5_2_PLUS_EFFORTS +} + +function gpt5CodexReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("codex")) return undefined + const version = gpt5Version(apiId) + if (version !== undefined && version >= 3) return OPENAI_GPT5_CODEX_3_PLUS_EFFORTS + if (apiId.includes("codex-max") || (version !== undefined && version >= 2)) return OPENAI_GPT5_CODEX_XHIGH_EFFORTS + return WIDELY_SUPPORTED_EFFORTS +} + +function gpt5ChatReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("-chat")) return undefined + return gpt5Version(apiId) === undefined ? [] : OPENAI_GPT5_CHAT_EFFORTS +} // Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream -// routed through it, e.g. cf-ai-gateway) model exposes. Returns null for models -// with no tunable effort knob (gpt-5-pro). Effort order: weakest to strongest. -function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | null { +// routed through it, e.g. cf-ai-gateway) model exposes. Effort order: weakest +// to strongest. +function openaiReasoningEfforts(apiId: string, releaseDate: string) { const id = apiId.toLowerCase() - if (id === "gpt-5-pro" || id === "openai/gpt-5-pro") return null - if (id.includes("codex")) { - if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - return [...WIDELY_SUPPORTED_EFFORTS] - } + const chatEfforts = gpt5ChatReasoningEfforts(id) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS + const codexEfforts = gpt5CodexReasoningEfforts(id) + if (codexEfforts) return codexEfforts + const versionedEfforts = versionedGpt5ReasoningEfforts(id) + // GPT-5.1 replaced GPT-5's `minimal` effort with `none`; GPT-5.2+ + // additionally accepts `xhigh`. Model pages list the supported subset. + if (versionedEfforts) return versionedEfforts const efforts = [...WIDELY_SUPPORTED_EFFORTS] if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal") if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none") @@ -531,6 +570,14 @@ function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | return efforts } +function openaiCompatibleReasoningEfforts(id: string) { + const apiId = id.toLowerCase() + const chatEfforts = gpt5ChatReasoningEfforts(apiId) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_EFFORTS + return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { return ["low", "medium", "high", "xhigh", "max"] @@ -577,8 +624,13 @@ export function variants(model: Provider.Model): Record [effort, { reasoning: { effort } }])) + if (!id.includes("gpt") && !id.includes("gemini-3") && !id.includes("claude")) return {} + return Object.fromEntries( + (id.includes("gpt") ? openaiCompatibleReasoningEfforts(id) : OPENAI_EFFORTS).map((effort) => [ + effort, + { reasoning: { effort } }, + ]), + ) case "ai-gateway-provider": { // Cloudflare AI Gateway routes every upstream through its OpenAI-compatible @@ -589,7 +641,6 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) } return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) @@ -652,7 +703,9 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) + return Object.fromEntries( + openaiCompatibleReasoningEfforts(model.api.id).map((effort) => [effort, { reasoningEffort: effort }]), + ) case "@ai-sdk/github-copilot": if (model.id.includes("gemini")) { @@ -700,12 +753,11 @@ export function variants(model: Provider.Model): Record [ + (GPT5_FAMILY_RE.test(id) && gpt5Version(id) === undefined + ? ["minimal", ...WIDELY_SUPPORTED_EFFORTS] + : WIDELY_SUPPORTED_EFFORTS + ).map((effort) => [ effort, { reasoningEffort: effort, @@ -717,7 +769,6 @@ export function variants(model: Provider.Model): Record [ effort, @@ -1102,6 +1153,11 @@ export function smallOptions(model: Provider.Model) { model.api.npm === "@ai-sdk/github-copilot" ) { if (model.api.id.includes("gpt-5")) { + if (model.api.id.includes("-chat")) { + if (gpt5Version(model.api.id) === undefined) return { store: false } + return { store: false, reasoningEffort: "medium" } + } + if (model.api.id.includes("search-api")) return { store: false } if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) { return { store: false, reasoningEffort: "low" } } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c7a321d571..064313ec51 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2464,6 +2464,32 @@ describe("ProviderTransform.variants", () => { expect(result.high).toEqual({ reasoning: { effort: "high" } }) }) + for (const testCase of [ + { id: "openai/gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5.5-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5.2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex-max", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5.2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported OpenAI reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.id, + providerID: "openrouter", + api: { + id: testCase.id, + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } + test("gemini-3 returns OPENAI_EFFORTS with reasoning", () => { const model = createMockModel({ id: "openrouter/gemini-3-5-pro", @@ -2651,6 +2677,32 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) + + for (const testCase of [ + { id: "openai/gpt-5-5", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5-5-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5-2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-3-codex-max", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5-2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported OpenAI reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.id, + providerID: "gateway", + api: { + id: testCase.id, + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } }) describe("@ai-sdk/github-copilot", () => { @@ -2929,10 +2981,27 @@ describe("ProviderTransform.variants", () => { const result = ProviderTransform.variants(model) expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) }) + + for (const id of ["gpt-5-4", "gpt-5-5"]) { + test(`${id} does not add minimal effort`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id, + providerID: "azure", + api: { + id, + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + } }) describe("@ai-sdk/openai", () => { - test("gpt-5-pro returns empty object", () => { + test("gpt-5-pro returns only high effort", () => { const model = createMockModel({ id: "gpt-5-pro", providerID: "openai", @@ -2943,7 +3012,7 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["high"]) }) test("standard openai models return custom efforts with reasoningSummary", () => { @@ -2983,10 +3052,10 @@ describe("ProviderTransform.variants", () => { test("models after 2025-12-04 include 'xhigh' effort", () => { const model = createMockModel({ - id: "openai/gpt-5-chat", + id: "openai/gpt-5-reasoning", providerID: "openai", api: { - id: "gpt-5-chat", + id: "gpt-5-reasoning", url: "https://api.openai.com", npm: "@ai-sdk/openai", }, @@ -2996,20 +3065,38 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) }) - test("dotted gpt-5.x ids include 'minimal' (regression: matcher used to miss gpt-5.4)", () => { - const model = createMockModel({ - id: "gpt-5.4", - providerID: "openai", - api: { - id: "gpt-5.4", - url: "https://api.openai.com", - npm: "@ai-sdk/openai", - }, - release_date: "2026-03-05", + for (const testCase of [ + { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, + { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.5", modelID: "gpt-5-5", releaseDate: "2026-04-23", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.4-pro", releaseDate: "2026-03-05", efforts: ["medium", "high", "xhigh"] }, + { id: "gpt-5.5-pro", releaseDate: "2026-04-23", efforts: ["medium", "high", "xhigh"] }, + { id: "gpt-5-codex", releaseDate: "2025-09-23", efforts: ["low", "medium", "high"] }, + { id: "gpt-5.1-codex", releaseDate: "2025-11-13", efforts: ["low", "medium", "high"] }, + { id: "gpt-5.1-codex-max", releaseDate: "2025-11-13", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "gpt-5.2-codex", releaseDate: "2025-12-11", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "gpt-5.3-codex", releaseDate: "2026-01-22", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.3-codex-max", releaseDate: "2026-01-22", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5-chat-latest", releaseDate: "2025-08-07", efforts: [] }, + { id: "gpt-5.1-chat-latest", releaseDate: "2025-11-13", efforts: ["medium"] }, + { id: "gpt-5.2-chat-latest", releaseDate: "2025-12-11", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.modelID ?? testCase.id, + providerID: "openai", + api: { + id: testCase.id, + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: testCase.releaseDate, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) - }) + } test("gpt-50 (lookalike) does not get gpt-5 family treatment", () => { const model = createMockModel({ @@ -3486,18 +3573,20 @@ describe("ProviderTransform.variants", () => { release_date: releaseDate, }) - test("openai gpt-5.4 includes xhigh effort (regression: variant=xhigh used to be silently ignored)", () => { - const result = ProviderTransform.variants(cfModel("openai/gpt-5.4", "2026-03-05")) - expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) - expect(result.high).toEqual({ reasoningEffort: "high" }) - expect(Object.keys(result)).toContain("minimal") - }) - - test("openai gpt-5.2-codex includes xhigh", () => { - const result = ProviderTransform.variants(cfModel("openai/gpt-5.2-codex", "2025-12-11")) - expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) - }) + for (const testCase of [ + { id: "openai/gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5.2-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5.2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants(cfModel(testCase.id, "2026-03-05")) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } test("openai gpt-4o (no reasoning) returns empty", () => { const model = cfModel("openai/gpt-4o") @@ -3517,6 +3606,30 @@ describe("ProviderTransform.variants", () => { }) }) +describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { + const createModel = (apiId: string) => + ({ + id: `openai/${apiId}`, + providerID: "openai", + api: { + id: apiId, + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + }) as any + + for (const testCase of [ + { id: "gpt-5-chat-latest", options: { store: false } }, + { id: "gpt-5.1-chat-latest", options: { store: false, reasoningEffort: "medium" } }, + { id: "gpt-5.2-chat-latest", options: { store: false, reasoningEffort: "medium" } }, + { id: "gpt-5-search-api", options: { store: false } }, + ]) { + test(`${testCase.id} returns only supported small options`, () => { + expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.options) + }) + } +}) + describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { const createModel = (overrides: Partial = {}) => ({ From 114eeb21dc5af4649979463dfaa25471b3120468 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 03:33:55 +0000 Subject: [PATCH 006/689] chore: generate --- packages/opencode/test/provider/transform.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 064313ec51..3fdc226375 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3068,7 +3068,12 @@ describe("ProviderTransform.variants", () => { for (const testCase of [ { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, - { id: "gpt-5.5", modelID: "gpt-5-5", releaseDate: "2026-04-23", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { + id: "gpt-5.5", + modelID: "gpt-5-5", + releaseDate: "2026-04-23", + efforts: ["none", "low", "medium", "high", "xhigh"], + }, { id: "gpt-5.4-pro", releaseDate: "2026-03-05", efforts: ["medium", "high", "xhigh"] }, { id: "gpt-5.5-pro", releaseDate: "2026-04-23", efforts: ["medium", "high", "xhigh"] }, { id: "gpt-5-codex", releaseDate: "2025-09-23", efforts: ["low", "medium", "high"] }, From 2ba9aa21961697bf9ff5de3b18becaabe56aefd7 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Thu, 7 May 2026 23:42:39 -0400 Subject: [PATCH 007/689] feat(desktop): working indicator on project sidebar (#26223) --- packages/app/src/pages/layout/sidebar-items.tsx | 12 +++++++++++- packages/app/src/pages/layout/sidebar-project.tsx | 10 +++++++++- .../cli/cmd/tui/component/dialog-session-list.tsx | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 296f035ce2..f27a9bb7a9 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -26,7 +26,12 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url return icon?.url } -export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { +export const ProjectIcon = (props: { + project: LocalProject + class?: string + notify?: boolean + working?: boolean +}): JSX.Element => { const globalSync = useGlobalSync() const notification = useNotification() const permission = usePermission() @@ -65,6 +70,11 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti }} /> + +
+ +
+
) } diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 2ba20092c5..58595c25b9 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -56,6 +56,7 @@ const ProjectTile = (props: { sidebarHovering: Accessor selected: Accessor active: Accessor + isWorking: Accessor overlay: Accessor suppressHover: Accessor dirs: Accessor @@ -143,7 +144,7 @@ const ProjectTile = (props: { }} onBlur={() => props.setOpen(false)} > - + @@ -301,6 +302,12 @@ export const SortableProject = (props: { } const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const isWorking = createMemo(() => + dirs().some((directory) => { + const [store] = globalSync.child(directory, { bootstrap: false }) + return Object.values(store.session_status).some((status) => status?.type === "busy" || status?.type === "retry") + }), + ) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) @@ -313,6 +320,7 @@ export const SortableProject = (props: { sidebarHovering={props.ctx.sidebarHovering} selected={selected} active={active} + isWorking={isWorking} overlay={overlay} suppressHover={() => state.suppressHover} dirs={dirs} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 6d3322151a..542449f5df 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -154,7 +154,7 @@ export function DialogSessionList() { } const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" + const isWorking = status?.type === "busy" || status?.type === "retry" return { title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, From 319498e2fd4e22eb6d38bc5810c1c089cf709162 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 7 May 2026 23:43:42 -0400 Subject: [PATCH 008/689] fix(provider): constrain OpenAI deep research efforts (#26273) --- packages/opencode/src/provider/transform.ts | 1 + packages/opencode/test/provider/transform.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7c0eaced26..69a0d484f4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -554,6 +554,7 @@ function gpt5ChatReasoningEfforts(apiId: string) { // to strongest. function openaiReasoningEfforts(apiId: string, releaseDate: string) { const id = apiId.toLowerCase() + if (id.includes("deep-research")) return ["medium"] const chatEfforts = gpt5ChatReasoningEfforts(id) if (chatEfforts) return chatEfforts if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 3fdc226375..25ed2aadc3 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3066,6 +3066,14 @@ describe("ProviderTransform.variants", () => { }) for (const testCase of [ + { id: "o1", releaseDate: "2024-12-17", efforts: ["low", "medium", "high"] }, + { id: "o1-pro", releaseDate: "2025-03-19", efforts: ["low", "medium", "high"] }, + { id: "o3", releaseDate: "2025-04-16", efforts: ["low", "medium", "high"] }, + { id: "o3-mini", releaseDate: "2025-01-31", efforts: ["low", "medium", "high"] }, + { id: "o3-pro", releaseDate: "2025-06-10", efforts: ["low", "medium", "high"] }, + { id: "o4-mini", releaseDate: "2025-04-16", efforts: ["low", "medium", "high"] }, + { id: "o3-deep-research", releaseDate: "2025-06-26", efforts: ["medium"] }, + { id: "o4-mini-deep-research", releaseDate: "2025-06-26", efforts: ["medium"] }, { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, { From e0396b809a8b685c8f84a2f5f711b68846e17bb5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 00:06:07 -0400 Subject: [PATCH 009/689] fix(provider): align Anthropic Opus 4.5 efforts (#26275) --- packages/opencode/src/provider/transform.ts | 4 + .../opencode/test/provider/transform.test.ts | 91 +++++++++---------- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 69a0d484f4..210c574d4f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -811,6 +811,10 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v))) { + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }])) + } + return { high: { thinking: { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 25ed2aadc3..c52a7bfa44 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3128,53 +3128,50 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/anthropic", () => { - test("sonnet 4.6 returns adaptive thinking options", () => { - const model = createMockModel({ - id: "anthropic/claude-sonnet-4-6", - providerID: "anthropic", - api: { - id: "claude-sonnet-4-6", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) - expect(result.high).toEqual({ - thinking: { - type: "adaptive", - }, - effort: "high", - }) - }) - - test("opus 4.7 returns adaptive thinking options with xhigh", () => { - const model = createMockModel({ - id: "anthropic/claude-opus-4-7", - providerID: "anthropic", - api: { - id: "claude-opus-4-7", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - thinking: { - type: "adaptive", - display: "summarized", - }, - effort: "xhigh", - }) - expect(result.max).toEqual({ - thinking: { - type: "adaptive", - display: "summarized", - }, - effort: "max", - }) - }) + for (const testCase of [ + { + name: "opus 4.5", + apiIds: ["claude-opus-4-5-20251101", "claude-opus-4.5-20251101"], + efforts: ["low", "medium", "high"], + expectedHigh: { effort: "high" }, + }, + { + name: "sonnet 4.6", + apiIds: ["claude-sonnet-4-6", "claude-sonnet-4.6"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.6", + apiIds: ["claude-opus-4-6", "claude-opus-4.6"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.7", + apiIds: ["claude-opus-4-7", "claude-opus-4.7"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + ]) { + for (const apiId of testCase.apiIds) { + test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: `anthropic/${apiId}`, + providerID: "anthropic", + api: { + id: apiId, + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + expect(result.high).toEqual(testCase.expectedHigh) + }) + } + } test("github copilot opus 4.7 returns only medium reasoning effort", () => { const model = createMockModel({ From 4e14f79511728d04329da664b747dd0b359cf931 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 7 May 2026 23:17:43 -0500 Subject: [PATCH 010/689] fix: tweaks to transform logic for anthropic and bedrock (#26276) --- packages/opencode/src/provider/transform.ts | 18 +- packages/opencode/src/session/message-v2.ts | 39 ++--- .../opencode/test/session/message-v2.test.ts | 157 +++++++++++++++++- 3 files changed, 190 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 210c574d4f..3f52f6a2aa 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -135,9 +135,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.anthropic?.signature != null || + part.providerOptions?.anthropic?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined @@ -156,9 +163,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.bedrock?.signature != null || + part.providerOptions?.bedrock?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ed09262d0e..2930dbaeb3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -35,7 +35,7 @@ interface FetchDecompressionError extends Error { path: string } -export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" +export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) @@ -734,25 +734,25 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages - // for providers that don't support media in tool results. + // for providers that don't support that media type in tool results. // // OpenAI-compatible APIs only support string content in tool results, so we need - // to extract media and inject as user messages. Other SDKs (anthropic, google, - // bedrock) handle type: "content" with media parts natively. + // to extract media and inject as user messages. Some SDKs only support a subset + // of media in tool results; e.g. Bedrock supports images but not PDFs there. // - // Only apply this workaround if the model actually supports image input - - // otherwise there's no point extracting images. - const supportsMediaInToolResults = (() => { + // Only apply this workaround if the model actually supports that media input - + // otherwise unsupportedParts() will turn it into a user-visible error. + const supportsMediaInToolResult = (attachment: { mime: string }) => { if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return true + if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { const id = model.api.id.toLowerCase() return id.includes("gemini-3") && !id.includes("gemini-2") } return false - })() + } const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { const output = options.output @@ -797,9 +797,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "user", parts: [], } - result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) + // User message parts should never be empty + if (part.type === "text" && !part.ignored && part.text !== "") userMessage.parts.push({ type: "text", text: part.text, @@ -834,11 +834,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } } + if (userMessage.parts.length > 0) result.push(userMessage) } if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` - const media: Array<{ mime: string; url: string }> = [] + const media: Array<{ mime: string; url: string; filename?: string }> = [] if ( msg.info.error && @@ -864,11 +865,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // a proxy, or a lower-level library, but preserving a non-empty separator // here is the only safe replay point we have. // Use a single space so the separator survives replay without changing - // the neighboring signed reasoning blocks. Bedrock-hosted Claude stores - // the same signature under the bedrock metadata namespace. + // the neighboring signed reasoning blocks. const hasSignedReasoning = msg.parts.some((part) => { if (part.type !== "reasoning") return false - return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null + return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { if (part.type === "text") { @@ -894,11 +894,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) - const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) - if (!supportsMediaInToolResults && mediaAttachments.length > 0) { - media.push(...mediaAttachments) + const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a)) + if (extractedMedia.length > 0) { + media.push(...extractedMedia) } - const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments + const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a)) const output = finalAttachments.length > 0 @@ -988,6 +988,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( type: "file" as const, url: attachment.url, mediaType: attachment.mime, + filename: attachment.filename, })), ], }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 999b61b48e..d9c71f8c07 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -155,6 +155,54 @@ describe("session.message-v2.toModelMessage", () => { expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) + test("filters out user messages with only empty text parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) + }) + + test("filters empty user text parts while keeping non-empty parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + { + ...basePart(messageID, "p2"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ]) + }) + test("includes synthetic text parts", async () => { const messageID = "m-user" @@ -443,6 +491,108 @@ describe("session.message-v2.toModelMessage", () => { }) }) + test("moves bedrock pdf tool-result media into a separate user message", async () => { + const bedrockModel: Provider.Model = { + ...model, + id: ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"), + providerID: ProviderID.make("amazon-bedrock"), + api: { + id: "anthropic.claude-sonnet-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + capabilities: { + ...model.capabilities, + attachment: true, + input: { + ...model.capabilities.input, + image: true, + pdf: true, + }, + }, + } + const pdf = Buffer.from("%PDF-1.4\n").toString("base64") + const userID = "m-user-bedrock-pdf" + const assistantID = "m-assistant-bedrock-pdf" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1-bedrock-pdf"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1-bedrock-pdf"), + type: "tool", + callID: "call-bedrock-pdf-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/tmp/example.pdf" }, + output: "PDF read successfully", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-bedrock-pdf-1"), + type: "file", + mime: "application/pdf", + filename: "example.pdf", + url: `data:application/pdf;base64,${pdf}`, + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, bedrockModel)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + input: { filePath: "/tmp/example.pdf" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + output: { type: "text", value: "PDF read successfully" }, + }, + ], + }, + { + role: "user", + content: [ + { type: "text", text: "Attached media from tool result:" }, + { type: "file", mediaType: "application/pdf", filename: "example.pdf", data: `data:application/pdf;base64,${pdf}` }, + ], + }, + ]) + }) + test("omits provider metadata when assistant model differs", async () => { const userID = "m-user" const assistantID = "m-assistant" @@ -1134,8 +1284,9 @@ describe("session.message-v2.toModelMessage", () => { expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") }) - test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => { - // AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock + test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => { + // Bedrock signed reasoning is preserved as reasoning metadata, but unlike the + // direct Anthropic path we do not preserve empty text separators for Bedrock. const assistantID = "m-assistant-bedrock" const input: MessageV2.WithParts[] = [ { @@ -1157,7 +1308,7 @@ describe("session.message-v2.toModelMessage", () => { expect(result).toHaveLength(1) const texts = (result[0].content as any[]).filter((p) => p.type === "text") - expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) }) test("leaves empty text alone when reasoning has no Anthropic signature", async () => { From 9c88235121eda5f5afd2dd806fbc6e6d8eef664d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 04:18:54 +0000 Subject: [PATCH 011/689] chore: generate --- packages/opencode/test/session/message-v2.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index d9c71f8c07..08629f5b1b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -587,7 +587,12 @@ describe("session.message-v2.toModelMessage", () => { role: "user", content: [ { type: "text", text: "Attached media from tool result:" }, - { type: "file", mediaType: "application/pdf", filename: "example.pdf", data: `data:application/pdf;base64,${pdf}` }, + { + type: "file", + mediaType: "application/pdf", + filename: "example.pdf", + data: `data:application/pdf;base64,${pdf}`, + }, ], }, ]) From 30868f52ea997ada6ac452e47ec00fb5ee59302c Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 00:26:08 -0400 Subject: [PATCH 012/689] go: update rate limit error copy --- packages/console/app/src/i18n/ar.ts | 8 ++++++-- packages/console/app/src/i18n/br.ts | 8 ++++++-- packages/console/app/src/i18n/da.ts | 8 ++++++-- packages/console/app/src/i18n/de.ts | 8 ++++++-- packages/console/app/src/i18n/en.ts | 8 ++++++-- packages/console/app/src/i18n/es.ts | 8 ++++++-- packages/console/app/src/i18n/fr.ts | 8 ++++++-- packages/console/app/src/i18n/it.ts | 8 ++++++-- packages/console/app/src/i18n/ja.ts | 8 ++++++-- packages/console/app/src/i18n/ko.ts | 8 ++++++-- packages/console/app/src/i18n/no.ts | 8 ++++++-- packages/console/app/src/i18n/pl.ts | 8 ++++++-- packages/console/app/src/i18n/ru.ts | 8 ++++++-- packages/console/app/src/i18n/th.ts | 8 ++++++-- packages/console/app/src/i18n/tr.ts | 8 ++++++-- packages/console/app/src/i18n/zh.ts | 7 ++++++- packages/console/app/src/i18n/zht.ts | 7 ++++++- .../console/app/src/routes/zen/util/handler.ts | 16 +++++++++++++--- 18 files changed, 115 insertions(+), 35 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 12ec7f1fbd..f413b5572f 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -355,8 +355,12 @@ export const dict = { "zen.api.error.missingApiKey": "مفتاح API مفقود.", "zen.api.error.invalidApiKey": "مفتاح API غير صالح.", "zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "تم الوصول إلى حد الاستخدام الأسبوعي. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "تم الوصول إلى حد الاستخدام الشهري. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}", "zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 0a6d8f153e..8466acc5fd 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Chave de API ausente.", "zen.api.error.invalidApiKey": "Chave de API inválida.", "zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite de uso semanal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite de uso mensal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 15e7151b67..9338e3add5 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -359,8 +359,12 @@ export const dict = { "zen.api.error.missingApiKey": "Manglende API-nøgle.", "zen.api.error.invalidApiKey": "Ugyldig API-nøgle.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Ugentlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Månedlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}", "zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 0efcce78bf..7a2d3e91b4 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -362,8 +362,12 @@ export const dict = { "zen.api.error.missingApiKey": "Fehlender API-Key.", "zen.api.error.invalidApiKey": "Ungültiger API-Key.", "zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Wöchentliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Monatliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}", "zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index f2cf3c14a4..b7ef397be6 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -355,8 +355,12 @@ export const dict = { "zen.api.error.missingApiKey": "Missing API key.", "zen.api.error.invalidApiKey": "Invalid API key.", "zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Subscription quota exceeded. You can continue using free models.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Weekly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Monthly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}", "zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 5614a8c7ad..f6347d3b52 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Falta la clave API.", "zen.api.error.invalidApiKey": "Clave API inválida.", "zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Límite de uso semanal alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Límite de uso mensual alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 390025d275..5d1cd0fab7 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Clé API manquante.", "zen.api.error.invalidApiKey": "Clé API invalide.", "zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Quota d'abonnement dépassé. Vous pouvez continuer à utiliser les modèles gratuits.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite d'utilisation hebdomadaire atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite d'utilisation mensuelle atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Aucune méthode de paiement. Ajoutez une méthode de paiement ici : {{billingUrl}}", "zen.api.error.insufficientBalance": "Solde insuffisant. Gérez votre facturation ici : {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 3737186996..07da9434eb 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -359,8 +359,12 @@ export const dict = { "zen.api.error.missingApiKey": "Chiave API mancante.", "zen.api.error.invalidApiKey": "Chiave API non valida.", "zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Quota dell'abbonamento superata. Puoi continuare a utilizzare modelli gratuiti.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite di utilizzo settimanale raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite di utilizzo mensile raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Nessun metodo di pagamento. Aggiungi un metodo di pagamento qui: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insufficiente. Gestisci la tua fatturazione qui: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 66f3c4a89d..975728fe7e 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -360,8 +360,12 @@ export const dict = { "zen.api.error.invalidApiKey": "無効なAPIキーです。", "zen.api.error.subscriptionQuotaExceeded": "サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "サブスクリプションの制限を超えました。無料モデルは引き続きご利用いただけます。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5時間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "週間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "月間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "お支払い方法がありません。こちらからお支払い方法を追加してください: {{billingUrl}}", "zen.api.error.insufficientBalance": "残高が不足しています。こちらから請求を管理してください: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 04482d35f6..293c3eb7d9 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -354,8 +354,12 @@ export const dict = { "zen.api.error.missingApiKey": "API 키가 누락되었습니다.", "zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.", "zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "구독 할당량을 초과했습니다. 무료 모델은 계속 사용할 수 있습니다.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "주간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "월간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "결제 수단이 없습니다. 결제 수단을 추가하세요: {{billingUrl}}", "zen.api.error.insufficientBalance": "잔액이 부족합니다. 결제 관리를 여기서 하세요: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 31200d3edd..27b5522e32 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -360,8 +360,12 @@ export const dict = { "zen.api.error.missingApiKey": "Mangler API-nøkkel.", "zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnementskvote overskredet. Du kan fortsette å bruke gratis modeller.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Ukentlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Månedlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Legg til en betalingsmetode her: {{billingUrl}}", "zen.api.error.insufficientBalance": "Utilstrekkelig saldo. Administrer faktureringen din her: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 50d904bc56..7f8c849156 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -361,8 +361,12 @@ export const dict = { "zen.api.error.missingApiKey": "Brak klucza API.", "zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.", "zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Przekroczono limit subskrypcji. Możesz kontynuować korzystanie z darmowych modeli.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Osiągnięto tygodniowy limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Osiągnięto miesięczny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Brak metody płatności. Dodaj metodę płatności tutaj: {{billingUrl}}", "zen.api.error.insufficientBalance": "Niewystarczające saldo. Zarządzaj swoimi płatnościami tutaj: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 651309fc95..4ac54c2ac0 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -365,8 +365,12 @@ export const dict = { "zen.api.error.missingApiKey": "Отсутствует API ключ.", "zen.api.error.invalidApiKey": "Неверный API ключ.", "zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Квота подписки превышена. Вы можете продолжить использовать бесплатные модели.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Достигнут недельный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Достигнут месячный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Нет способа оплаты. Добавьте способ оплаты здесь: {{billingUrl}}", "zen.api.error.insufficientBalance": "Недостаточно средств. Управляйте оплатой здесь: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 42c9e455fd..280b9d9fa8 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -356,8 +356,12 @@ export const dict = { "zen.api.error.missingApiKey": "ไม่มี API key", "zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง", "zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "โควต้าการสมัครสมาชิกเกินขีดจำกัด คุณสามารถดำเนินการต่อโดยใช้โมเดลฟรี", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "ถึงขีดจำกัดการใช้งานรายสัปดาห์แล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "ถึงขีดจำกัดการใช้งานรายเดือนแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "ไม่มีวิธีการชำระเงิน เพิ่มวิธีการชำระเงินที่นี่: {{billingUrl}}", "zen.api.error.insufficientBalance": "ยอดเงินคงเหลือไม่เพียงพอ จัดการการเรียกเก็บเงินของคุณที่นี่: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 64380db375..a8f449dc47 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "API anahtarı eksik.", "zen.api.error.invalidApiKey": "Geçersiz API anahtarı.", "zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonelik kotası aşıldı. Ücretsiz modelleri kullanmaya devam edebilirsiniz.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Haftalık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Aylık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ödeme yöntemi bulunamadı. Buradan bir ödeme yöntemi ekleyin: {{billingUrl}}", "zen.api.error.insufficientBalance": "Yetersiz bakiye. Faturalandırmanızı buradan yönetin: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 3b104cca6d..ced0060ca0 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -343,7 +343,12 @@ export const dict = { "zen.api.error.missingApiKey": "缺少 API 密钥。", "zen.api.error.invalidApiKey": "无效的 API 密钥。", "zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出订阅配额。您可以继续使用免费模型。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "已达到每周使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "已达到每月使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "没有付款方式。请在此处添加付款方式:{{billingUrl}}", "zen.api.error.insufficientBalance": "余额不足。请在此处管理您的计费:{{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index a4d5512da4..e3e374a329 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -343,7 +343,12 @@ export const dict = { "zen.api.error.missingApiKey": "缺少 API 金鑰。", "zen.api.error.invalidApiKey": "無效的 API 金鑰。", "zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出訂閱配額。你可以繼續使用免費模型。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "已達每週使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "已達每月使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "無付款方式。請在此處新增付款方式:{{billingUrl}}", "zen.api.error.insufficientBalance": "餘額不足。請在此處管理你的帳務:{{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 4b6fe5feb8..278a541610 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -744,6 +744,7 @@ export async function handler( // Validate lite subscription billing if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) { try { + const consoleGoUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/go` const sub = authInfo.lite const liteData = LiteData.getLimits() @@ -756,7 +757,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionWeeklyLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "weekly", result.resetInSec, @@ -773,7 +777,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionMonthlyLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "monthly", result.resetInSec, @@ -790,7 +797,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionRollingLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "5 hour", result.resetInSec, From dd8bb44d1db520ada12da5efc5611696372d0810 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 8 May 2026 13:34:53 +0800 Subject: [PATCH 013/689] refactor(desktop): use electron-log in shell-env and simplify env merging (#26284) --- packages/desktop/src/main/logging.ts | 6 +++++- packages/desktop/src/main/server.ts | 19 ++++++++----------- packages/desktop/src/main/shell-env.ts | 12 +++++++----- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 1f1c5e54e3..5d373ed27f 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -1,3 +1,4 @@ +import { MainLogger } from "electron-log" import log from "electron-log/main.js" import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs" import { dirname, join } from "node:path" @@ -5,11 +6,14 @@ import { dirname, join } from "node:path" const MAX_LOG_AGE_DAYS = 7 const TAIL_LINES = 1000 +let logger: MainLogger +export const getLogger = () => logger + export function initLogging() { log.transports.file.maxSize = 5 * 1024 * 1024 initConsoleTransport() cleanup() - return log + return (logger = log) } export function tail(): string { diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 635a93578a..909138b89c 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url" import { app, utilityProcess } from "electron" import type { Details } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" +import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" import type { SqliteMigrationProgress } from "../preload/types" @@ -57,16 +57,13 @@ export function setWslConfig(config: WslConfig) { export function preferAppEnv(userDataPath: string) { const shell = process.platform === "win32" ? null : getUserShell() - Object.assign( - process.env, - mergeShellEnv(shell ? loadShellEnv(shell) : null, { - ...process.env, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, - }), - ) + Object.assign(process.env, { + ...(shell ? loadShellEnv(shell) : null), + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }) } export async function spawnLocalServer( diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index f57677323c..8a1ee1f586 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" +import { getLogger } from "./logging"; const TIMEOUT = 5_000 @@ -55,28 +56,29 @@ export function isNushell(shell: string) { } export function loadShellEnv(shell: string) { + const logger = getLogger() if (isNushell(shell)) { - console.log(`[server] Skipping shell env probe for nushell: ${shell}`) + logger.log(`[server] Skipping shell env probe for nushell: ${shell}`) return null } const interactive = probe(shell, "-il") if (interactive.type === "Loaded") { - console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) + logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) return interactive.value } if (interactive.type === "Timeout") { - console.warn(`[server] Interactive shell env probe timed out: ${shell}`) + logger.log(`[server] Interactive shell env probe timed out: ${shell}`) return null } const login = probe(shell, "-l") if (login.type === "Loaded") { - console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) + logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) return login.value } - console.warn(`[server] Falling back to app environment: ${shell}`) + logger.log(`[server] Falling back to app environment: ${shell}`) return null } From cef0c8ac844189872875cae6950fc14ee3b522cb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 05:36:06 +0000 Subject: [PATCH 014/689] chore: generate --- packages/desktop/src/main/shell-env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index 8a1ee1f586..4a65fbf0f7 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" -import { getLogger } from "./logging"; +import { getLogger } from "./logging" const TIMEOUT = 5_000 From 6f165e23deae6d3a812af2b0aaf1557d94251a15 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 15:36:28 +1000 Subject: [PATCH 015/689] perf(ui): defer tool status width measurement (#26282) --- .../ui/src/components/tool-status-title.tsx | 132 +++++++++--------- 1 file changed, 65 insertions(+), 67 deletions(-) diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 412d92e3db..5c46593f71 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { TextShimmer } from "./text-shimmer" @@ -15,10 +15,8 @@ function common(active: string, done: string) { } function contentWidth(el: HTMLSpanElement | undefined) { - if (!el) return 0 - const range = document.createRange() - range.selectNodeContents(el) - return Math.ceil(range.getBoundingClientRect().width) + if (!el) return + return `${Math.ceil(el.getBoundingClientRect().width)}px` } export function ToolStatusTitle(props: { @@ -37,99 +35,99 @@ export function ToolStatusTitle(props: { const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) const [state, setState] = createStore({ - width: "auto", - ready: false, + active: props.active, + animating: false, + width: undefined as string | undefined, }) const width = () => state.width - const ready = () => state.ready + const active = () => state.active + const animating = () => state.animating let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined + let widthRef: HTMLSpanElement | undefined let frame: number | undefined - let readyFrame: number | undefined - - const measure = () => { - const target = props.active ? activeRef : doneRef - const px = contentWidth(target) - if (px > 0) setState("width", `${px}px`) - } - - const schedule = () => { - if (typeof requestAnimationFrame !== "function") { - measure() - return - } - if (frame !== undefined) cancelAnimationFrame(frame) - frame = requestAnimationFrame(() => { - frame = undefined - measure() - }) - } + let finishTimer: ReturnType | undefined const finish = () => { - if (typeof requestAnimationFrame !== "function") { - setState("ready", true) + if (frame !== undefined) cancelAnimationFrame(frame) + if (finishTimer !== undefined) clearTimeout(finishTimer) + frame = undefined + finishTimer = undefined + setState("animating", false) + setState("width", undefined) + } + + const animate = () => { + const first = contentWidth(widthRef) + finish() + setState("animating", true) + setState("active", props.active) + const last = contentWidth(props.active ? activeRef : doneRef) + if (!first || !last) { + finish() return } - if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) - readyFrame = requestAnimationFrame(() => { - readyFrame = undefined - setState("ready", true) + + setState("width", first) + if (first === last) { + finishTimer = setTimeout(finish, 600) + return + } + + frame = requestAnimationFrame(() => { + frame = undefined + setState("width", last) + finishTimer = setTimeout(finish, 600) }) } - createEffect(on([() => props.active, activeTail, doneTail, suffix], () => schedule())) - - onMount(() => { - measure() - const fonts = typeof document !== "undefined" ? document.fonts : undefined - if (!fonts) { - finish() - return - } - void fonts.ready.finally(() => { - measure() - finish() - }) - }) + createEffect(on([() => props.active, activeTail, doneTail], () => animate(), { defer: true })) onCleanup(() => { - if (frame !== undefined) cancelAnimationFrame(frame) - if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + finish() }) return ( - - - - - - + + + + + + + + + + + } > - + - - - - - - - + + + + + + + + + + + From bb3f14119b25edcf0478757f7c5f9e1a8e664dab Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 01:51:31 -0400 Subject: [PATCH 016/689] tui: update go upsell copy --- packages/opencode/src/session/retry.ts | 24 ++++++++++---------- packages/opencode/test/session/retry.test.ts | 8 ++++--- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index a4ef5b7a8f..3bccee212d 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -6,7 +6,6 @@ import { iife } from "@/util/iife" export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" -export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG" export const GO_UPSELL_URL = "https://opencode.ai/go" export type Retryable = { @@ -83,11 +82,11 @@ export function retryable(error: Err) { if (error.data.responseBody?.includes("GoUsageLimitError")) { const body = parseJSON(error.data.responseBody) const workspace = str(body?.metadata?.workspace) - const limit = str(body?.metadata?.limit) - const resetAt = num(body?.metadata?.resetAt) + const limitName = str(body?.metadata?.limitName) + const retryAfter = num(error.data.responseHeaders?.["retry-after"]) const resetIn = iife(() => { - if (resetAt === undefined) return "" - const seconds = Math.max(0, Math.ceil(resetAt)) + if (retryAfter === undefined) return "" + const seconds = Math.max(0, Math.ceil(retryAfter)) const days = Math.floor(seconds / 86_400) const hours = Math.floor((seconds % 86_400) / 3_600) const minutes = Math.ceil((seconds % 3_600) / 60) @@ -97,16 +96,17 @@ export function retryable(error: Err) { if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour") return minutes > 0 ? unit(minutes, "minute") : "less than a minute" }) + + const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` + + const link = `https://opencode.ai/workspace/${workspace}/go` return { - message: PAYG_UPSELL_MESSAGE, + message: `${message} - ${link}`, action: { title: "Go limit reached", - message: - limit && resetIn - ? `You hit your ${limit} limit. It will reset in ${resetIn}. You can also enable pay-as-you-go.` - : "Enable pay-as-you-go to keep using Go models after your subscription quota is used.", - label: "enable PAYG", - ...(workspace ? { link: `https://opencode.ai/workspace/${workspace}/go` } : {}), + message, + label: "open settings", + link, }, } } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 8a4d6d6af0..9bb5e48652 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -279,11 +279,13 @@ describe("session.retry.retryable", () => { ) expect(SessionRetry.retryable(error)).toEqual({ - message: SessionRetry.PAYG_UPSELL_MESSAGE, + message: + "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", action: { title: "Go limit reached", - message: "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", - label: "enable PAYG", + message: + "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance", + label: "open settings", link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", }, }) From 21ae91b4f237f9ef9947a6988f4a25de3ab1c31a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 8 May 2026 14:19:09 +0800 Subject: [PATCH 017/689] refactor(desktop): convert main process to Effect-TS (#26148) --- packages/desktop/src/main/index.ts | 650 +++++++++++---------------- packages/desktop/src/main/updater.ts | 126 ++++++ 2 files changed, 384 insertions(+), 392 deletions(-) create mode 100644 packages/desktop/src/main/updater.ts diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 52e45a702c..1b624800e8 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -7,38 +7,9 @@ import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" -import { app, BrowserWindow, dialog } from "electron" -import pkg from "electron-updater" +import { app, BrowserWindow } from "electron" import contextMenu from "electron-context-menu" -contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) - -// on macOS apps run in `/` which can cause issues with ripgrep -try { - process.chdir(homedir()) -} catch {} - -process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" - -const APP_NAMES: Record = { - dev: "OpenCode Dev", - beta: "OpenCode Beta", - prod: "OpenCode", -} -const APP_IDS: Record = { - dev: "ai.opencode.desktop.dev", - beta: "ai.opencode.desktop.beta", - prod: "ai.opencode.desktop", -} -const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" -const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" -const onboardingTestRoot = setupOnboardingTestEnv() -app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") -app.setAppUserModelId(appId) -app.setPath("userData", onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId)) -if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) -const logger = initLogging() -const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" @@ -64,104 +35,30 @@ import { setDockIcon, } from "./windows" import { migrate } from "./migrate" +import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater" +import { Deferred, Effect, Fiber } from "effect" + +const APP_NAMES: Record = { + dev: "OpenCode Dev", + beta: "OpenCode Beta", + prod: "OpenCode", +} +const APP_IDS: Record = { + dev: "ai.opencode.desktop.dev", + beta: "ai.opencode.desktop.beta", + prod: "ai.opencode.desktop", +} +const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" + +let logger: ReturnType +let mainWindow: BrowserWindow | null = null +let server: SidecarListener | null = null const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } -let mainWindow: BrowserWindow | null = null -let server: SidecarListener | null = null -const loadingComplete = defer() - const pendingDeepLinks: string[] = [] -const serverReady = defer() - -useSystemCertificates() - -function setupOnboardingTestEnv() { - if (!TEST_ONBOARDING) return - - const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) - rmSync(root, { recursive: true, force: true }) - ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => - mkdirSync(join(root, dir), { recursive: true }), - ) - process.env.OPENCODE_DB = ":memory:" - process.env.XDG_DATA_HOME = join(root, "data") - process.env.XDG_CONFIG_HOME = join(root, "config") - process.env.XDG_CACHE_HOME = join(root, "cache") - process.env.XDG_STATE_HOME = join(root, "state") - return root -} - -logger.log("app starting", { - version: app.getVersion(), - packaged: app.isPackaged, - onboardingTest: Boolean(onboardingTestRoot), -}) - -setupApp() - -function setupApp() { - ensureLoopbackNoProxy() - useEnvProxy() - app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") - if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") - - if (!app.requestSingleInstanceLock()) { - app.quit() - return - } - - preferAppEnv(app.getPath("userData")) - - app.on("second-instance", (_event: Event, argv: string[]) => { - const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) - if (urls.length) { - logger.log("deep link received via second-instance", { urls }) - emitDeepLinks(urls) - } - focusMainWindow() - }) - - app.on("open-url", (event: Event, url: string) => { - event.preventDefault() - logger.log("deep link received via open-url", { url }) - emitDeepLinks([url]) - }) - - app.on("before-quit", () => { - void killSidecar() - }) - - app.on("will-quit", () => { - void killSidecar() - }) - - for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - void killSidecar().finally(() => app.exit(0)) - }) - } - - void app.whenReady().then(async () => { - if (!TEST_ONBOARDING) migrate() - app.setAsDefaultProtocolClient("opencode") - registerRendererProtocol() - setDockIcon() - setupAutoUpdater() - await initialize() - }) -} - -function useSystemCertificates() { - try { - setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) - } catch (error) { - logger.warn("failed to load system certificates", error) - } -} - function useEnvProxy() { try { // Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2. @@ -177,145 +74,12 @@ function emitDeepLinks(urls: string[]) { if (mainWindow) sendDeepLinks(mainWindow, urls) } -function focusMainWindow() { - if (!mainWindow) return - mainWindow.show() - mainWindow.focus() -} - function setInitStep(step: InitStep) { initStep = step logger.log("init step", { step }) initEmitter.emit("step", step) } -async function initialize() { - const needsMigration = !sqliteFileExists() - let overlay: BrowserWindow | null = null - - const port = await getSidecarPort() - const hostname = "127.0.0.1" - const url = `http://${hostname}:${port}` - const password = randomUUID() - - const loadingTask = (async () => { - logger.log("sidecar connection started", { url }) - - initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { - setInitStep({ phase: "sqlite_waiting" }) - if (overlay) sendSqliteMigrationProgress(overlay, progress) - if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - }) - - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer( - hostname, - port, - password, - () => { - ensureLoopbackNoProxy() - useEnvProxy() - }, - { - needsMigration, - userDataPath: app.getPath("userData"), - onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => logger.log("sidecar stdout", { message }), - onStderr: (message) => logger.warn("sidecar stderr", { message }), - onExit: (code) => logger.warn("sidecar exited", { code }), - }, - ) - server = listener - serverReady.resolve({ - url, - username: "opencode", - password, - }) - - await Promise.race([ - health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]).catch((error) => { - logger.error("sidecar health check failed", error) - }) - - logger.log("loading task finished") - })() - - if (needsMigration) { - const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) - if (show) { - overlay = createLoadingWindow() - await delay(1_000) - } - } - - await loadingTask - setInitStep({ phase: "done" }) - - if (overlay) { - await loadingComplete.promise - } - - mainWindow = createMainWindow() - wireMenu() - - overlay?.close() -} - -function wireMenu() { - if (!mainWindow) return - createMenu({ - trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), - checkForUpdates: () => { - void checkForUpdates(true) - }, - reload: () => mainWindow?.reload(), - relaunch: () => { - void killSidecar().finally(() => { - app.relaunch() - app.exit(0) - }) - }, - }) -} - -registerIpcHandlers({ - killSidecar: () => killSidecar(), - awaitInitialization: async (sendStep) => { - sendStep(initStep) - const listener = (step: InitStep) => sendStep(step) - initEmitter.on("step", listener) - try { - logger.log("awaiting server ready") - const res = await serverReady.promise - logger.log("server ready", { url: res.url }) - return res - } finally { - initEmitter.off("step", listener) - } - }, - getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), - consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), - getDefaultServerUrl: () => getDefaultServerUrl(), - setDefaultServerUrl: (url) => setDefaultServerUrl(url), - getWslConfig: () => Promise.resolve(getWslConfig()), - setWslConfig: (config: WslConfig) => setWslConfig(config), - getDisplayBackend: async () => null, - setDisplayBackend: async () => undefined, - parseMarkdown: async (markdown) => parseMarkdown(markdown), - checkAppExists: (appName) => checkAppExists(appName), - wslPath: async (path, mode) => wslPath(path, mode), - resolveAppPath: async (appName) => resolveAppPath(appName), - loadingWindowComplete: () => loadingComplete.resolve(), - runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), - checkUpdate: async () => checkUpdate(), - installUpdate: async () => installUpdate(), - setBackgroundColor: (color) => setBackgroundColor(color), -}) - async function killSidecar() { if (!server) return const current = server @@ -343,163 +107,265 @@ function ensureLoopbackNoProxy() { upsert("no_proxy") } -async function getSidecarPort() { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed +const main = Effect.gen(function* () { + contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) + + // on macOS apps run in `/` which can cause issues with ripgrep + try { + process.chdir(homedir()) + } catch {} + + process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" + + const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" + const onboardingTestRoot = ((): string | undefined => { + if (!TEST_ONBOARDING) return + + const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + rmSync(root, { recursive: true, force: true }) + ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => + mkdirSync(join(root, dir), { recursive: true }), + ) + process.env.OPENCODE_DB = ":memory:" + process.env.XDG_DATA_HOME = join(root, "data") + process.env.XDG_CONFIG_HOME = join(root, "config") + process.env.XDG_CACHE_HOME = join(root, "cache") + process.env.XDG_STATE_HOME = join(root, "state") + return root + })() + app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") + app.setAppUserModelId(appId) + app.setPath( + "userData", + onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), + ) + if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) + logger = initLogging() + + try { + setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) + } catch (error) { + logger.warn("failed to load system certificates", error) } - return await new Promise((resolve, reject) => { + logger.log("app starting", { + version: app.getVersion(), + packaged: app.isPackaged, + onboardingTest: Boolean(onboardingTestRoot), + }) + + ensureLoopbackNoProxy() + useEnvProxy() + app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") + + if (!app.requestSingleInstanceLock()) { + app.quit() + return + } + + preferAppEnv(app.getPath("userData")) + + app.on("second-instance", (_event: Event, argv: string[]) => { + const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) + if (urls.length) { + logger.log("deep link received via second-instance", { urls }) + emitDeepLinks(urls) + } + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } + }) + + app.on("open-url", (event: Event, url: string) => { + event.preventDefault() + logger.log("deep link received via open-url", { url }) + emitDeepLinks([url]) + }) + + app.on("before-quit", () => { + void killSidecar() + }) + + app.on("will-quit", () => { + void killSidecar() + }) + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + void killSidecar().finally(() => app.exit(0)) + }) + } + + const serverReady = Deferred.makeUnsafe() + const loadingComplete = Deferred.makeUnsafe() + + registerIpcHandlers({ + killSidecar: () => killSidecar(), + awaitInitialization: Effect.fnUntraced( + function* (sendStep) { + sendStep(initStep) + const listener = (step: InitStep) => sendStep(step) + initEmitter.on("step", listener) + try { + logger.log("awaiting server ready") + const res = yield* Deferred.await(serverReady) + logger.log("server ready", { url: res.url }) + return res + } finally { + initEmitter.off("step", listener) + } + }, + (e) => Effect.runPromise(e), + ), + getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), + consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), + getDefaultServerUrl: () => getDefaultServerUrl(), + setDefaultServerUrl: (url) => setDefaultServerUrl(url), + getWslConfig: () => Promise.resolve(getWslConfig()), + setWslConfig: (config: WslConfig) => setWslConfig(config), + getDisplayBackend: async () => null, + setDisplayBackend: async () => undefined, + parseMarkdown: async (markdown) => parseMarkdown(markdown), + checkAppExists: (appName) => checkAppExists(appName), + wslPath: async (path, mode) => wslPath(path, mode), + resolveAppPath: async (appName) => resolveAppPath(appName), + loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void), + runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar), + checkUpdate: async () => checkUpdate(), + installUpdate: async () => installUpdate(killSidecar), + setBackgroundColor: (color) => setBackgroundColor(color), + }) + + yield* Effect.promise(() => app.whenReady()) + + if (!TEST_ONBOARDING) migrate() + app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() + setDockIcon() + setupAutoUpdater() + + const needsMigration = ((): boolean => { + if (process.env.OPENCODE_DB === ":memory:") return false + + const xdg = process.env.XDG_DATA_HOME + const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") + return !existsSync(join(base, "opencode", "opencode.db")) + })() + let overlay: BrowserWindow | null = null + + const port = yield* Effect.gen(function* () { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } + + const res = yield* Deferred.make() const server = createServer() - server.on("error", reject) + server.on("error", (e) => Deferred.failSync(res, () => e)) server.listen(0, "127.0.0.1", () => { const address = server.address() if (typeof address !== "object" || !address) { server.close() - reject(new Error("Failed to get port")) + Deferred.failSync(res, () => new Error("Failed to get port")) return } const port = address.port - server.close(() => resolve(port)) + server.close(() => Effect.runSync(Deferred.succeed(res, port))) }) + + return yield* Deferred.await(res) }) -} + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() -function sqliteFileExists() { - if (process.env.OPENCODE_DB === ":memory:") return true + const loadingTask = yield* Effect.gen(function* () { + logger.log("sidecar connection started", { url }) - const xdg = process.env.XDG_DATA_HOME - const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") - return existsSync(join(base, "opencode", "opencode.db")) -} - -function setupAutoUpdater() { - if (!UPDATER_ENABLED) return - autoUpdater.logger = logger - autoUpdater.channel = "latest" - autoUpdater.allowPrerelease = false - autoUpdater.allowDowngrade = true - autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = false - logger.log("auto updater configured", { - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - currentVersion: app.getVersion(), - }) -} - -let downloadedUpdateVersion: string | undefined - -async function checkUpdate() { - if (!UPDATER_ENABLED) return { updateAvailable: false } - if (downloadedUpdateVersion) { - logger.log("returning cached downloaded update", { - version: downloadedUpdateVersion, + initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { + setInitStep({ phase: "sqlite_waiting" }) + if (overlay) sendSqliteMigrationProgress(overlay, progress) + if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) }) - return { updateAvailable: true, version: downloadedUpdateVersion } - } - logger.log("checking for updates", { - currentVersion: app.getVersion(), - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - }) - try { - const result = await autoUpdater.checkForUpdates() - const updateInfo = result?.updateInfo - logger.log("update metadata fetched", { - releaseVersion: updateInfo?.version ?? null, - releaseDate: updateInfo?.releaseDate ?? null, - releaseName: updateInfo?.releaseName ?? null, - files: updateInfo?.files?.map((file) => file.url) ?? [], + + logger.log("spawning sidecar", { url }) + const { listener, health } = yield* Effect.promise(() => + spawnLocalServer( + hostname, + port, + password, + () => { + ensureLoopbackNoProxy() + useEnvProxy() + }, + { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }, + ), + ) + server = listener + yield* Deferred.succeed(serverReady, { + url, + username: "opencode", + password, }) - const version = result?.updateInfo?.version - if (result?.isUpdateAvailable === false || !version) { - logger.log("no update available", { - reason: "provider returned no newer version", - }) - return { updateAvailable: false } + + yield* Effect.promise(() => health.wait).pipe( + Effect.timeout("30 seconds"), + Effect.catch((e) => + Effect.sync(() => { + logger.error("sidecar health check failed", e.toString()) + }), + ), + ) + + logger.log("loading task finished") + }).pipe(Effect.forkChild) + + if (needsMigration) { + const show = yield* loadingTask.pipe( + Fiber.await, + Effect.timeout("1 second"), + Effect.as(false), + Effect.catch(() => Effect.succeed(true)), + ) + if (show) { + overlay = createLoadingWindow() + yield* Effect.sleep("1 second") } - logger.log("update available", { version }) - await autoUpdater.downloadUpdate() - logger.log("update download completed", { version }) - downloadedUpdateVersion = version - return { updateAvailable: true, version } - } catch (error) { - logger.error("update check failed", error) - return { updateAvailable: false, failed: true } } -} -async function installUpdate() { - if (!downloadedUpdateVersion) { - logger.log("install update skipped", { - reason: "no downloaded update ready", + yield* Fiber.await(loadingTask) + setInitStep({ phase: "done" }) + + if (overlay) yield* Deferred.await(loadingComplete) + + mainWindow = createMainWindow() + if (mainWindow) { + createMenu({ + trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), + checkForUpdates: () => { + void checkForUpdates(true, killSidecar) + }, + reload: () => mainWindow?.reload(), + relaunch: () => { + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) + }, }) - return - } - logger.log("installing downloaded update", { - version: downloadedUpdateVersion, - }) - await killSidecar() - autoUpdater.quitAndInstall(true, true) -} - -async function checkForUpdates(alertOnFail: boolean) { - if (!UPDATER_ENABLED) return - logger.log("checkForUpdates invoked", { alertOnFail }) - const result = await checkUpdate() - if (!result.updateAvailable) { - if (result.failed) { - logger.log("no update decision", { reason: "update check failed" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "error", - message: "Update check failed.", - title: "Update Error", - }) - return - } - - logger.log("no update decision", { reason: "already up to date" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "info", - message: "You're up to date.", - title: "No Updates", - }) - return } - const response = await dialog.showMessageBox({ - type: "info", - message: `Update ${result.version ?? ""} downloaded. Restart now?`, - title: "Update Ready", - buttons: ["Restart", "Later"], - defaultId: 0, - cancelId: 1, - }) - logger.log("update prompt response", { - version: result.version ?? null, - restartNow: response.response === 0, - }) - if (response.response === 0) { - await installUpdate() - } -} + overlay?.close() +}) -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function defer() { - let resolve!: (value: T) => void - let reject!: (error: Error) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} +Effect.runFork(main) diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts new file mode 100644 index 0000000000..a220e00891 --- /dev/null +++ b/packages/desktop/src/main/updater.ts @@ -0,0 +1,126 @@ +import { app, dialog } from "electron" +import pkg from "electron-updater" +import { UPDATER_ENABLED } from "./constants" +import { initLogging } from "./logging" + +const logger = initLogging() +const { autoUpdater } = pkg + +let downloadedUpdateVersion: string | undefined + +export function setupAutoUpdater() { + if (!UPDATER_ENABLED) return + autoUpdater.logger = logger + autoUpdater.channel = "latest" + autoUpdater.allowPrerelease = false + autoUpdater.allowDowngrade = true + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + logger.log("auto updater configured", { + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + currentVersion: app.getVersion(), + }) +} + +export async function checkUpdate() { + if (!UPDATER_ENABLED) return { updateAvailable: false } + if (downloadedUpdateVersion) { + logger.log("returning cached downloaded update", { + version: downloadedUpdateVersion, + }) + return { updateAvailable: true, version: downloadedUpdateVersion } + } + logger.log("checking for updates", { + currentVersion: app.getVersion(), + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + }) + try { + const result = await autoUpdater.checkForUpdates() + const updateInfo = result?.updateInfo + logger.log("update metadata fetched", { + releaseVersion: updateInfo?.version ?? null, + releaseDate: updateInfo?.releaseDate ?? null, + releaseName: updateInfo?.releaseName ?? null, + files: updateInfo?.files?.map((file) => file.url) ?? [], + }) + const version = result?.updateInfo?.version + if (result?.isUpdateAvailable === false || !version) { + logger.log("no update available", { + reason: "provider returned no newer version", + }) + return { updateAvailable: false } + } + logger.log("update available", { version }) + await autoUpdater.downloadUpdate() + logger.log("update download completed", { version }) + downloadedUpdateVersion = version + return { updateAvailable: true, version } + } catch (error) { + logger.error("update check failed", error) + return { updateAvailable: false, failed: true } + } +} + +export async function installUpdate(killSidecar: () => Promise) { + if (!downloadedUpdateVersion) { + logger.log("install update skipped", { + reason: "no downloaded update ready", + }) + return + } + logger.log("installing downloaded update", { + version: downloadedUpdateVersion, + }) + await killSidecar() + autoUpdater.quitAndInstall() +} + +export async function checkForUpdates( + alertOnFail: boolean, + killSidecar: () => Promise, +) { + if (!UPDATER_ENABLED) return + logger.log("checkForUpdates invoked", { alertOnFail }) + const result = await checkUpdate() + if (!result.updateAvailable) { + if (result.failed) { + logger.log("no update decision", { reason: "update check failed" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "error", + message: "Update check failed.", + title: "Update Error", + }) + return + } + + logger.log("no update decision", { reason: "already up to date" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "info", + message: "You're up to date.", + title: "No Updates", + }) + return + } + + const response = await dialog.showMessageBox({ + type: "info", + message: `Update ${result.version ?? ""} downloaded. Restart now?`, + title: "Update Ready", + buttons: ["Restart", "Later"], + defaultId: 0, + cancelId: 1, + }) + logger.log("update prompt response", { + version: result.version ?? null, + restartNow: response.response === 0, + }) + if (response.response === 0) { + await installUpdate(killSidecar) + } +} From 014dbd34c4f5612d9a037b3641a8244b213a8a30 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 06:20:21 +0000 Subject: [PATCH 018/689] chore: generate --- packages/desktop/src/main/updater.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index a220e00891..b7f4bce785 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -79,10 +79,7 @@ export async function installUpdate(killSidecar: () => Promise) { autoUpdater.quitAndInstall() } -export async function checkForUpdates( - alertOnFail: boolean, - killSidecar: () => Promise, -) { +export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise) { if (!UPDATER_ENABLED) return logger.log("checkForUpdates invoked", { alertOnFail }) const result = await checkUpdate() From f8c6742e5483a6e198e13674e526cca35691290e Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 02:03:39 -0400 Subject: [PATCH 019/689] zen: lift default rate limit --- packages/console/app/src/routes/zen/util/keyRateLimiter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts index 0bf495f7db..37fe9f127e 100644 --- a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts @@ -13,7 +13,7 @@ export function createRateLimiter( if (!zenApiKey) return const dict = i18n(localeFromRequest(request)) - const LIMIT = rateLimit ?? 300 + const LIMIT = rateLimit ?? 500 const yyyyMMddHHmm = new Date(Date.now()) .toISOString() .replace(/[^0-9]/g, "") From 6869186fc69983becd55f2a9ec6f9c623037d3fc Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 03:52:56 -0400 Subject: [PATCH 020/689] zen: update tpm rate limit algo --- .../src/routes/zen/util/modelTpmLimiter.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts index 8e3e8cc95e..2ccc47589f 100644 --- a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts +++ b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts @@ -1,4 +1,4 @@ -import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js" +import { and, Database, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js" import { ModelTpmRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { UsageInfo } from "./provider/provider" @@ -6,12 +6,16 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp const ids = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`) if (ids.length === 0) return - const yyyyMMddHHmm = parseInt( - new Date(Date.now()) - .toISOString() - .replace(/[^0-9]/g, "") - .substring(0, 12), - ) + const toInterval = (date: Date) => + parseInt( + date + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 12), + ) + const now = Date.now() + const currInterval = toInterval(new Date(now)) + const prevInterval = toInterval(new Date(now - 60_000)) return { check: async () => { @@ -19,13 +23,18 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp tx .select() .from(ModelTpmRateLimitTable) - .where(and(inArray(ModelTpmRateLimitTable.id, ids), eq(ModelTpmRateLimitTable.interval, yyyyMMddHHmm))), + .where( + and( + inArray(ModelTpmRateLimitTable.id, ids), + inArray(ModelTpmRateLimitTable.interval, [currInterval, prevInterval]), + ), + ), ) // convert to map of model to count return data.reduce( (acc, curr) => { - acc[curr.id] = curr.count + acc[curr.id] = Math.max(acc[curr.id] ?? 0, curr.count) return acc }, {} as Record, @@ -39,7 +48,7 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp await Database.use((tx) => tx .insert(ModelTpmRateLimitTable) - .values({ id, interval: yyyyMMddHHmm, count: usage }) + .values({ id, interval: currInterval, count: usage }) .onDuplicateKeyUpdate({ set: { count: sql`${ModelTpmRateLimitTable.count} + ${usage}` } }), ) }, From ae25278edaed91a9d385977df579160be830142b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 8 May 2026 14:10:18 +0530 Subject: [PATCH 021/689] test(session): update go retry fixture (#26312) --- packages/opencode/test/session/retry.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 9bb5e48652..0b67294796 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -263,6 +263,9 @@ describe("session.retry.retryable", () => { message: "Subscription quota exceeded. You can continue using free models.", isRetryable: true, statusCode: 429, + responseHeaders: { + "retry-after": "19380", + }, responseBody: JSON.stringify({ type: "error", error: { @@ -271,8 +274,7 @@ describe("session.retry.retryable", () => { }, metadata: { workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", - limit: "5 hour", - resetAt: 19_380, + limitName: "5 hour", }, }), }).toObject(), From a43d3e0e1ee9bbc6f5a6ed3a069c4ac3ec6c0d6f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 8 May 2026 14:19:36 +0530 Subject: [PATCH 022/689] feat(websearch): add parallel provider rollout (#26227) --- packages/core/src/flag/flag.ts | 1 + packages/opencode/src/cli/cmd/run.ts | 9 +- .../tui/feature-plugins/system/session-v2.tsx | 12 +- .../src/cli/cmd/tui/routes/session/index.tsx | 7 +- .../cli/cmd/tui/routes/session/permission.tsx | 3 +- .../opencode/src/command/template/review.txt | 2 +- .../src/tool/{mcp-exa.ts => mcp-websearch.ts} | 35 +++++- packages/opencode/src/tool/registry.ts | 9 +- packages/opencode/src/tool/websearch.ts | 103 +++++++++++++++--- packages/opencode/src/tool/websearch.txt | 6 +- packages/opencode/test/tool/websearch.test.ts | 92 ++++++++++++++++ packages/ui/src/components/message-part.tsx | 37 ++++++- .../ui/src/components/tool-error-card.tsx | 4 +- 13 files changed, 276 insertions(+), 44 deletions(-) rename packages/opencode/src/tool/{mcp-exa.ts => mcp-websearch.ts} (63%) create mode 100644 packages/opencode/test/tool/websearch.test.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 0daae55800..f55c14bd05 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -77,6 +77,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), + OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a05b273e44..5c38c2871f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -20,7 +20,7 @@ import { ReadTool } from "../../tool/read" import { WebFetchTool } from "../../tool/webfetch" import { EditTool } from "../../tool/edit" import { WriteTool } from "../../tool/write" -import { WebSearchTool } from "../../tool/websearch" +import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" import { ShellTool } from "../../tool/shell" @@ -148,7 +148,7 @@ function edit(info: ToolProps) { function websearch(info: ToolProps) { inline({ icon: "◈", - title: `Exa Web Search "${info.input.query}"`, + title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`, }) } @@ -469,7 +469,10 @@ export const RunCommand = effectCmd({ } inline({ icon: "✗", - title: `${part.tool} failed`, + title: + part.tool === "websearch" + ? `${webSearchProviderLabel(props(part).metadata.provider)} failed` + : `${part.tool} failed`, }) UI.error(part.state.error) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 0d899a8bae..8fca0de0c8 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -9,6 +9,7 @@ import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/c import { useBindings } from "../../keymap" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { webSearchProviderLabel } from "@/tool/websearch" import path from "path" import stripAnsi from "strip-ansi" import type { @@ -89,6 +90,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { - + )} @@ -400,7 +403,7 @@ function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; sub ) } -function AssistantTool(props: { part: SessionMessageAssistantTool }) { +function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) { const input = createMemo(() => toolInputRecord(props.part.state.input)) const toolprops = { get input() { @@ -412,6 +415,7 @@ function AssistantTool(props: { part: SessionMessageAssistantTool }) { get output() { return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) }, + sessionID: props.sessionID, part: props.part, } return ( @@ -469,6 +473,7 @@ type ToolProps = { input: Record metadata: Record output?: string + sessionID: string part: SessionMessageAssistantTool } @@ -775,9 +780,10 @@ function CodeSearch(props: ToolProps) { } function WebSearch(props: ToolProps) { + const label = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( - Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} {(results) => <>({results()} results)} ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d2b50c32f8..af70f83711 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -45,7 +45,7 @@ import type { GrepTool } from "@/tool/grep" import type { EditTool } from "@/tool/edit" import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" -import type { WebSearchTool } from "@/tool/websearch" +import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" @@ -1933,10 +1933,11 @@ function WebFetch(props: ToolProps) { } function WebSearch(props: ToolProps) { - const metadata = props.metadata as { numResults?: number } + const metadata = props.metadata as { numResults?: number; provider?: unknown } return ( - Exa Web Search "{props.input.query}" ({metadata.numResults} results) + {webSearchProviderLabel(metadata.provider)} "{props.input.query}"{" "} + ({metadata.numResults} results) ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 5e7e80b66a..fd4c96d124 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -13,6 +13,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" +import { webSearchProviderLabel } from "@/tool/websearch" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -338,7 +339,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const query = typeof data.query === "string" ? data.query : "" return { icon: "◈", - title: `Exa Web Search "${query}"`, + title: `${webSearchProviderLabel(data.provider)} "${query}"`, body: ( diff --git a/packages/opencode/src/command/template/review.txt b/packages/opencode/src/command/template/review.txt index b745247e7f..43c6738577 100644 --- a/packages/opencode/src/command/template/review.txt +++ b/packages/opencode/src/command/template/review.txt @@ -85,7 +85,7 @@ Use these to inform your review: - **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit. - **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong. -- **Exa Web Search** - Research best practices if you're unsure about a pattern. +- **Web Search** - Research best practices if you're unsure about a pattern. If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue. diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-websearch.ts similarity index 63% rename from packages/opencode/src/tool/mcp-exa.ts rename to packages/opencode/src/tool/mcp-websearch.ts index af9a3390e3..208924cba5 100644 --- a/packages/opencode/src/tool/mcp-exa.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -1,9 +1,10 @@ import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -const URL = process.env.EXA_API_KEY +export const EXA_URL = process.env.EXA_API_KEY ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}` : "https://mcp.exa.ai/mcp" +export const PARALLEL_URL = "https://search.parallel.ai/mcp" const McpResult = Schema.Struct({ result: Schema.Struct({ @@ -18,11 +19,23 @@ const McpResult = Schema.Struct({ const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult)) -const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) { +const parsePayload = (payload: string) => + Effect.gen(function* () { + const trimmed = payload.trim() + if (!trimmed.startsWith("{")) return undefined + const data = yield* decode(trimmed) + return data.result.content.find((item) => item.text)?.text + }) + +export const parseResponse = Effect.fn("McpWebSearch.parseResponse")(function* (body: string) { + const trimmed = body.trim() + const direct = trimmed ? yield* parsePayload(trimmed) : undefined + if (direct) return direct + for (const line of body.split("\n")) { if (!line.startsWith("data: ")) continue - const data = yield* decode(line.substring(6)) - if (data.result.content[0]?.text) return data.result.content[0].text + const data = yield* parsePayload(line.substring(6)) + if (data) return data } return undefined }) @@ -35,6 +48,13 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) +export const ParallelSearchArgs = Schema.Struct({ + objective: Schema.String, + search_queries: Schema.Array(Schema.String), + session_id: Schema.optional(Schema.String), + model_name: Schema.optional(Schema.String), +}) + const McpRequest = (args: Schema.Struct) => Schema.Struct({ jsonrpc: Schema.Literal("2.0"), @@ -48,14 +68,17 @@ const McpRequest = (args: Schema.Struct) => export const call = ( http: HttpClient.HttpClient, + url: string, tool: string, args: Schema.Struct, value: Schema.Struct.Type, timeout: Duration.Input, + headers?: Record, ) => Effect.gen(function* () { - const request = yield* HttpClientRequest.post(URL).pipe( + const request = yield* HttpClientRequest.post(url).pipe( HttpClientRequest.accept("application/json, text/event-stream"), + HttpClientRequest.setHeaders(headers ?? {}), HttpClientRequest.schemaBodyJson(McpRequest(args))({ jsonrpc: "2.0" as const, id: 1 as const, @@ -69,5 +92,5 @@ export const call = ( Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }), ) const body = yield* response.text - return yield* parseSse(body) + return yield* parseResponse(body) }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a4eb31acc7..b288bf7ae5 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -49,6 +49,13 @@ import { Permission } from "@/permission" const log = Log.create({ service: "tool.registry" }) +export function webSearchEnabled( + providerID: ProviderID, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +) { + return providerID === ProviderID.opencode || flags.exa || flags.parallel +} + type TaskDef = Tool.InferDef type ReadDef = Tool.InferDef @@ -284,7 +291,7 @@ export const layer: Layer.Layer< const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { - return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + return webSearchEnabled(input.providerID) } const usePatch = diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index ff4c696a25..0218ecbe3b 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,8 +1,11 @@ import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" -import * as McpExa from "./mcp-exa" +import * as McpWebSearch from "./mcp-websearch" import DESCRIPTION from "./websearch.txt" +import { Flag } from "@opencode-ai/core/flag/flag" +import { checksum } from "@opencode-ai/core/util/encode" +import { InstallationVersion } from "@opencode-ai/core/installation/version" export const Parameters = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), @@ -21,6 +24,81 @@ export const Parameters = Schema.Struct({ }), }) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +export type WebSearchProvider = Schema.Schema.Type + +export function selectWebSearchProvider( + sessionID: string, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +): WebSearchProvider { + const override = process.env.OPENCODE_WEBSEARCH_PROVIDER + if (override === "exa" || override === "parallel") return override + if (flags.parallel) return "parallel" + if (flags.exa) return "exa" + + return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel" +} + +export function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function webSearchModelName(extra: Tool.Context["extra"]) { + const model = extra?.model + if (!model || typeof model !== "object") return undefined + const api = "api" in model && model.api && typeof model.api === "object" ? model.api : undefined + const apiID = api && "id" in api && typeof api.id === "string" ? api.id : undefined + const id = "id" in model && typeof model.id === "string" ? model.id : undefined + return (apiID ?? id)?.slice(0, 100) +} + +function parallelAuthHeaders() { + const headers = { "User-Agent": `opencode/${InstallationVersion}` } + if (!process.env.PARALLEL_API_KEY) return headers + return { ...headers, Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` } +} + +function callProvider( + http: HttpClient.HttpClient, + provider: WebSearchProvider, + params: Schema.Schema.Type, + ctx: Tool.Context, +) { + if (provider === "parallel") { + return McpWebSearch.call( + http, + McpWebSearch.PARALLEL_URL, + "web_search", + McpWebSearch.ParallelSearchArgs, + { + objective: params.query, + search_queries: [params.query], + session_id: ctx.sessionID, + model_name: webSearchModelName(ctx.extra), + }, + "25 seconds", + parallelAuthHeaders(), + ) + } + + return McpWebSearch.call( + http, + McpWebSearch.EXA_URL, + "web_search_exa", + McpWebSearch.SearchArgs, + { + query: params.query, + type: params.type || "auto", + numResults: params.numResults || 8, + livecrawl: params.livecrawl || "fallback", + contextMaxCharacters: params.contextMaxCharacters, + }, + "25 seconds", + ) +} + export const WebSearchTool = Tool.define( "websearch", Effect.gen(function* () { @@ -33,6 +111,10 @@ export const WebSearchTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const provider = selectWebSearchProvider(ctx.sessionID) + const title = webSearchProviderLabel(provider) + yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } }) + yield* ctx.ask({ permission: "websearch", patterns: [params.query], @@ -43,27 +125,16 @@ export const WebSearchTool = Tool.define( livecrawl: params.livecrawl, type: params.type, contextMaxCharacters: params.contextMaxCharacters, + provider, }, }) - const result = yield* McpExa.call( - http, - "web_search_exa", - McpExa.SearchArgs, - { - query: params.query, - type: params.type || "auto", - numResults: params.numResults || 8, - livecrawl: params.livecrawl || "fallback", - contextMaxCharacters: params.contextMaxCharacters, - }, - "25 seconds", - ) + const result = yield* callProvider(http, provider, params, ctx) return { output: result ?? "No search results found. Please try a different query.", - title: `Web search: ${params.query}`, - metadata: {}, + title: `${title}: ${params.query}`, + metadata: { provider }, } }).pipe(Effect.orDie), } diff --git a/packages/opencode/src/tool/websearch.txt b/packages/opencode/src/tool/websearch.txt index 551c0f3b59..ad5238cbd5 100644 --- a/packages/opencode/src/tool/websearch.txt +++ b/packages/opencode/src/tool/websearch.txt @@ -1,12 +1,12 @@ -- Search the web using Exa AI - performs real-time web searches and can scrape content from specific URLs +- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs - Provides up-to-date information for current events and recent data - Supports configurable result counts and returns the content from the most relevant websites - Use this tool for accessing information beyond knowledge cutoff - Searches are performed automatically within a single API call Usage notes: - - Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) - - Search types: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) + - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) + - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) - Configurable context length for optimal LLM integration - Domain filtering and advanced search options available diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts new file mode 100644 index 0000000000..477fe2b428 --- /dev/null +++ b/packages/opencode/test/tool/websearch.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { parseResponse } from "../../src/tool/mcp-websearch" +import { + selectWebSearchProvider, + webSearchModelName, + webSearchProviderLabel, +} from "../../src/tool/websearch" +import { ProviderID } from "../../src/provider/schema" +import { webSearchEnabled } from "../../src/tool/registry" + +const SESSION_ID = "ses_0196aabbccddeeff001122334455" + +describe("websearch provider", () => { + test("selects a stable provider per session", () => { + expect(selectWebSearchProvider(SESSION_ID)).toBe(selectWebSearchProvider(SESSION_ID)) + }) + + test("supports an operational override", () => { + const original = process.env.OPENCODE_WEBSEARCH_PROVIDER + + try { + process.env.OPENCODE_WEBSEARCH_PROVIDER = "parallel" + expect(selectWebSearchProvider(SESSION_ID)).toBe("parallel") + + process.env.OPENCODE_WEBSEARCH_PROVIDER = "exa" + expect(selectWebSearchProvider(SESSION_ID)).toBe("exa") + } finally { + if (original === undefined) delete process.env.OPENCODE_WEBSEARCH_PROVIDER + else process.env.OPENCODE_WEBSEARCH_PROVIDER = original + } + }) + + test("routes to Exa when the Exa flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: true, parallel: false })).toBe("exa") + }) + + test("routes to Parallel when the Parallel flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: false, parallel: true })).toBe("parallel") + }) + + test("is only enabled for opencode or explicit websearch provider flags", () => { + expect(webSearchEnabled(ProviderID.opencode, { exa: false, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: false })).toBe(false) + expect(webSearchEnabled(ProviderID.openai, { exa: true, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: true })).toBe(true) + }) + + test("uses branded labels", () => { + expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search") + expect(webSearchProviderLabel("exa")).toBe("Exa Web Search") + expect(webSearchProviderLabel(undefined)).toBe("Web Search") + }) + + test("uses the provider API model id for Parallel analytics", () => { + expect( + webSearchModelName({ + model: { + id: "claude-opus-4-7", + api: { id: "claude-opus-4.7" }, + }, + }), + ).toBe("claude-opus-4.7") + }) +}) + +describe("websearch MCP response parser", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + content: [ + { + type: "text", + text: "search results", + }, + ], + }, + }) + + test("parses plain JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results") + }) + + test("parses SSE JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) + + test("ignores non-JSON SSE data frames", async () => { + await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) +}) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c36a52f81e..92b6e95acc 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -317,7 +317,17 @@ function taskAgent( } } -export function getToolInfo(tool: string, input: any = {}): ToolInfo { +function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function getToolInfo( + tool: string, + input: any = {}, + metadata: Record | undefined = {}, +): ToolInfo { const i18n = useI18n() switch (tool) { case "read": @@ -353,7 +363,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "websearch": return { icon: "window-cursor", - title: i18n.t("ui.tool.websearch"), + title: webSearchProviderLabel(metadata?.provider), subtitle: input.query, } case "task": { @@ -692,7 +702,11 @@ function isContextGroupTool(part: PartType): part is ToolPart { } function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}) + const info = getToolInfo( + part.tool, + part.state.input ?? {}, + "metadata" in part.state ? part.state.metadata : undefined, + ) if (info.subtitle) return info.subtitle if (part.state.status === "error") return part.state.error if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) @@ -744,7 +758,11 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } default: { - const info = getToolInfo(part.tool, input) + const info = getToolInfo( + part.tool, + input, + "metadata" in part.state ? part.state.metadata : undefined, + ) return { title: info.title, subtitle: info.subtitle || contextToolDetail(part), @@ -1224,6 +1242,7 @@ export interface ToolProps { input: Record metadata: Record tool: string + sessionID?: string output?: string status?: string hideDetails?: boolean @@ -1346,6 +1365,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { { const value = props.input.query if (typeof value !== "string") return "" return value }) + const title = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( , "children" | "variant"> { tool: string error: string + title?: string defaultOpen?: boolean subtitle?: string href?: string @@ -23,8 +24,9 @@ export function ToolErrorCard(props: ToolErrorCardProps) { }) const open = () => state.open const copied = () => state.copied - const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen", "subtitle", "href"]) + const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"]) const name = createMemo(() => { + if (split.title) return split.title const map: Record = { read: "ui.tool.read", list: "ui.tool.list", From edbc02855d2d359e93cc6241c2114a2e464f29ee Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 08:50:42 +0000 Subject: [PATCH 023/689] chore: generate --- packages/opencode/test/tool/websearch.test.ts | 10 ++++------ packages/ui/src/components/message-part.tsx | 12 ++---------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts index 477fe2b428..591b385fdc 100644 --- a/packages/opencode/test/tool/websearch.test.ts +++ b/packages/opencode/test/tool/websearch.test.ts @@ -1,11 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { parseResponse } from "../../src/tool/mcp-websearch" -import { - selectWebSearchProvider, - webSearchModelName, - webSearchProviderLabel, -} from "../../src/tool/websearch" +import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch" import { ProviderID } from "../../src/provider/schema" import { webSearchEnabled } from "../../src/tool/registry" @@ -83,7 +79,9 @@ describe("websearch MCP response parser", () => { }) test("parses SSE JSON-RPC responses", async () => { - await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe("search results") + await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe( + "search results", + ) }) test("ignores non-JSON SSE data frames", async () => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 92b6e95acc..d9771671a6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -758,11 +758,7 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } default: { - const info = getToolInfo( - part.tool, - input, - "metadata" in part.state ? part.state.metadata : undefined, - ) + const info = getToolInfo(part.tool, input, "metadata" in part.state ? part.state.metadata : undefined) return { title: info.title, subtitle: info.subtitle || contextToolDetail(part), @@ -1365,11 +1361,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { Date: Fri, 8 May 2026 12:06:30 +0200 Subject: [PATCH 024/689] chore: reduce alerts threshold --- infra/monitoring.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 313e6c1dd4..1b5d097c21 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -45,7 +45,7 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, ], - formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 500), DIV($FAILED, $TOTAL), 0)" }], + formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, }).json } @@ -86,9 +86,9 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }, ], formulas: [ - { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 250), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, + { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, ], - timeRange: 1800, + timeRange: 900, }).json } @@ -100,7 +100,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { queryJson: modelHttpErrorsQuery("go"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -119,7 +119,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { queryJson: modelHttpErrorsQuery("zen"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -138,7 +138,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { queryJson: providerHttpErrorsQuery("go"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -157,7 +157,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { queryJson: providerHttpErrorsQuery("zen"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -184,7 +184,7 @@ new honeycomb.Trigger("IncreasedFreeTierRequests", { }).json, alertType: "on_change", frequency: 900, - thresholds: [{ op: ">=", value: 60, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 50, exceededLimit: 1 }], baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }], recipients: [ { From 7f2b5ee8c29bfb16aeace26402b688d2ece8af25 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 8 May 2026 12:17:14 +0200 Subject: [PATCH 025/689] feat(opencode): add interactive split-footer mode to run (#23557) --- bun.lock | 30 +- package.json | 6 +- packages/opencode/src/cli/cmd/run.ts | 790 +++++---- packages/opencode/src/cli/cmd/run/demo.ts | 1281 +++++++++++++++ .../opencode/src/cli/cmd/run/entry.body.ts | 194 +++ .../src/cli/cmd/run/footer.command.tsx | 647 ++++++++ .../opencode/src/cli/cmd/run/footer.menu.tsx | 290 ++++ .../src/cli/cmd/run/footer.permission.tsx | 478 ++++++ .../src/cli/cmd/run/footer.prompt.tsx | 1108 +++++++++++++ .../src/cli/cmd/run/footer.question.tsx | 582 +++++++ .../src/cli/cmd/run/footer.subagent.tsx | 192 +++ packages/opencode/src/cli/cmd/run/footer.ts | 893 ++++++++++ .../opencode/src/cli/cmd/run/footer.view.tsx | 719 ++++++++ .../opencode/src/cli/cmd/run/keymap.shared.ts | 154 ++ packages/opencode/src/cli/cmd/run/otel.ts | 119 ++ .../src/cli/cmd/run/permission.shared.ts | 256 +++ .../opencode/src/cli/cmd/run/prompt.shared.ts | 328 ++++ .../src/cli/cmd/run/question.shared.ts | 340 ++++ .../opencode/src/cli/cmd/run/runtime.boot.ts | 214 +++ .../src/cli/cmd/run/runtime.lifecycle.ts | 308 ++++ .../opencode/src/cli/cmd/run/runtime.queue.ts | 293 ++++ .../src/cli/cmd/run/runtime.shared.ts | 17 + .../opencode/src/cli/cmd/run/runtime.stdin.ts | 37 + packages/opencode/src/cli/cmd/run/runtime.ts | 793 +++++++++ .../src/cli/cmd/run/scrollback.shared.ts | 92 ++ .../src/cli/cmd/run/scrollback.surface.ts | 391 +++++ .../src/cli/cmd/run/scrollback.writer.tsx | 330 ++++ .../opencode/src/cli/cmd/run/session-data.ts | 970 +++++++++++ .../src/cli/cmd/run/session.shared.ts | 196 +++ packages/opencode/src/cli/cmd/run/splash.ts | 302 ++++ .../src/cli/cmd/run/stream.transport.ts | 1008 ++++++++++++ packages/opencode/src/cli/cmd/run/stream.ts | 175 ++ .../opencode/src/cli/cmd/run/subagent-data.ts | 746 +++++++++ packages/opencode/src/cli/cmd/run/theme.ts | 599 +++++++ packages/opencode/src/cli/cmd/run/tool.ts | 1460 +++++++++++++++++ packages/opencode/src/cli/cmd/run/trace.ts | 94 ++ packages/opencode/src/cli/cmd/run/types.ts | 317 ++++ .../src/cli/cmd/run/variant.shared.ts | 213 +++ packages/opencode/src/cli/cmd/tui/attach.ts | 2 +- .../src/cli/cmd/tui/component/spinner.tsx | 4 +- .../src/cli/cmd/tui/context/theme.tsx | 6 +- packages/opencode/src/cli/cmd/tui/thread.ts | 2 +- .../opencode/test/cli/run/entry.body.test.ts | 483 ++++++ .../opencode/test/cli/run/footer.menu.test.ts | 43 + .../test/cli/run/footer.view.test.tsx | 273 +++ .../test/cli/run/permission.shared.test.ts | 144 ++ .../test/cli/run/prompt.shared.test.ts | 132 ++ .../test/cli/run/question.shared.test.ts | 115 ++ .../test/cli/run/runtime.boot.test.ts | 303 ++++ .../test/cli/run/runtime.queue.test.ts | 318 ++++ .../test/cli/run/runtime.stdin.test.ts | 71 + .../test/cli/run/scrollback.surface.test.ts | 883 ++++++++++ .../test/cli/run/session-data.test.ts | 422 +++++ .../test/cli/run/session.shared.test.ts | 247 +++ packages/opencode/test/cli/run/stream.test.ts | 55 + .../test/cli/run/stream.transport.test.ts | 1062 ++++++++++++ .../test/cli/run/subagent-data.test.ts | 328 ++++ packages/opencode/test/cli/run/theme.test.ts | 122 ++ .../test/cli/run/variant.shared.test.ts | 214 +++ packages/plugin/package.json | 6 +- 60 files changed, 21850 insertions(+), 347 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/demo.ts create mode 100644 packages/opencode/src/cli/cmd/run/entry.body.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.command.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.menu.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.permission.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.prompt.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.question.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.subagent.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.view.tsx create mode 100644 packages/opencode/src/cli/cmd/run/keymap.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/otel.ts create mode 100644 packages/opencode/src/cli/cmd/run/permission.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/prompt.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/question.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.boot.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.queue.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.stdin.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.surface.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.writer.tsx create mode 100644 packages/opencode/src/cli/cmd/run/session-data.ts create mode 100644 packages/opencode/src/cli/cmd/run/session.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/splash.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.transport.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.ts create mode 100644 packages/opencode/src/cli/cmd/run/subagent-data.ts create mode 100644 packages/opencode/src/cli/cmd/run/theme.ts create mode 100644 packages/opencode/src/cli/cmd/run/tool.ts create mode 100644 packages/opencode/src/cli/cmd/run/trace.ts create mode 100644 packages/opencode/src/cli/cmd/run/types.ts create mode 100644 packages/opencode/src/cli/cmd/run/variant.shared.ts create mode 100644 packages/opencode/test/cli/run/entry.body.test.ts create mode 100644 packages/opencode/test/cli/run/footer.menu.test.ts create mode 100644 packages/opencode/test/cli/run/footer.view.test.tsx create mode 100644 packages/opencode/test/cli/run/permission.shared.test.ts create mode 100644 packages/opencode/test/cli/run/prompt.shared.test.ts create mode 100644 packages/opencode/test/cli/run/question.shared.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.boot.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.queue.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.stdin.test.ts create mode 100644 packages/opencode/test/cli/run/scrollback.surface.test.ts create mode 100644 packages/opencode/test/cli/run/session-data.test.ts create mode 100644 packages/opencode/test/cli/run/session.shared.test.ts create mode 100644 packages/opencode/test/cli/run/stream.test.ts create mode 100644 packages/opencode/test/cli/run/stream.transport.test.ts create mode 100644 packages/opencode/test/cli/run/subagent-data.test.ts create mode 100644 packages/opencode/test/cli/run/theme.test.ts create mode 100644 packages/opencode/test/cli/run/variant.shared.test.ts diff --git a/bun.lock b/bun.lock index 2f21ed7d54..3e73e0c236 100644 --- a/bun.lock +++ b/bun.lock @@ -486,9 +486,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.4", - "@opentui/keymap": ">=0.2.4", - "@opentui/solid": ">=0.2.4", + "@opentui/core": ">=0.2.5", + "@opentui/keymap": ">=0.2.5", + "@opentui/solid": ">=0.2.5", }, "optionalPeers": [ "@opentui/core", @@ -667,9 +667,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.4", - "@opentui/keymap": "0.2.4", - "@opentui/solid": "0.2.4", + "@opentui/core": "0.2.5", + "@opentui/keymap": "0.2.5", + "@opentui/solid": "0.2.5", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1594,23 +1594,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="], + "@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="], - "@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="], + "@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="], - "@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="], + "@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index 15d96e131c..f2258ab698 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.4", - "@opentui/keymap": "0.2.4", - "@opentui/solid": "0.2.4", + "@opentui/core": "0.2.5", + "@opentui/keymap": "0.2.5", + "@opentui/solid": "0.2.5", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 5c38c2871f..bca89c3cab 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,3 +1,16 @@ +// CLI entry point for `opencode run`. +// +// Handles three modes: +// 1. Non-interactive (default): sends a single prompt, streams events to +// stdout, and exits when the session goes idle. +// 2. Interactive local (`--interactive`): boots the split-footer direct mode +// with an in-process server (no external HTTP). +// 3. Interactive attach (`--interactive --attach`): connects to a running +// opencode server and runs interactive mode against it. +// +// Also supports `--command` for slash-command execution, `--format json` for +// raw event streaming, `--continue` / `--session` for session resumption, +// and `--fork` for forking before continuing. import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" @@ -9,38 +22,39 @@ import { ServerAuth } from "@/server/auth" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" -import { Server } from "../../server/server" -import { Provider } from "@/provider/provider" -import { Agent } from "../../agent/agent" -import { Permission } from "../../permission" -import { Tool } from "@/tool/tool" -import { GlobTool } from "../../tool/glob" -import { GrepTool } from "../../tool/grep" -import { ReadTool } from "../../tool/read" -import { WebFetchTool } from "../../tool/webfetch" -import { EditTool } from "../../tool/edit" -import { WriteTool } from "../../tool/write" -import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch" -import { TaskTool } from "../../tool/task" -import { SkillTool } from "../../tool/skill" -import { ShellTool } from "../../tool/shell" -import { ShellID } from "../../tool/shell/id" -import { TodoWriteTool } from "../../tool/todo" -import { Locale } from "@/util/locale" +import { Agent } from "@/agent/agent" +import { Permission } from "@/permission" +import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" -type ToolProps = { - input: Tool.InferParameters - metadata: Tool.InferMetadata - part: ToolPart +const runtimeTask = import("./run/runtime") +type ModelInput = Parameters[0]["model"] + +function pick(value: string | undefined): ModelInput | undefined { + if (!value) return undefined + const [providerID, ...rest] = value.split("/") + return { + providerID, + modelID: rest.join("/"), + } as ModelInput } -function props(part: ToolPart): ToolProps { - const state = part.state - return { - input: state.input as Tool.InferParameters, - metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata, - part, +function resolveRunInput(value?: string, piped?: string): string | undefined { + if (!value) { + return piped } + + if (!piped) { + return value + } + + return value + "\n" + piped +} + +type FilePart = { + type: "file" + url: string + filename: string + mime: string } type Inline = { @@ -49,6 +63,12 @@ type Inline = { description?: string } +type SessionInfo = { + id: string + title?: string + directory?: string +} + function inline(info: Inline) { const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) @@ -62,145 +82,40 @@ function block(info: Inline, output?: string) { UI.empty() } -function fallback(part: ToolPart) { - const state = part.state - const input = "input" in state ? state.input : undefined - const title = - ("title" in state && state.title ? state.title : undefined) || - (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown") - inline({ - icon: "⚙", - title: `${part.tool} ${title}`, - }) +async function tool(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + if (next.mode === "block") { + block(next, next.body) + return + } + + inline(next) + } catch { + inline({ + icon: "\u2699", + title: part.tool, + }) + } } -function glob(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Glob "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.count - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function grep(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Grep "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.matches - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function read(info: ToolProps) { - const file = normalizePath(info.input.filePath) - const pairs = Object.entries(info.input).filter(([key, value]) => { - if (key === "filePath") return false - return typeof value === "string" || typeof value === "number" || typeof value === "boolean" - }) - const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined - inline({ - icon: "→", - title: `Read ${file}`, - ...(description && { description }), - }) -} - -function write(info: ToolProps) { - block( - { - icon: "←", - title: `Write ${normalizePath(info.input.filePath)}`, - }, - info.part.state.status === "completed" ? info.part.state.output : undefined, - ) -} - -function webfetch(info: ToolProps) { - inline({ - icon: "%", - title: `WebFetch ${info.input.url}`, - }) -} - -function edit(info: ToolProps) { - const title = normalizePath(info.input.filePath) - const diff = info.metadata.diff - block( - { - icon: "←", - title: `Edit ${title}`, - }, - diff, - ) -} - -function websearch(info: ToolProps) { - inline({ - icon: "◈", - title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`, - }) -} - -function task(info: ToolProps) { - const input = info.part.state.input - const status = info.part.state.status - const subagent = - typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown" - const agent = Locale.titlecase(subagent) - const desc = - typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined - const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓" - const name = desc ?? `${agent} Task` - inline({ - icon, - title: name, - description: desc ? `${agent} Agent` : undefined, - }) -} - -function skill(info: ToolProps) { - inline({ - icon: "→", - title: `Skill "${info.input.name}"`, - }) -} - -function shell(info: ToolProps) { - const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined - block( - { - icon: "$", - title: `${info.input.command}`, - }, - output, - ) -} - -function todo(info: ToolProps) { - block( - { - icon: "#", - title: "Todos", - }, - info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"), - ) -} - -function normalizePath(input?: string) { - if (!input) return "" - if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." - return input +async function toolError(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + inline({ + icon: "✗", + title: `${next.title} failed`, + ...(next.description && { description: next.description }), + }) + return + } catch { + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + } } export const RunCommand = effectCmd({ @@ -296,38 +211,98 @@ export const RunCommand = effectCmd({ .option("thinking", { type: "boolean", describe: "show thinking blocks", + }) + .option("interactive", { + alias: ["i"], + type: "boolean", + describe: "run in direct interactive split-footer mode", default: false, }) .option("dangerously-skip-permissions", { type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, + }) + .option("demo", { + type: "boolean", + default: false, + describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { const agentSvc = yield* Agent.Service yield* Effect.promise(async () => { + const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") + const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) + const die = (message: string): never => { + UI.error(message) + process.exit(1) + } + const dieInteractive = (error: unknown): never => { + if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) { + die(error.message) + } + + throw error + } + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") - const directory = (() => { - if (!args.dir) return undefined - if (args.attach) return args.dir + if (args.interactive && args.command) { + die("--interactive cannot be used with --command") + } + + if (args.demo && !args.interactive) { + die("--demo requires --interactive") + } + + if (args.interactive && args.format === "json") { + die("--interactive cannot be used with --format json") + } + + if (args.interactive && !process.stdout.isTTY) { + die("--interactive requires a TTY stdout") + } + + if (args.interactive) { try { - process.chdir(args.dir) + resolveInteractiveStdin().cleanup?.() + } catch (error) { + dieInteractive(error) + } + } + + const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) + const directory = (() => { + if (!args.dir) return args.attach ? undefined : root + if (args.attach) return args.dir + + try { + process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir)) return process.cwd() } catch { UI.error("Failed to change directory to " + args.dir) process.exit(1) } })() + const attachHeaders = args.attach + ? ServerAuth.headers({ password: args.password, username: args.username }) + : undefined + const attachSDK = (dir?: string) => { + return createOpencodeClient({ + baseUrl: args.attach!, + directory: dir, + headers: attachHeaders, + }) + } - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + const files: FilePart[] = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) + const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath) if (!(await Filesystem.exists(resolvedPath))) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -344,9 +319,11 @@ export const RunCommand = effectCmd({ } } - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text() + message = resolveRunInput(message, piped) ?? "" + const initialInput = resolveRunInput(rawMessage, piped) - if (message.trim().length === 0 && !args.command) { + if (message.trim().length === 0 && !args.command && !args.interactive) { UI.error("You must provide a message or a command") process.exit(1) } @@ -356,23 +333,25 @@ export const RunCommand = effectCmd({ process.exit(1) } - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] + const rules: Permission.Ruleset = args.interactive + ? [] + : [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] function title() { if (args.title === undefined) return @@ -380,19 +359,83 @@ export const RunCommand = effectCmd({ return message.slice(0, 50) + (message.length > 50 ? "..." : "") } - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + async function session(sdk: OpencodeClient): Promise { + if (args.session) { + const current = await sdk.session + .get({ + sessionID: args.session, + }) + .catch(() => undefined) - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id + if (!current?.data) { + UI.error("Session not found") + process.exit(1) + } + + if (args.fork) { + const forked = await sdk.session.fork({ + sessionID: args.session, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? current.data.title, + directory: forked.data?.directory ?? current.data.directory, + } + } + + return { + id: current.data.id, + title: current.data.title, + directory: current.data.directory, + } } - if (baseID) return baseID + const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined + + if (base && args.fork) { + const forked = await sdk.session.fork({ + sessionID: base.id, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? base.title, + directory: forked.data?.directory ?? base.directory, + } + } + + if (base) { + return { + id: base.id, + title: base.title, + directory: base.directory, + } + } const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id + const result = await sdk.session.create({ + title: name, + permission: rules, + }) + const id = result.data?.id + if (!id) { + return + } + + return { + id, + title: result.data?.title ?? name, + directory: result.data?.directory, + } } async function share(sdk: OpencodeClient, sessionID: string) { @@ -410,43 +453,159 @@ export const RunCommand = effectCmd({ } } - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === ShellID.ToolID) return shell(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } + async function createFreshSession( + sdk: OpencodeClient, + input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined }, + ): Promise { + const result = await sdk.session.create({ + title: args.title !== undefined && args.title !== "" ? args.title : undefined, + agent: input.agent, + model: input.model + ? { + providerID: input.model.providerID, + id: input.model.modelID, + variant: input.variant, + } + : undefined, + permission: rules, + }) + const id = result.data?.id + if (!id) { + throw new Error("Failed to create session") } + void share(sdk, id).catch(() => {}) + return { + id, + title: result.data?.title, + } + } + + async function current(sdk: OpencodeClient): Promise { + if (!args.attach) { + return directory ?? root + } + + const next = await sdk.path + .get() + .then((x) => x.data?.directory) + .catch(() => undefined) + if (next) { + return next + } + + UI.error("Failed to resolve remote directory") + process.exit(1) + } + + async function localAgent() { + if (!args.agent) return undefined + const name = args.agent + + const entry = await Effect.runPromise(agentSvc.get(name)) + if (!entry) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + if (entry.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + return name + } + + async function attachAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + const name = args.agent + + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) + + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name + } + + async function pickAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + if (args.attach) { + return attachAgent(sdk) + } + + return localAgent() + } + + async function execute(sdk: OpencodeClient) { + const sess = await session(sdk) + if (!sess?.id) { + UI.error("Session not found") + process.exit(1) + } + const sessionID = sess.id + function emit(type: string, data: Record) { if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + process.stdout.write( + JSON.stringify({ + type, + timestamp: Date.now(), + sessionID, + ...data, + }) + EOL, + ) return true } return false } - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { + // Consume one subscribed event stream for the active session and mirror it + // to stdout/UI. `client` is passed explicitly because attach mode may + // rebind the SDK to the session's directory after the subscription is + // created, and replies issued from inside the loop must use that client. + async function loop(client: OpencodeClient, events: Awaited>) { const toggles = new Map() + let error: string | undefined for await (const event of events.stream) { if ( event.type === "message.updated" && + event.properties.sessionID === sessionID && event.properties.info.role === "assistant" && args.format !== "json" && toggles.get("start") !== true @@ -464,16 +623,10 @@ export const RunCommand = effectCmd({ if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { if (emit("tool_use", { part })) continue if (part.state.status === "completed") { - tool(part) + await tool(part) continue } - inline({ - icon: "✗", - title: - part.tool === "websearch" - ? `${webSearchProviderLabel(props(part).metadata.provider)} failed` - : `${part.tool} failed`, - }) + await toolError(part) UI.error(part.state.error) } @@ -484,7 +637,7 @@ export const RunCommand = effectCmd({ args.format !== "json" ) { if (toggles.get(part.id) === true) continue - task(props(part)) + await tool(part) toggles.set(part.id, true) } @@ -509,7 +662,7 @@ export const RunCommand = effectCmd({ UI.empty() } - if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (part.type === "reasoning" && part.time?.end && thinking) { if (emit("reasoning", { part })) continue const text = part.text.trim() if (!text) continue @@ -549,7 +702,7 @@ export const RunCommand = effectCmd({ if (permission.sessionID !== sessionID) continue if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "once", }) @@ -559,7 +712,7 @@ export const RunCommand = effectCmd({ UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "reject", }) @@ -567,114 +720,113 @@ export const RunCommand = effectCmd({ } } } + const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) + const client = args.attach ? attachSDK(cwd) : sdk // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + const agent = await pickAgent(client) - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + await share(client, sessionID) - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined - } - - const agent = modes.find((a) => a.name === name) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - - if (agent.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - - return name - } - - const entry = await Effect.runPromise(agentSvc.get(name)) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, + if (!args.interactive) { + const events = await client.event.subscribe() + loop(client, events).catch((e) => { + console.error(e) + process.exit(1) }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ + + if (args.command) { + await client.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + return + } + + const model = pick(args.model) + await client.session.prompt({ sessionID, agent, model, variant: args.variant, parts: [...files, { type: "text", text: message }], }) + return + } + + const model = pick(args.model) + const { runInteractiveMode } = await runtimeTask + try { + await runInteractiveMode({ + sdk: client, + directory: cwd, + sessionID, + sessionTitle: sess.title, + resume: Boolean(args.session || args.continue) && !args.fork, + agent, + model, + variant: args.variant, + files, + initialInput, + createSession: createFreshSession, + thinking, + demo: args.demo, + }) + } catch (error) { + dieInteractive(error) + } + return + } + + if (args.interactive && !args.attach && !args.session && !args.continue) { + const model = pick(args.model) + const { runInteractiveLocalMode } = await runtimeTask + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + + try { + return await runInteractiveLocalMode({ + directory: directory ?? root, + fetch: fetchFn, + resolveAgent: localAgent, + session, + share, + createSession: createFreshSession, + agent: args.agent, + model, + variant: args.variant, + files, + initialInput, + thinking, + demo: args.demo, + }) + } catch (error) { + dieInteractive(error) } } if (args.attach) { - const headers = ServerAuth.headers({ password: args.password, username: args.username }) - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + const sdk = attachSDK(directory) return await execute(sdk) } const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") const request = new Request(input, init) return Server.Default().app.fetch(request) }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: fetchFn, + directory, + }) await execute(sdk) }) }), diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts new file mode 100644 index 0000000000..195ef6f496 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -0,0 +1,1281 @@ +// Demo mode for testing direct interactive mode without a real SDK. +// +// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic +// SDK events that feed through the real reducer and footer pipeline. This +// lets you test scrollback formatting, permission UI, question UI, and tool +// snapshots without making actual model calls. Pass a demo slash command as +// the initial interactive message to trigger a preview immediately. +// +// Slash commands: +// /permission [kind] → triggers a permission request variant +// /question [kind] → triggers a question request variant +// /fmt → emits a specific tool/text type (text, reasoning, bash, +// write, edit, patch, task, todo, question, error, mix) +// +// Demo mode also handles permission and question replies locally, completing +// or failing the synthetic tool parts as appropriate. +import path from "path" +import type { Event, ToolPart } from "@opencode-ai/sdk/v2" +import { createSessionData, reduceSessionData, type SessionData } from "./session-data" +import { writeSessionOutput } from "./stream" +import type { + FooterApi, + PermissionReply, + QuestionReject, + QuestionReply, + RunPrompt, + StreamCommit, +} from "./types" + +const KINDS = [ + "markdown", + "table", + "text", + "reasoning", + "bash", + "write", + "edit", + "patch", + "task", + "todo", + "question", + "error", + "mix", +] +const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const +const QUESTIONS = ["multi", "single", "checklist", "custom"] as const + +type PermissionKind = (typeof PERMISSIONS)[number] +type QuestionKind = (typeof QUESTIONS)[number] + +function permissionKind(value: string | undefined): PermissionKind | undefined { + const next = (value || "edit").toLowerCase() + return PERMISSIONS.find((item) => item === next) +} + +function questionKind(value: string | undefined): QuestionKind | undefined { + const next = (value || "multi").toLowerCase() + return QUESTIONS.find((item) => item === next) +} + +const SAMPLE_MARKDOWN = [ + "# Direct Mode Demo", + "", + "This is a realistic assistant response for direct-mode formatting checks.", + "It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.", + "", + "## Summary", + "", + "- Restored the final markdown flush so the last block is committed on idle.", + "- Switched markdown scrollback commits back to top-level block boundaries.", + "- Added footer-level regression coverage for split-footer rendering.", + "", + "## Status", + "", + "| Area | Before | After | Notes |", + "| --- | --- | --- | --- |", + "| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |", + "| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |", + "| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |", + "", + "> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.", + "", + "```ts", + "const result = { markdown: true, tables: 2, stable: true }", + "```", + "", + "## Files", + "", + "| File | Change |", + "| --- | --- |", + "| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |", + "| `footer.ts` | Keep active surfaces across footer-height-only resizes |", + "| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |", + "", + "Next step: run `/fmt table` if you want a tighter table-only sample.", +].join("\n") + +const SAMPLE_TABLE = [ + "# Table Sample", + "", + "| Kind | Example | Notes |", + "| --- | --- | --- |", + "| Pipe | `A\\|B` | Escaped pipes should stay in one cell |", + "| Unicode | `漢字` | Wide characters should remain aligned |", + "| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |", + "| Status | done | Final row should still appear after idle |", +].join("\n") + +type Ref = { + msg: string + part: string + call: string + tool: string + input: Record + start: number +} + +type Ask = { + ref: Ref +} + +type Perm = { + ref: Ref + done: { + title: string + output: string + metadata?: Record + } +} + +type Permit = { + ref: Ref + permission: string + patterns: string[] + metadata?: Record + always: string[] + done: Perm["done"] +} + +type State = { + id: string + thinking: boolean + data: SessionData + footer: FooterApi + limits: () => Record + msg: number + part: number + call: number + perm: number + ask: number + perms: Map + asks: Map +} + +type Input = { + sessionID: string + thinking: boolean + limits: () => Record + footer: FooterApi +} + +function note(footer: FooterApi, text: string): void { + footer.append({ + kind: "system", + text, + phase: "start", + source: "system", + }) +} + +function clearSubagent(footer: FooterApi): void { + footer.event({ + type: "stream.subagent", + state: { + tabs: [], + details: {}, + permissions: [], + questions: [], + }, + }) +} + +function showSubagent( + state: State, + input: { + sessionID: string + partID: string + callID: string + label: string + description: string + status: "running" | "completed" | "error" + title?: string + toolCalls?: number + commits: StreamCommit[] + }, +) { + state.footer.event({ + type: "stream.subagent", + state: { + tabs: [ + { + sessionID: input.sessionID, + partID: input.partID, + callID: input.callID, + label: input.label, + description: input.description, + status: input.status, + title: input.title, + toolCalls: input.toolCalls, + lastUpdatedAt: Date.now(), + }, + ], + details: { + [input.sessionID]: { + sessionID: input.sessionID, + commits: input.commits, + }, + }, + permissions: [], + questions: [], + }, + }) +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (!signal) { + setTimeout(resolve, ms) + return + } + + if (signal.aborted) { + resolve() + return + } + + const done = () => { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + + const timer = setTimeout(() => { + signal.removeEventListener("abort", done) + resolve() + }, ms) + + signal.addEventListener("abort", done, { once: true }) + }) +} + +function split(text: string): string[] { + if (text.length <= 48) { + return [text] + } + + const size = Math.ceil(text.length / 3) + return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)] +} + +function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string { + state[key] += 1 + return `demo_${prefix}_${state[key]}` +} + +function feed(state: State, event: Event): void { + const out = reduceSessionData({ + data: state.data, + event, + sessionID: state.id, + thinking: state.thinking, + limits: state.limits(), + }) + state.data = out.data + writeSessionOutput( + { + footer: state.footer, + }, + out, + ) +} + +function open(state: State): string { + const id = take(state, "msg", "msg") + feed(state, { + type: "message.updated", + properties: { + sessionID: state.id, + info: { + id, + sessionID: state.id, + role: "assistant", + time: { + created: Date.now(), + }, + parentID: `user_${id}`, + modelID: "demo", + providerID: "demo", + mode: "demo", + agent: "demo", + path: { + cwd: process.cwd(), + root: process.cwd(), + }, + cost: 0.001, + tokens: { + input: 120, + output: 320, + reasoning: 80, + cache: { + read: 0, + write: 0, + }, + }, + }, + }, + } as Event) + return id +} + +async function emitText(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +function make(state: State, tool: string, input: Record): Ref { + return { + msg: open(state), + part: take(state, "part", "part"), + call: take(state, "call", "call"), + tool, + input, + start: Date.now(), + } +} + +function startTool(state: State, ref: Ref, metadata: Record = {}): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "running", + input: ref.input, + metadata, + time: { + start: ref.start, + }, + }, + }, + }, + } as Event) +} + +function askPermission(state: State, item: Permit): void { + startTool(state, item.ref) + + const id = take(state, "perm", "perm") + state.perms.set(id, { + ref: item.ref, + done: item.done, + }) + + feed(state, { + type: "permission.asked", + properties: { + id, + sessionID: state.id, + permission: item.permission, + patterns: item.patterns, + metadata: item.metadata ?? {}, + always: item.always, + tool: { + messageID: item.ref.msg, + callID: item.ref.call, + }, + }, + } as Event) +} + +function doneTool( + state: State, + ref: Ref, + output: { + title: string + output: string + metadata?: Record + }, +): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "completed", + input: ref.input, + output: output.output, + title: output.title, + metadata: output.metadata ?? {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function failTool(state: State, ref: Ref, error: string): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "error", + input: ref.input, + error, + metadata: {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function emitError(state: State, text: string): void { + const event = { + id: `session.error:${state.id}:${Date.now()}`, + type: "session.error", + properties: { + sessionID: state.id, + error: { + name: "UnknownError", + data: { + message: text, + }, + }, + }, + } satisfies Event + feed(state, event) +} + +async function emitBash(state: State, signal?: AbortSignal): Promise { + const ref = make(state, "bash", { + command: "git status", + workdir: process.cwd(), + description: "Show git status", + }) + startTool(state, ref) + await wait(70, signal) + doneTool(state, ref, { + title: "git status", + output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`, + metadata: { + exitCode: 0, + }, + }) +} + +function emitWrite(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "write", { + filePath: file, + content: "export const demo = 42\n", + }) + doneTool(state, ref, { + title: "write", + output: "", + metadata: {}, + }) +} + +function emitEdit(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "edit", { + filePath: file, + }) + doneTool(state, ref, { + title: "edit", + output: "", + metadata: { + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + }, + }) +} + +function emitPatch(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "apply_patch", { + patchText: "*** Begin Patch\n*** End Patch", + }) + doneTool(state, ref, { + title: "apply_patch", + output: "", + metadata: { + files: [ + { + type: "update", + filePath: file, + relativePath: "src/demo-format.ts", + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + deletions: 1, + }, + { + type: "add", + filePath: path.join(process.cwd(), "README-demo.md"), + relativePath: "README-demo.md", + diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n", + deletions: 0, + }, + ], + }, + }) +} + +function emitTask(state: State): void { + const ref = make(state, "task", { + description: "Scan run/* for reducer touchpoints", + subagent_type: "explore", + }) + doneTool(state, ref, { + title: "Reducer touchpoints found", + output: "", + metadata: { + toolcalls: 4, + sessionId: "sub_demo_1", + }, + }) + const part = { + id: "sub_demo_tool_1", + type: "tool", + sessionID: "sub_demo_1", + messageID: "sub_demo_msg_tool", + callID: "sub_demo_call_1", + tool: "read", + state: { + status: "running", + input: { + filePath: "packages/opencode/src/cli/cmd/run/stream.ts", + offset: 1, + limit: 200, + }, + time: { + start: Date.now(), + }, + }, + } satisfies ToolPart + showSubagent(state, { + sessionID: "sub_demo_1", + partID: ref.part, + callID: ref.call, + label: "Explore", + description: "Scan run/* for reducer touchpoints", + status: "completed", + title: "Reducer touchpoints found", + toolCalls: 4, + commits: [ + { + kind: "user", + text: "Scan run/* for reducer touchpoints", + phase: "start", + source: "system", + }, + { + kind: "reasoning", + text: "Thinking: tracing reducer and footer boundaries", + phase: "progress", + source: "reasoning", + messageID: "sub_demo_msg_reasoning", + partID: "sub_demo_reasoning_1", + }, + { + kind: "tool", + text: "running read", + phase: "start", + source: "tool", + messageID: "sub_demo_msg_tool", + partID: "sub_demo_tool_1", + tool: "read", + part, + }, + { + kind: "assistant", + text: "Footer updates flow through stream.ts into RunFooter", + phase: "progress", + source: "assistant", + messageID: "sub_demo_msg_text", + partID: "sub_demo_text_1", + }, + ], + }) +} + +function emitTodo(state: State): void { + const ref = make(state, "todowrite", { + todos: [ + { + content: "Trigger permission UI", + status: "completed", + }, + { + content: "Trigger question UI", + status: "in_progress", + }, + { + content: "Tune tool formatting", + status: "pending", + }, + ], + }) + doneTool(state, ref, { + title: "todowrite", + output: "", + metadata: {}, + }) +} + +function emitQuestionTool(state: State): void { + const ref = make(state, "question", { + questions: [ + { + header: "Style", + question: "Which output style do you want to inspect?", + options: [ + { label: "Diff", description: "Show diff block" }, + { label: "Code", description: "Show code block" }, + ], + multiple: false, + }, + { + header: "Extras", + question: "Pick extra rows", + options: [ + { label: "Usage", description: "Add usage row" }, + { label: "Duration", description: "Add duration row" }, + ], + multiple: true, + custom: true, + }, + ], + }) + doneTool(state, ref, { + title: "question", + output: "", + metadata: { + answers: [["Diff"], ["Usage", "custom-note"]], + }, + }) +} + +function emitPermission(state: State, kind: PermissionKind = "edit"): void { + const root = process.cwd() + const file = path.join(root, "src", "demo-format.ts") + + if (kind === "bash") { + const command = "git status --short" + const ref = make(state, "bash", { + command, + workdir: root, + description: "Inspect worktree changes", + }) + askPermission(state, { + ref, + permission: "bash", + patterns: [command], + always: ["*"], + done: { + title: "git status --short", + output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`, + metadata: { + exitCode: 0, + }, + }, + }) + return + } + + if (kind === "read") { + const target = path.join(root, "package.json") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 80, + }) + askPermission(state, { + ref, + permission: "read", + patterns: [target], + always: [target], + done: { + title: "read", + output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"), + metadata: {}, + }, + }) + return + } + + if (kind === "task") { + const ref = make(state, "task", { + description: "Inspect footer spacing across direct-mode prompts", + subagent_type: "explore", + }) + askPermission(state, { + ref, + permission: "task", + patterns: ["explore"], + always: ["*"], + done: { + title: "Footer spacing checked", + output: "", + metadata: { + toolcalls: 3, + sessionId: "sub_demo_perm_1", + }, + }, + }) + return + } + + if (kind === "external") { + const dir = path.join(path.dirname(root), "demo-shared") + const target = path.join(dir, "README.md") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 40, + }) + askPermission(state, { + ref, + permission: "external_directory", + patterns: [`${dir}/**`], + metadata: { + parentDir: dir, + filepath: target, + }, + always: [`${dir}/**`], + done: { + title: "read", + output: `1: # External demo\n2: Shared preview file\nPath: ${target}`, + metadata: {}, + }, + }) + return + } + + if (kind === "doom") { + const ref = make(state, "task", { + description: "Retry the formatter after repeated failures", + subagent_type: "general", + }) + askPermission(state, { + ref, + permission: "doom_loop", + patterns: ["*"], + always: ["*"], + done: { + title: "Retry allowed", + output: "Continuing after repeated failures.\n", + metadata: {}, + }, + }) + return + } + + const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n" + const ref = make(state, "edit", { + filePath: file, + filepath: file, + diff, + }) + askPermission(state, { + ref, + permission: "edit", + patterns: [file], + always: [file], + done: { + title: "edit", + output: "", + metadata: { + diff, + }, + }, + }) +} + +function emitQuestion(state: State, kind: QuestionKind = "multi"): void { + const questions = (() => { + if (kind === "single") { + return [ + { + header: "Mode", + question: "Which footer should be the reference for spacing checks?", + options: [ + { label: "Permission", description: "Inspect the permission footer" }, + { label: "Question", description: "Keep this question footer open" }, + { label: "Prompt", description: "Return to the normal composer" }, + ], + multiple: false, + custom: false, + }, + ] + } + + if (kind === "checklist") { + return [ + { + header: "Checks", + question: "Select the direct-mode cases you want to inspect next", + options: [ + { label: "Diff", description: "Show an edit diff in the footer" }, + { label: "Task", description: "Show a structured task summary" }, + { label: "Todo", description: "Show a todo snapshot" }, + { label: "Error", description: "Show an error transcript row" }, + ], + multiple: true, + custom: false, + }, + ] + } + + if (kind === "custom") { + return [ + { + header: "Reply", + question: "What custom answer should appear in the footer preview?", + options: [ + { label: "Short note", description: "Keep the answer to one line" }, + { label: "Wrapped note", description: "Use a longer answer to test wrapping" }, + ], + multiple: false, + custom: true, + }, + ] + } + + return [ + { + header: "Layout", + question: "Which footer view should stay active while testing?", + options: [ + { label: "Prompt", description: "Return to prompt" }, + { label: "Question", description: "Keep question open" }, + ], + multiple: false, + }, + { + header: "Rows", + question: "Pick formatting previews", + options: [ + { label: "Diff", description: "Emit edit diff" }, + { label: "Task", description: "Emit task card" }, + { label: "Todo", description: "Emit todo card" }, + ], + multiple: true, + custom: true, + }, + ] + })() + + const ref = make(state, "question", { questions }) + startTool(state, ref) + + const id = take(state, "ask", "ask") + state.asks.set(id, { ref }) + + feed(state, { + type: "question.asked", + properties: { + id, + sessionID: state.id, + questions, + tool: { + messageID: ref.msg, + callID: ref.call, + }, + }, + } as Event) +} + +async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise { + if (kind === "text") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "markdown" || kind === "md") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "table") { + await emitText(state, body || SAMPLE_TABLE, signal) + return true + } + + if (kind === "reasoning") { + await emitReasoning(state, body || "Planning next steps [REDACTED] while preserving reducer ordering.", signal) + return true + } + + if (kind === "bash") { + await emitBash(state, signal) + return true + } + + if (kind === "write") { + emitWrite(state) + return true + } + + if (kind === "edit") { + emitEdit(state) + return true + } + + if (kind === "patch") { + emitPatch(state) + return true + } + + if (kind === "task") { + emitTask(state) + return true + } + + if (kind === "todo") { + emitTodo(state) + return true + } + + if (kind === "question") { + emitQuestionTool(state) + return true + } + + if (kind === "error") { + emitError(state, body || "demo error event") + return true + } + + if (kind === "mix") { + await emitText(state, SAMPLE_MARKDOWN, signal) + await wait(50, signal) + await emitReasoning(state, "Thinking through formatter edge cases [REDACTED].", signal) + await wait(50, signal) + await emitBash(state, signal) + emitWrite(state) + emitEdit(state) + emitPatch(state) + emitTask(state) + emitTodo(state) + emitQuestionTool(state) + emitError(state, "demo mixed scenario error") + return true + } + + return false +} + +function intro(state: State): void { + note( + state.footer, + [ + "Demo slash commands enabled for interactive mode.", + `- /permission [kind] (${PERMISSIONS.join(", ")})`, + `- /question [kind] (${QUESTIONS.join(", ")})`, + `- /fmt (${KINDS.join(", ")})`, + "Examples:", + "- /permission bash", + "- /question custom", + "- /fmt markdown", + "- /fmt table", + "- /fmt text your custom text", + ].join("\n"), + ) +} + +export function createRunDemo(input: Input) { + const state: State = { + id: input.sessionID, + thinking: input.thinking, + data: createSessionData(), + footer: input.footer, + limits: input.limits, + msg: 0, + part: 0, + call: 0, + perm: 0, + ask: 0, + perms: new Map(), + asks: new Map(), + } + + const start = async (): Promise => { + intro(state) + } + + const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise => { + const text = line.text.trim() + const list = text.split(/\s+/) + const cmd = list[0] || "" + + clearSubagent(state.footer) + + if (cmd === "/help") { + intro(state) + return true + } + + if (cmd === "/permission") { + const kind = permissionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`) + return true + } + + emitPermission(state, kind) + return true + } + + if (cmd === "/question") { + const kind = questionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`) + return true + } + + emitQuestion(state, kind) + return true + } + + if (cmd === "/fmt") { + const kind = (list[1] || "").toLowerCase() + const body = list.slice(2).join(" ") + if (!kind) { + note(state.footer, `Pick a kind: ${KINDS.join(", ")}`) + return true + } + + const ok = await emitFmt(state, kind, body, signal) + if (ok) { + return true + } + + note(state.footer, `Unknown kind "${kind}". Use: ${KINDS.join(", ")}`) + return true + } + + return false + } + + const permission = (input: PermissionReply): boolean => { + const item = state.perms.get(input.requestID) + if (!item || !input.reply) { + return false + } + + state.perms.delete(input.requestID) + const event = { + id: `permission.replied:${input.requestID}:${Date.now()}`, + type: "permission.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + reply: input.reply, + }, + } satisfies Event + feed(state, event) + + if (input.reply === "reject") { + failTool(state, item.ref, input.message || "permission rejected") + return true + } + + doneTool(state, item.ref, item.done) + return true + } + + const questionReply = (input: QuestionReply): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask || !input.answers) { + return false + } + + state.asks.delete(input.requestID) + const event = { + id: `question.replied:${input.requestID}:${Date.now()}`, + type: "question.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + answers: input.answers, + }, + } satisfies Event + feed(state, event) + doneTool(state, ask.ref, { + title: "question", + output: "", + metadata: { + answers: input.answers, + }, + }) + return true + } + + const questionReject = (input: QuestionReject): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask) { + return false + } + + state.asks.delete(input.requestID) + feed(state, { + type: "question.rejected", + properties: { + sessionID: state.id, + requestID: input.requestID, + }, + } as Event) + failTool(state, ask.ref, "question rejected") + return true + } + + return { + start, + prompt, + permission, + questionReply, + questionReject, + } +} diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts new file mode 100644 index 0000000000..bb058e8a37 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/entry.body.ts @@ -0,0 +1,194 @@ +import { toolEntryBody } from "./tool" +import type { RunEntryBody, StreamCommit } from "./types" + +export type EntryFlags = { + startOnNewLine: boolean + trailingNewline: boolean +} + +export const RUN_ENTRY_NONE: RunEntryBody = { + type: "none", +} + +export function cleanRunText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +function textBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "text", + content, + } +} + +function codeBody(content: string, filetype?: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "code", + content, + filetype, + } +} + +function markdownBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "markdown", + content, + } +} + +function userBody(raw: string): RunEntryBody { + if (!raw.trim()) { + return RUN_ENTRY_NONE + } + + const lead = raw.match(/^\n+/)?.[0] ?? "" + const body = lead ? raw.slice(lead.length) : raw + return textBody(`${lead}› ${body}`) +} + +function reasoningBody(raw: string): RunEntryBody { + const clean = raw.replace(/\[REDACTED\]/g, "") + if (!clean) { + return RUN_ENTRY_NONE + } + + const lead = clean.match(/^\n+/)?.[0] ?? "" + const body = lead ? clean.slice(lead.length) : clean + const mark = "Thinking:" + if (body.startsWith(mark)) { + return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown") + } + + return codeBody(clean, "markdown") +} + +function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { + return textBody(phase === "progress" ? raw : raw.trim()) +} + +export function entryFlags(commit: StreamCommit): EntryFlags { + if (commit.kind === "user") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + if (commit.kind === "tool") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "assistant" || commit.kind === "reasoning") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "error") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } +} + +export function entryDone(commit: StreamCommit): boolean { + if (commit.kind === "assistant" || commit.kind === "reasoning") { + return commit.phase === "final" + } + + if (commit.kind === "tool") { + return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed") + } + + return true +} + +export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean { + if (commit.phase !== "progress") { + return false + } + + if (body.type === "none") { + return false + } + + if (commit.kind === "tool") { + return commit.toolState !== "completed" + } + + return commit.kind === "assistant" || commit.kind === "reasoning" +} + +export function entryBody(commit: StreamCommit): RunEntryBody { + const raw = cleanRunText(commit.text) + + if (commit.kind === "user") { + return userBody(raw) + } + + if (commit.kind === "tool") { + return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE + } + + if (commit.kind === "assistant") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE + } + + return markdownBody(raw) + } + + if (commit.kind === "reasoning") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE + } + + return reasoningBody(raw) + } + + return systemBody(raw, commit.phase) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx new file mode 100644 index 0000000000..9e964d02bc --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -0,0 +1,647 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core" +import { useKeyboard, type JSX } from "@opentui/solid" +import fuzzysort from "fuzzysort" +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" +import { formatBindings } from "./keymap.shared" +import type { RunFooterTheme } from "./theme" +import type { FooterKeybinds, RunCommand, RunInput, RunProvider } from "./types" + +type PanelEntry = RunFooterMenuItem & { + category: string + keywords?: string +} + +type CommandEntry = + | (PanelEntry & { action: "model" }) + | (PanelEntry & { action: "variant.cycle" }) + | (PanelEntry & { action: "variant.list" }) + | (PanelEntry & { action: "slash"; name: string }) + | (PanelEntry & { action: "exit" }) + +type ModelEntry = PanelEntry & { + providerID: string + modelID: string + providerName: string + current: boolean +} + +type VariantEntry = PanelEntry & { + variant: string | undefined + current: boolean +} + +type MenuState = ReturnType + +const PANEL_PAD = 2 +const PANEL_LIST_ROWS = 10 +export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + 6 +const PANEL_PAGE = PANEL_LIST_ROWS - 1 +const PANEL_BORDER = { + topLeft: "", + bottomLeft: "", + vertical: "┃", + topRight: "", + bottomRight: "", + horizontal: " ", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} +const PANEL_BOTTOM_BORDER = { + ...PANEL_BORDER, + vertical: "╹", +} +const HALF_BLOCK_BORDER = { + topLeft: "", + bottomLeft: "", + vertical: "", + topRight: "", + bottomRight: "", + horizontal: "▀", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} + +function countLabel(count: number, total: number, query: string) { + if (!query.trim()) { + return `${total}` + } + + return `${count}/${total}` +} + +function categoryRank(category: string) { + if (category === "Project Commands") { + return 0 + } + + if (category === "MCP Commands") { + return 1 + } + + return 2 +} + +function handleKey(input: { + event: KeyEvent + menu: MenuState + field: () => InputRenderable | undefined + setQuery: (value: string) => void + select: () => void + close: () => void +}) { + const name = input.event.name.toLowerCase() + const ctrl = input.event.ctrl && !input.event.meta && !input.event.shift && !input.event.super + + if (name === "escape" || (ctrl && name === "c")) { + input.event.preventDefault() + input.close() + return + } + + if (name === "up" || (ctrl && name === "p")) { + input.event.preventDefault() + input.menu.move(-1) + return + } + + if (name === "down" || (ctrl && name === "n")) { + input.event.preventDefault() + input.menu.move(1) + return + } + + if (name === "pageup") { + input.event.preventDefault() + input.menu.reveal(input.menu.selected() - PANEL_PAGE) + return + } + + if (name === "pagedown") { + input.event.preventDefault() + input.menu.reveal(input.menu.selected() + PANEL_PAGE) + return + } + + if (name === "home") { + input.event.preventDefault() + input.menu.reveal(0) + return + } + + if (name === "end") { + input.event.preventDefault() + input.menu.reveal(Number.POSITIVE_INFINITY) + return + } + + if (name === "return") { + input.event.preventDefault() + input.select() + return + } + + if (ctrl && name === "u") { + input.event.preventDefault() + input.setQuery("") + input.field()?.setText("") + } +} + +function match(query: string, entries: T[]) { + const text = query.trim() + if (!text) { + return entries + } + + return fuzzysort + .go(text, entries, { keys: ["display", "category", "description", "keywords"] }) + .map((item) => item.obj) +} + +function PanelShell(props: { + id: string + title: string + countVisible?: boolean + query: string + count: number + total: number + placeholder: string + theme: Accessor + inputRef: (input: InputRenderable) => void + onQuery: (query: string) => void + children: JSX.Element +}) { + return ( + + + + + + {props.title} + + {props.countVisible !== false ? ( + + {countLabel(props.count, props.total, props.query)} + + ) : null} + + + esc + + + + + { + props.inputRef(input) + input.traits = { status: "FILTER" } + queueMicrotask(() => { + if (!input.isDestroyed) { + input.focus() + } + }) + }} + /> + + + + {props.children} + + + + + + + ) +} + +export function RunCommandMenuBody(props: { + theme: Accessor + commands: Accessor + variants: Accessor + keybinds: FooterKeybinds + onClose: () => void + onModel: () => void + onVariant: () => void + onVariantCycle: () => void + onCommand: (name: string) => void + onNew: () => void + onExit: () => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => { + const builtins = ["new"] + return [ + { + action: "model", + category: "Suggested", + display: "Switch model", + }, + { + action: "variant.cycle", + category: "Suggested", + display: "Variant cycle", + footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader), + keywords: "variant cycle", + }, + ...(props.variants().length > 0 + ? [ + { + action: "variant.list" as const, + category: "Suggested", + display: "Switch model variant", + keywords: `variant variants ${props.variants().join(" ")}`, + }, + ] + : []), + { + action: "slash", + category: "Session", + name: "new", + display: "New session", + footer: "/new", + keywords: "new session clear", + }, + ...(props.commands() ?? []) + .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) + .map( + (item) => + ({ + action: "slash", + category: item.source === "mcp" ? "MCP Commands" : "Project Commands", + name: item.name, + display: item.name, + footer: `/${item.name}`, + keywords: + item.source === "mcp" + ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` + : `/${item.name} ${item.name} ${item.description ?? ""}`, + }) satisfies CommandEntry, + ) + .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)), + { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" }, + ] + }) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: CommandEntry) => { + if (item.action === "model") { + props.onModel() + return + } + + if (item.action === "variant.cycle") { + props.onVariantCycle() + return + } + + if (item.action === "variant.list") { + props.onVariant() + return + } + + if (item.action === "exit") { + props.onExit() + return + } + + if (item.name === "new") { + props.onNew() + return + } + + props.onCommand(item.name) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No results found" + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={!query().trim()} + /> + + ) +} + +export function RunVariantSelectBody(props: { + theme: Accessor + variants: Accessor + current: Accessor + onClose: () => void + onSelect: (variant: string | undefined) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => [ + { + category: "", + display: "Default", + description: props.current() === undefined ? "current" : undefined, + keywords: "default", + variant: undefined, + current: props.current() === undefined, + }, + ...props.variants().map((variant) => ({ + category: "", + display: variant, + description: props.current() === variant ? "current" : undefined, + keywords: variant, + variant, + current: props.current() === variant, + })), + ]) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: VariantEntry) => { + props.onSelect(item.variant) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + createEffect(() => { + if (query().trim()) { + return + } + + const index = items().findIndex((item) => item.current) + if (index !== -1) { + menu.reveal(index) + } + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No results found" + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={false} + /> + + ) +} + +export function RunModelSelectBody(props: { + theme: Accessor + providers: Accessor + current: Accessor + onClose: () => void + onSelect: (model: NonNullable) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => + (props.providers() ?? []) + .flatMap((provider) => + Object.entries(provider.models) + .filter(([, model]) => model.status !== "deprecated") + .map(([modelID, model]) => { + const title = model.name ?? modelID + const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID + const footer = current + ? "current" + : model.cost?.input === 0 && provider.id === "opencode" + ? "Free" + : title !== modelID + ? modelID + : undefined + return { + providerID: provider.id, + modelID, + providerName: provider.name, + category: provider.name, + display: title, + footer, + keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`, + current, + } + }), + ) + .sort((a, b) => { + const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode") + if (provider !== 0) { + return provider + } + + const name = a.providerName.localeCompare(b.providerName) + if (name !== 0) { + return name + } + + return a.display.localeCompare(b.display) + }), + ) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: ModelEntry) => { + props.onSelect({ providerID: item.providerID, modelID: item.modelID }) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + createEffect(() => { + if (query().trim()) { + return + } + + const index = items().findIndex((item) => item.current) + if (index !== -1) { + menu.reveal(index) + } + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty={props.providers() ? "No results found" : "Models loading"} + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={!query().trim()} + /> + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/opencode/src/cli/cmd/run/footer.menu.tsx new file mode 100644 index 0000000000..7a3332165b --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.menu.tsx @@ -0,0 +1,290 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes } from "@opentui/core" +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import { transparent, type RunFooterTheme } from "./theme" + +export const FOOTER_MENU_ROWS = 8 + +export type RunFooterMenuItem = { + display: string + description?: string + category?: string + footer?: string +} + +type RunFooterMenuRow = + | { type: "header"; label: string } + | { type: "item"; item: RunFooterMenuItem; index: number } + | { type: "spacer" } + +function maxOffset(count: number, limit: number) { + return Math.max(0, count - limit) +} + +function previewMargin(limit: number) { + return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2))) +} + +function revealOffset(value: number, input: { count: number; limit: number; selected: number }) { + const max = maxOffset(input.count, input.limit) + if (input.selected < value) { + return Math.min(max, input.selected) + } + + if (input.selected >= value + input.limit) { + return Math.min(max, input.selected - input.limit + 1) + } + + return Math.min(max, value) +} + +function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) { + const max = maxOffset(input.count, input.limit) + const margin = previewMargin(input.limit) + if (input.dir < 0 && input.selected < value + margin) { + return Math.max(0, Math.min(max, input.selected - margin)) + } + + if (input.dir > 0 && input.selected > value + input.limit - margin - 1) { + return Math.min(max, input.selected - input.limit + margin + 1) + } + + return Math.min(max, value) +} + +export function createFooterMenuState(input: { count: Accessor; limit?: number }) { + const [selected, setSelected] = createSignal(0) + const [offset, setOffset] = createSignal(0) + const limit = () => input.limit ?? FOOTER_MENU_ROWS + const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count()))) + + const reveal = (index: number) => { + const count = input.count() + if (count === 0) { + setSelected(0) + setOffset(0) + return + } + + const next = Math.max(0, Math.min(count - 1, index)) + setSelected(next) + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next })) + } + + const reset = () => { + setSelected(0) + setOffset(0) + } + + createEffect(() => { + const count = input.count() + if (count === 0) { + reset() + return + } + + if (selected() >= count) { + setSelected(count - 1) + } + + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() })) + }) + + const move = (dir: -1 | 1) => { + const count = input.count() + if (count === 0) { + reset() + return + } + + const next = Math.max(0, Math.min(count - 1, selected() + dir)) + setSelected(next) + setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir })) + } + + return { + selected, + offset, + rows, + reveal, + reset, + move, + } +} + +export function RunFooterMenu(props: { + id?: string + theme: Accessor + items: Accessor + selected: Accessor + offset: Accessor + rows: Accessor + limit?: number + empty?: string + border?: boolean + paddingLeft?: number + paddingRight?: number + grouped?: boolean +}) { + const limit = () => props.limit ?? FOOTER_MENU_ROWS + const border = () => props.border ?? true + const [groupOffset, setGroupOffset] = createSignal(0) + let previous = -1 + const groupedRows = createMemo(() => { + const all: RunFooterMenuRow[] = [] + let category = "" + props.items().forEach((item, index) => { + if (item.category && item.category !== category) { + if (all.length > 0) { + all.push({ type: "spacer" }) + } + + category = item.category + all.push({ type: "header", label: item.category }) + } + + all.push({ type: "item", item, index }) + }) + return all + }) + + createEffect(() => { + if (!props.grouped) { + return + } + + const all = groupedRows() + const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected()) + if (all.length === 0 || selected === -1) { + setGroupOffset(0) + previous = props.selected() + return + } + + const dir = + props.selected() === previous + 1 ? 1 + : props.selected() === previous - 1 ? -1 + : undefined + setGroupOffset((value) => + dir + ? moveOffset(value, { count: all.length, limit: limit(), selected, dir }) + : revealOffset(value, { count: all.length, limit: limit(), selected }), + ) + previous = props.selected() + }) + + const rows = createMemo(() => { + if (!props.grouped) { + return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({ + type: "item", + item, + index: index + props.offset(), + })) + } + + const all = groupedRows() + const start = Math.max(0, Math.min(groupOffset(), all.length - limit())) + return all.slice(start, start + limit()) + }) + const descriptionColumn = createMemo(() => { + const width = Math.max(0, ...props.items().filter((item) => item.description).map((item) => Bun.stringWidth(item.display))) + return width === 0 ? 0 : width + 2 + }) + const descriptionPad = (item: RunFooterMenuItem) => { + if (!item.description) { + return "" + } + + return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display))) + } + return ( + + {rows().length === 0 ? ( + + {border() ? ( + + ┃ + + ) : undefined} + + + {props.empty ?? "No matching items"} + + + + ) : ( + rows().map((row) => { + if (row.type === "spacer") { + return + } + + if (row.type === "header") { + return ( + + + {row.label} + + + ) + } + + const active = () => row.index === props.selected() + const inset = () => (active() ? 1 : 0) + return ( + + {border() ? ( + + ┃ + + ) : undefined} + + + + + {row.item.display} + {row.item.description ? ( + + {descriptionPad(row.item)} + {row.item.description} + + ) : undefined} + + {row.item.footer ? ( + + {row.item.footer} + + ) : undefined} + + + + + ) + }) + )} + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx new file mode 100644 index 0000000000..b38c2da9d1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -0,0 +1,478 @@ +// Permission UI body for the direct-mode footer. +// +// Renders inside the footer when the reducer pushes a FooterView of type +// "permission". Uses a three-stage state machine (permission.shared.ts): +// +// permission → shows the request with Allow once / Always / Reject buttons +// always → confirmation step before granting permanent access +// reject → text field for the rejection message +// +// Keyboard: left/right to select, enter to confirm, esc to reject. +// The diff view (when available) uses the same diff component as scrollback +// tool snapshots. +/** @jsxImportSource @opentui/solid */ +import type { TextareaRenderable } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { + createPermissionBodyState, + permissionAlwaysLines, + permissionCancel, + permissionEscape, + permissionHover, + permissionInfo, + permissionLabel, + permissionOptions, + permissionReject, + permissionRun, + permissionShift, + type PermissionOption, +} from "./permission.shared" +import { toolFiletype } from "./tool" +import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme" +import type { PermissionReply, RunDiffStyle } from "./types" + +function buttons( + list: PermissionOption[], + selected: PermissionOption, + theme: RunFooterTheme, + disabled: boolean, + onHover: (option: PermissionOption) => void, + onSelect: (option: PermissionOption) => void, +) { + return ( + + + {(option) => ( + { + if (!disabled) onHover(option) + }} + onMouseUp={() => { + if (!disabled) onSelect(option) + }} + > + {permissionLabel(option)} + + )} + + + ) +} + +function RejectField(props: { + theme: RunFooterTheme + text: string + disabled: boolean + onChange: (text: string) => void + onConfirm: () => void + onCancel: () => void +}) { + let area: TextareaRenderable | undefined + + createEffect(() => { + if (!area || area.isDestroyed) { + return + } + + if (area.plainText !== props.text) { + area.setText(props.text) + area.cursorOffset = props.text.length + } + + queueMicrotask(() => { + if (!area || area.isDestroyed || props.disabled) { + return + } + area.focus() + }) + }) + + return ( +