migrate local IO helpers to a module

This commit is contained in:
Simon Klee
2026-04-18 19:31:35 +02:00
parent af668b33d4
commit 7dc365a9f8
3 changed files with 355 additions and 128 deletions

View File

@@ -5,7 +5,9 @@
// model variant list with context limits, and session history for the prompt
// history ring. All are async because they read config or hit the SDK, but
// none block each other.
import { TuiConfig } from "../tui/config/tui"
import { Context, Effect, Layer } from "effect"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { makeRuntime } from "@/effect/run-service"
import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared"
import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt } from "./types"
@@ -21,12 +23,6 @@ const DEFAULT_KEYBINDS: FooterKeybinds = {
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
}
const configTask: { current?: ReturnType<typeof TuiConfig.get> } = {}
function loadConfig() {
return reusePendingTask(configTask, () => TuiConfig.get())
}
export type ModelInfo = {
variants: string[]
limits: Record<string, number>
@@ -38,45 +34,152 @@ export type SessionInfo = {
variant: string | undefined
}
function modelKey(provider: string, model: string): string {
return `${provider}/${model}`
type Config = Awaited<ReturnType<typeof TuiConfig.get>>
type BootService = {
readonly resolveModelInfo: (sdk: RunInput["sdk"], model: RunInput["model"]) => Effect.Effect<ModelInfo>
readonly resolveSessionInfo: (
sdk: RunInput["sdk"],
sessionID: string,
model: RunInput["model"],
) => Effect.Effect<SessionInfo>
readonly resolveFooterKeybinds: () => Effect.Effect<FooterKeybinds>
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
}
const configTask: { current?: Promise<Config> } = {}
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
function loadConfig() {
return reusePendingTask(configTask, () => TuiConfig.get())
}
function emptyModelInfo(): ModelInfo {
return {
variants: [],
limits: {},
}
}
function emptySessionInfo(): SessionInfo {
return {
first: true,
history: [],
variant: undefined,
}
}
function footerKeybinds(config: Config | undefined): FooterKeybinds {
const leader = config?.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader
const cycle = config?.keybinds?.variant_cycle?.trim() || "ctrl+t"
const interrupt = config?.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt
const previous = config?.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious
const next = config?.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext
const submit = config?.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit
const newline = config?.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline
const bindings = cycle
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
if (!bindings.some((binding) => binding.toLowerCase() === "<leader>t")) {
bindings.push("<leader>t")
}
return {
leader,
variantCycle: bindings.join(","),
interrupt,
historyPrevious: previous,
historyNext: next,
inputSubmit: submit,
inputNewline: newline,
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = Effect.fn("RunBoot.config")(() =>
Effect.promise(loadConfig).pipe(
Effect.orElseSucceed(() => undefined),
),
)
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (sdk: RunInput["sdk"], model: RunInput["model"]) {
const providers = yield* Effect.promise(() => sdk.provider.list()).pipe(
Effect.map((item) => item.data?.all ?? []),
Effect.orElseSucceed(() => []),
)
const limits = Object.fromEntries(
providers.flatMap((provider) =>
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
const limit = info?.limit?.context
if (typeof limit !== "number" || limit <= 0) {
return []
}
return [[`${provider.id}/${modelID}`, limit] as const]
}),
),
)
if (!model) {
return {
variants: [],
limits,
}
}
const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]
return {
variants: Object.keys(info?.variants ?? {}),
limits,
}
})
const resolveSessionInfo = Effect.fn("RunBoot.resolveSessionInfo")(function* (
sdk: RunInput["sdk"],
sessionID: string,
model: RunInput["model"],
) {
const session = yield* Effect.promise(() => resolveSession(sdk, sessionID)).pipe(
Effect.orElseSucceed(() => undefined),
)
if (!session) {
return emptySessionInfo()
}
return {
first: session.first,
history: sessionHistory(session),
variant: pickVariant(model, session),
}
})
const resolveFooterKeybinds = Effect.fn("RunBoot.resolveFooterKeybinds")(function* () {
return footerKeybinds(yield* config())
})
const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
return (yield* config())?.diff_style ?? "auto"
})
return Service.of({
resolveModelInfo,
resolveSessionInfo,
resolveFooterKeybinds,
resolveDiffStyle,
})
}),
)
const runtime = makeRuntime(Service, layer)
// Fetches available variants and context limits for every provider/model pair.
export async function resolveModelInfo(sdk: RunInput["sdk"], model: RunInput["model"]): Promise<ModelInfo> {
try {
const response = await sdk.provider.list()
const providers = response.data?.all ?? []
const limits: Record<string, number> = {}
for (const provider of providers) {
for (const [modelID, info] of Object.entries(provider.models ?? {})) {
const limit = info?.limit?.context
if (typeof limit === "number" && limit > 0) {
limits[modelKey(provider.id, modelID)] = limit
}
}
}
if (!model) {
return {
variants: [],
limits,
}
}
const provider = providers.find((item) => item.id === model.providerID)
const modelInfo = provider?.models?.[model.modelID]
return {
variants: Object.keys(modelInfo?.variants ?? {}),
limits,
}
} catch {
return {
variants: [],
limits: {},
}
}
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, model)).catch(() => emptyModelInfo())
}
// Fetches session messages to determine if this is the first turn and build prompt history.
@@ -85,63 +188,15 @@ export async function resolveSessionInfo(
sessionID: string,
model: RunInput["model"],
): Promise<SessionInfo> {
try {
const session = await resolveSession(sdk, sessionID)
return {
first: session.first,
history: sessionHistory(session),
variant: pickVariant(model, session),
}
} catch {
return {
first: true,
history: [],
variant: undefined,
}
}
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
}
// Reads keybind overrides from TUI config and merges them with defaults.
// Always ensures <leader>t is present in the variant cycle binding.
export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
try {
const config = await loadConfig()
const configuredLeader = config.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader
const configuredVariantCycle = config.keybinds?.variant_cycle?.trim() || "ctrl+t"
const configuredInterrupt = config.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt
const configuredHistoryPrevious = config.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious
const configuredHistoryNext = config.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext
const configuredSubmit = config.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit
const configuredNewline = config.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline
const variantBindings = configuredVariantCycle
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
if (!variantBindings.some((binding) => binding.toLowerCase() === "<leader>t")) {
variantBindings.push("<leader>t")
}
return {
leader: configuredLeader,
variantCycle: variantBindings.join(","),
interrupt: configuredInterrupt,
historyPrevious: configuredHistoryPrevious,
historyNext: configuredHistoryNext,
inputSubmit: configuredSubmit,
inputNewline: configuredNewline,
}
} catch {
return DEFAULT_KEYBINDS
}
return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS)
}
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
try {
const config = await loadConfig()
return config.diff_style ?? "auto"
} catch {
return "auto"
}
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
}

View File

@@ -7,16 +7,29 @@
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
// variant and the persisted file.
import path from "path"
import { Global } from "../../../global"
import * as Filesystem from "../../../util/filesystem"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Context, Effect, Layer } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@/global"
import { isRecord } from "@/util/record"
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
import type { RunInput } from "./types"
const MODEL_FILE = path.join(Global.Path.state, "model.json")
type ModelState = {
type ModelState = Record<string, unknown> & {
variant?: Record<string, string | undefined>
}
type VariantService = {
readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined>
readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void>
}
type VariantRuntime = {
resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined>
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
}
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
function modelKey(provider: string, model: string): string {
return `${provider}/${model}`
@@ -86,41 +99,102 @@ export function resolveVariant(
return fallback
}
export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
if (!model) {
return undefined
function state(value: unknown): ModelState {
if (!isRecord(value)) {
return {}
}
try {
const state = await Filesystem.readJson<ModelState>(MODEL_FILE)
return state.variant?.[variantKey(model)]
} catch {
return undefined
const variant = isRecord(value.variant)
? Object.fromEntries(
Object.entries(value.variant).flatMap(([key, item]) => {
if (typeof item !== "string") {
return []
}
return [[key, item] as const]
}),
)
: undefined
return {
...value,
variant,
}
}
function createLayer(fs = AppFileSystem.defaultLayer) {
return Layer.fresh(
Layer.effect(
Service,
Effect.gen(function* () {
const file = yield* AppFileSystem.Service
const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe(
Effect.map(state),
Effect.catchCause(() => Effect.succeed({})),
)
})
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
if (!model) {
return undefined
}
return (yield* read()).variant?.[variantKey(model)]
})
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
model: RunInput["model"],
variant: string | undefined,
) {
if (!model) {
return
}
const current = yield* read()
const next = {
...(current.variant ?? {}),
}
const key = variantKey(model)
if (variant) {
next[key] = variant
}
if (!variant) {
delete next[key]
}
yield* file.writeJson(MODEL_FILE, {
...current,
variant: next,
}).pipe(Effect.orElseSucceed(() => undefined))
})
return Service.of({
resolveSavedVariant,
saveVariant,
})
}),
).pipe(Layer.provide(fs)),
)
}
/** @internal Exported for testing. */
export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime {
const runtime = makeRuntime(Service, createLayer(fs))
return {
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
}
}
const runtime = createVariantRuntime()
export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
return runtime.resolveSavedVariant(model)
}
export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
if (!model) {
return
}
void (async () => {
const state = await Filesystem.readJson<ModelState>(MODEL_FILE).catch(() => ({}) as ModelState)
const map = {
...(state.variant ?? {}),
}
const key = variantKey(model)
if (variant) {
map[key] = variant
}
if (!variant) {
delete map[key]
}
await Filesystem.writeJson(MODEL_FILE, {
...state,
variant: map,
})
})().catch(() => {})
void runtime.saveVariant(model, variant)
}

View File

@@ -1,12 +1,52 @@
import path from "path"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { describe, expect, test } from "bun:test"
import { cycleVariant, formatModelLabel, pickVariant, resolveVariant } from "../../../src/cli/cmd/run/variant.shared"
import { Effect, FileSystem, Layer } from "effect"
import { Global } from "../../../src/global"
import {
createVariantRuntime,
cycleVariant,
formatModelLabel,
pickVariant,
resolveVariant,
} from "../../../src/cli/cmd/run/variant.shared"
import type { SessionMessages } from "../../../src/cli/cmd/run/session.shared"
import { testEffect } from "../../lib/effect"
const model = {
providerID: "openai",
modelID: "gpt-5",
}
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, NodeFileSystem.layer))
function remap(root: string, file: string) {
if (file === Global.Path.state) {
return root
}
if (file.startsWith(Global.Path.state + path.sep)) {
return path.join(root, path.relative(Global.Path.state, file))
}
return file
}
function remappedFs(root: string) {
return Layer.effect(
AppFileSystem.Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return AppFileSystem.Service.of({
...fs,
readJson: (file) => fs.readJson(remap(root, file)),
writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
})
}),
).pipe(Layer.provide(AppFileSystem.defaultLayer))
}
describe("run variant shared", () => {
test("prefers cli then session then saved variants", () => {
expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max")
@@ -65,4 +105,62 @@ describe("run variant shared", () => {
expect(pickVariant(model, msgs)).toBe("minimal")
})
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* fs.writeJson(file, {
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
"openai/gpt-5": "high",
},
})
yield* Effect.promise(() => svc.saveVariant(model, undefined))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBeUndefined()
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
}),
)
it.live("repairs malformed saved variant state on the next write", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* filesys.writeFileString(file, "{")
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
variant: {
"openai/gpt-5": "high",
},
})
}),
)
})