refactor(tui): simplify selection helpers (#43972)

This commit is contained in:
Kit Langton 2026-08-21 15:28:17 -04:00 committed by GitHub
parent 238e1903df
commit ed08f0e691
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 63 additions and 147 deletions

View file

@ -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(

View file

@ -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<A>(task: (signal: AbortSignal) => Promise<A>) {
function required<A>(value: A | null | undefined, message: string) {
return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
}
function errorMessage(cause: Cause.Cause<unknown>) {
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)
}

View file

@ -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(

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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))

View file

@ -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)

View file

@ -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

View file

@ -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<RunCommandInput, "format">, message: string, sessionID?: string) {
process.exitCode = 1

View file

@ -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<A, E, R>(effect: Effect.Effect<A, E, R>) {
),
)
}
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)
}

View file

@ -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)
}

View file

@ -33,12 +33,14 @@ describe("debug config command", () => {
},
]
let requested: URL | undefined
let healthProbes = 0
const authorization: Array<string | null> = []
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 })

View file

@ -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")

View file

@ -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)

View file

@ -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<RunInput["model"]>): 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<void> => {
if (!this.options.miniSettings.update) {
this.setNotice("settings are unavailable")

View file

@ -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) => {

View file

@ -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<string, unknown> | undefined {
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record<string, unknown>
return undefined
return isRecord(value) ? value : undefined
}
function text(value: unknown): string | undefined {

View file

@ -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<ToolName, ToolRule>
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 {

View file

@ -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 }) {
>
<For each={entries()}>
{(entry, index) => {
const active = createMemo(() => index() === selected())
const active = createMemo(() => index() === store.selected)
const status = createMemo(() => {
if (entry.status === "running") return "Running"
return ""

View file

@ -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<ThinkingMode>(() => 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<string, unknown> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return
return value as Record<string, unknown>
return isRecord(value) ? value : undefined
}
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {