diff --git a/packages/cli/src/commands/handlers/auth/list.ts b/packages/cli/src/commands/handlers/auth/list.ts index 7441b772328..feb5f751136 100644 --- a/packages/cli/src/commands/handlers/auth/list.ts +++ b/packages/cli/src/commands/handlers/auth/list.ts @@ -3,7 +3,7 @@ import { Effect, Option } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { createClient, loadIntegrations } from "./shared" -import { errorMessage } from "../../../ui/prompt" +import { errorMessage } from "../../../util/error" export default Runtime.handler(Commands.commands.auth.commands.list, (input) => list(input).pipe( diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts index 0964f76dfb7..a0c767c718d 100644 --- a/packages/cli/src/commands/handlers/console/login.ts +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -6,6 +6,7 @@ import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" import { createTimelineHost, type TimelineHost } from "../../../ui/timeline" +import { errorMessage } from "../../../util/error" const integrationID = "opencode" const location = { directory: process.cwd() } @@ -24,9 +25,9 @@ export default Runtime.handler( if (Exit.isSuccess(exit)) return const cancelled = timeline.signal.aborted - yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe( - Effect.ignore, - ) + yield* request(() => + timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(Cause.squash(exit.cause))), + ).pipe(Effect.ignore) process.exitCode = cancelled ? 130 : 1 }), ) @@ -107,12 +108,3 @@ function request(task: (signal: AbortSignal) => Promise) { function required(value: A | null | undefined, message: string) { return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value) } - -function errorMessage(cause: Cause.Cause) { - const error = Cause.squash(cause) - if (error instanceof Error) return error.message - if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { - return error.message - } - return String(error) -} diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index c178e90b45b..4d81e6c8b28 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -9,9 +9,7 @@ import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( Commands.commands.debug.commands.agents, Effect.fn("cli.debug.agents")(function* () { - const options = yield* ServiceConfig.options() - const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.ensure(options)) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } })) process.stdout.write( diff --git a/packages/cli/src/commands/handlers/debug/config.ts b/packages/cli/src/commands/handlers/debug/config.ts index d6ff940e764..7dd822ce84d 100644 --- a/packages/cli/src/commands/handlers/debug/config.ts +++ b/packages/cli/src/commands/handlers/debug/config.ts @@ -9,9 +9,7 @@ import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( Commands.commands.debug.commands.config, Effect.fn("cli.debug.config")(function* () { - const options = yield* ServiceConfig.options() - const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.ensure(options)) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } })) process.stdout.write(JSON.stringify(entries, null, 2) + EOL) diff --git a/packages/cli/src/commands/handlers/export.ts b/packages/cli/src/commands/handlers/export.ts index abeb2fb8bab..f8fbff092ed 100644 --- a/packages/cli/src/commands/handlers/export.ts +++ b/packages/cli/src/commands/handlers/export.ts @@ -6,7 +6,7 @@ import { EOL } from "node:os" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { ServerConnection } from "../../services/server-connection" -import { errorMessage } from "../../ui/prompt" +import { errorMessage } from "../../util/error" export default Runtime.handler( Commands.commands.export, diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index cf4f216f921..0d7fd3afd4a 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -17,9 +17,7 @@ const location = { directory: process.cwd() } export default Runtime.handler( Commands.commands.mcp.commands.auth, Effect.fn("cli.mcp.auth")(function* (input) { - const options = yield* ServiceConfig.options() - const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.ensure(options)) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index 9572d3e1e7b..d26e24e3d1e 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -9,9 +9,7 @@ import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( Commands.commands.mcp.commands.list, Effect.fn("cli.mcp.list")(function* () { - const options = yield* ServiceConfig.options() - const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.ensure(options)) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } })) const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name)) diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts index 47dc3052a6f..6935bc6a8ab 100644 --- a/packages/cli/src/commands/handlers/mcp/logout.ts +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -12,9 +12,7 @@ const location = { directory: process.cwd() } export default Runtime.handler( Commands.commands.mcp.commands.logout, Effect.fn("cli.mcp.logout")(function* (input) { - const options = yield* ServiceConfig.options() - const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.ensure(options)) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) diff --git a/packages/cli/src/commands/handlers/plugin/list.ts b/packages/cli/src/commands/handlers/plugin/list.ts index 35b6c3b4ad4..a8538cd9617 100644 --- a/packages/cli/src/commands/handlers/plugin/list.ts +++ b/packages/cli/src/commands/handlers/plugin/list.ts @@ -12,9 +12,7 @@ import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugi export default Runtime.handler( Commands.commands.plugin.commands.list, Effect.fn("cli.plugin.list")(function* (input) { - const options = yield* ServiceConfig.options() - const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.ensure(options)) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } })) const config = yield* Config.Service diff --git a/packages/cli/src/run/run.ts b/packages/cli/src/run/run.ts index ff1dd57f3eb..737738db7c8 100644 --- a/packages/cli/src/run/run.ts +++ b/packages/cli/src/run/run.ts @@ -10,6 +10,7 @@ import { toolInlineInfo } from "@opencode-ai/tui/mini/tool" import { runNonInteractivePrompt } from "./noninteractive" import { UI } from "./ui" import { Env } from "../env" +import { errorMessage } from "../util/error" export type RunCommandInput = { server: ServerConnection.Resolved @@ -243,13 +244,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`) } -function errorMessage(error: unknown) { - if (error instanceof Error) return error.message - if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") - return error.message - return String(error) -} - /** @internal Used by the V1 command boundary before a Session exists. */ export function reportRunError(input: Pick, message: string, sessionID?: string) { process.exitCode = 1 diff --git a/packages/cli/src/ui/prompt.ts b/packages/cli/src/ui/prompt.ts index 8482c16d99d..f702113bac8 100644 --- a/packages/cli/src/ui/prompt.ts +++ b/packages/cli/src/ui/prompt.ts @@ -1,5 +1,6 @@ import { cancel, isCancel, log, outro } from "@clack/prompts" import { Effect } from "effect" +import { errorMessage } from "../util/error" const cancelled = Symbol("cancelled") @@ -38,11 +39,3 @@ export function handlePromptErrors(effect: Effect.Effect) { ), ) } - -export function errorMessage(error: unknown) { - if (error instanceof Error) return error.message - if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { - return error.message - } - return String(error) -} diff --git a/packages/cli/src/util/error.ts b/packages/cli/src/util/error.ts new file mode 100644 index 00000000000..e70f201c303 --- /dev/null +++ b/packages/cli/src/util/error.ts @@ -0,0 +1,7 @@ +export function errorMessage(error: unknown) { + if (error instanceof Error) return error.message + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + return error.message + } + return String(error) +} diff --git a/packages/cli/test/debug-config.test.ts b/packages/cli/test/debug-config.test.ts index e3dacd630c2..307275e9ba6 100644 --- a/packages/cli/test/debug-config.test.ts +++ b/packages/cli/test/debug-config.test.ts @@ -33,12 +33,14 @@ describe("debug config command", () => { }, ] let requested: URL | undefined + let healthProbes = 0 const authorization: Array = [] const server = Bun.serve({ port: 0, fetch(request) { const url = new URL(request.url) if (url.pathname === "/api/health") { + healthProbes += 1 return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }) } requested = url @@ -60,6 +62,7 @@ describe("debug config command", () => { expect(requested?.pathname).toBe("/api/config") expect(requested?.searchParams.get("location[directory]")).toBe(project) expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`]) + expect(healthProbes).toBe(1) } finally { server.stop(true) await fs.rm(root, { recursive: true, force: true }) diff --git a/packages/cli/test/import-boundaries.test.ts b/packages/cli/test/import-boundaries.test.ts index 3634aa5aed9..79773cd1456 100644 --- a/packages/cli/test/import-boundaries.test.ts +++ b/packages/cli/test/import-boundaries.test.ts @@ -35,7 +35,9 @@ describe("CLI frontend import boundaries", () => { test("keeps run and Mini on separate evaluation graphs", async () => { const run = await bundleInputs("packages/cli/src/commands/handlers/run.ts") expect(run).toContain("packages/cli/src/run/run.ts") + expect(run).toContain("packages/cli/src/util/error.ts") expect(run).toContain("packages/tui/src/mini/tool.ts") + expect(run).not.toContain("packages/cli/src/ui/prompt.ts") expect(run).not.toContain("packages/tui/src/mini/runtime.ts") expect(run).not.toContain("packages/tui/src/mini/runtime.lifecycle.ts") expect(run).not.toContain("packages/tui/src/mini/footer.ts") diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 677ff9245c2..806cd35fb2e 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -291,8 +291,7 @@ const themeContext = createSimpleContext({ }, delay), ) } - let unsubscribeRefresh: (() => void) | undefined - unsubscribeRefresh = themes.subscribeRefresh?.(refresh) + const unsubscribeRefresh = themes.subscribeRefresh?.(refresh) onCleanup(() => { renderer.off(CliRenderEvents.THEME_MODE, handle) diff --git a/packages/tui/src/mini/footer.ts b/packages/tui/src/mini/footer.ts index cb2434c8427..c1419058062 100644 --- a/packages/tui/src/mini/footer.ts +++ b/packages/tui/src/mini/footer.ts @@ -786,22 +786,8 @@ export class RunFooter implements FooterApi { return } - const patch: FooterPatch = {} - - if ("variants" in result) { - this.setVariants(result.variants ?? []) - } - - if ("variant" in result) { - this.setCurrentVariant(result.variant) - } - - if (result.modelLabel) { - patch.model = result.modelLabel - } - - this.patch(patch) - this.setNotice(result.status ?? "variant updated") + this.applySelectionResult(result) + if (result.status === undefined) this.setNotice("variant updated") } private handleModelSelect = (model: NonNullable): void => { @@ -827,26 +813,7 @@ export class RunFooter implements FooterApi { ) { return } - - if ("variants" in result) { - this.setVariants(result.variants ?? []) - } - - if ("variant" in result) { - this.setCurrentVariant(result.variant) - } - - const patch: FooterPatch = {} - if (result.modelLabel) { - patch.model = result.modelLabel - } - - if (patch.model) { - this.patch(patch) - } - if (result.status) { - this.setNotice(result.status) - } + this.applySelectionResult(result) }) .catch(() => {}) } @@ -875,30 +842,18 @@ export class RunFooter implements FooterApi { ) { return } - - if ("variants" in result) { - this.setVariants(result.variants ?? []) - } - - if ("variant" in result) { - this.setCurrentVariant(result.variant) - } - - const patch: FooterPatch = {} - if (result.modelLabel) { - patch.model = result.modelLabel - } - - if (patch.model) { - this.patch(patch) - } - if (result.status) { - this.setNotice(result.status) - } + this.applySelectionResult(result) }) .catch(() => {}) } + private applySelectionResult(result: CycleResult): void { + if ("variants" in result) this.setVariants(result.variants ?? []) + if ("variant" in result) this.setCurrentVariant(result.variant) + if (result.modelLabel) this.patch({ model: result.modelLabel }) + if (result.status) this.setNotice(result.status) + } + private handleMiniSettingChange = async (change: MiniSettingChange): Promise => { if (!this.options.miniSettings.update) { this.setNotice("settings are unavailable") diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 05bb17b02c8..35bfa0b1b7f 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -272,19 +272,21 @@ export function RunFooterView(props: RunFooterViewProps) { return current.type === "composer" ? "prompt" : current.type }) - const openCommand = () => { - setRoute({ type: "command" }) + const openRoute = (next: FooterPromptRoute) => { + setRoute(next) props.onSubagentSelect?.(undefined) } + const openCommand = () => { + openRoute({ type: "command" }) + } + const openModel = () => { - setRoute({ type: "model" }) - props.onSubagentSelect?.(undefined) + openRoute({ type: "model" }) } const openAgent = () => { - setRoute({ type: "agent" }) - props.onSubagentSelect?.(undefined) + openRoute({ type: "agent" }) } const openSkillMenu = () => { @@ -292,18 +294,15 @@ export function RunFooterView(props: RunFooterViewProps) { return } - setRoute({ type: "skill" }) - props.onSubagentSelect?.(undefined) + openRoute({ type: "skill" }) } const openVariant = () => { - setRoute({ type: "variant" }) - props.onSubagentSelect?.(undefined) + openRoute({ type: "variant" }) } const openSettings = () => { - setRoute({ type: "settings" }) - props.onSubagentSelect?.(undefined) + openRoute({ type: "settings" }) } const openSubagentMenu = () => { @@ -311,14 +310,12 @@ export function RunFooterView(props: RunFooterViewProps) { return } - setRoute({ type: "subagent-menu" }) - props.onSubagentSelect?.(undefined) + openRoute({ type: "subagent-menu" }) } const openQueuedMenu = () => { if (queue().length === 0) return - setRoute({ type: "queued-menu" }) - props.onSubagentSelect?.(undefined) + openRoute({ type: "queued-menu" }) } const closePanel = () => { @@ -347,8 +344,7 @@ export function RunFooterView(props: RunFooterViewProps) { } const closeTab = () => { - setRoute({ type: "composer" }) - props.onSubagentSelect?.(undefined) + openRoute({ type: "composer" }) } const cycleTab = (dir: -1 | 1) => { diff --git a/packages/tui/src/mini/stream-v2.subagent.ts b/packages/tui/src/mini/stream-v2.subagent.ts index bbc0c9d4b10..e7ac374dbde 100644 --- a/packages/tui/src/mini/stream-v2.subagent.ts +++ b/packages/tui/src/mini/stream-v2.subagent.ts @@ -34,6 +34,7 @@ import type { } from "./types" import { canonicalToolName, normalizeTool, toolOutputText, toolView } from "./tool" import { toolDisplayContent } from "../util/tool-display" +import { isRecord } from "../util/record" const CHILD_MESSAGE_LIMIT = 80 const CHILD_FRAME_LIMIT = 80 @@ -154,8 +155,7 @@ type DiscoveryJob = { } function record(value: unknown): Record | undefined { - if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record - return undefined + return isRecord(value) ? value : undefined } function text(value: unknown): string | undefined { diff --git a/packages/tui/src/mini/tool.ts b/packages/tui/src/mini/tool.ts index 176fb900ab6..4e44e775b29 100644 --- a/packages/tui/src/mini/tool.ts +++ b/packages/tui/src/mini/tool.ts @@ -26,6 +26,7 @@ import { webSearchProviderLabel, } from "../util/tool-display" import { formatPath } from "../util/path-format" +import { isRecord } from "../util/record" import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types" export { canonicalToolName } from "../util/tool-display" @@ -138,11 +139,7 @@ type ToolRegistry = Record type AnyToolRule = ToolRule function dict(v: unknown): ToolDict { - if (!v || typeof v !== "object" || Array.isArray(v)) { - return {} - } - - return { ...v } + return isRecord(v) ? { ...v } : {} } function props(frame: ToolFrame): ToolProps { diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index bbb1eb380ff..afb6a7b0965 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -63,10 +63,7 @@ export function SubagentsTab(props: { sessionID: string }) { let wasActive = false let scroll: ScrollBoxRenderable | undefined - const selected = createMemo(() => { - return store.selected - }) - const selectedEntry = createMemo(() => entries()[selected()]) + const selectedEntry = createMemo(() => entries()[store.selected]) createEffect(() => { const active = composer.active("subagents") @@ -95,11 +92,7 @@ export function SubagentsTab(props: { sessionID: string }) { function moveTo(next: number, center = false) { setStore("selected", next) - scrollToSelection(center) - } - - function scrollToSelection(center: boolean) { - scrollToIndex(selected(), center) + scrollToIndex(next, center) } function scrollToIndex(index: number, center: boolean) { @@ -209,7 +202,7 @@ export function SubagentsTab(props: { sessionID: string }) { > {(entry, index) => { - const active = createMemo(() => index() === selected()) + const active = createMemo(() => index() === store.selected) const status = createMemo(() => { if (entry.status === "running") return "Running" return "" diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 2cfc352fd6d..c82c7185470 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -108,6 +108,7 @@ import type { SessionInbox } from "@opencode-ai/schema/session-inbox" import { generateThinkingSyntax } from "./thinking-syntax" import { createDelayedPresence } from "../../util/delayed-presence" import { SessionLocationMissing } from "./location-missing" +import { isRecord } from "../../util/record" addDefaultParsers(parsers.parsers) @@ -131,7 +132,6 @@ const context = createContext<{ terminal: { width: number; height: number } sessionID: string thinkingMode: () => ThinkingMode - showThinking: () => boolean markdownMode: () => "source" | "rendered" groupExploration: () => boolean diffWrapMode: () => "word" | "none" @@ -228,7 +228,6 @@ export function Session(props: { verticalTabsWidth: number }) { const sidebar = createMemo(() => config.session?.sidebar ?? "auto") const [sidebarOpen, setSidebarOpen] = createSignal(false) const thinkingMode = createMemo(() => config.session?.thinking ?? "hide") - const showThinking = createMemo(() => true) const showScrollbar = createMemo(() => config.session?.scrollbar ?? false) const markdownMode = createMemo(() => config.session?.markdown ?? "rendered") const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word") @@ -996,7 +995,7 @@ export function Session(props: { verticalTabsWidth: number }) { try { const sessionData = session() if (!sessionData) return - const transcript = formatSessionTranscript(sessionData, messages(), showThinking()) + const transcript = formatSessionTranscript(sessionData, messages(), true) await clipboard.write(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -1017,7 +1016,7 @@ export function Session(props: { verticalTabsWidth: number }) { const sessionData = session() if (!sessionData) return - const options = await DialogExportOptions.show(dialog, showThinking()) + const options = await DialogExportOptions.show(dialog, true) if (options === null) return @@ -1152,7 +1151,6 @@ export function Session(props: { verticalTabsWidth: number }) { }, sessionID: route.sessionID, thinkingMode, - showThinking, markdownMode, groupExploration, diffWrapMode, @@ -3624,8 +3622,7 @@ export function toolDisplay(tool: string) { } function recordValue(value: unknown): Record | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) return - return value as Record + return isRecord(value) ? value : undefined } function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {