mini: fix default model (#35515)
This commit is contained in:
@@ -4,7 +4,9 @@ import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
|
|||||||
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
|
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
|
||||||
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
|
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
|
||||||
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
|
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
|
||||||
type CurrentProvider = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]>["data"][number]
|
type CurrentProvider = NonNullable<
|
||||||
|
Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]
|
||||||
|
>["data"][number]
|
||||||
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
|
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
|
||||||
|
|
||||||
function location(directory: string) {
|
function location(directory: string) {
|
||||||
@@ -105,6 +107,23 @@ export async function waitForCatalogReady(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForDefaultModel(input: {
|
||||||
|
sdk: OpencodeClient
|
||||||
|
directory: string
|
||||||
|
timeoutMs?: number
|
||||||
|
active?: () => boolean
|
||||||
|
}): Promise<{ providerID: string; modelID: string } | undefined> {
|
||||||
|
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
||||||
|
while (Date.now() < deadline && (input.active?.() ?? true)) {
|
||||||
|
const model = await input.sdk.v2.model
|
||||||
|
.default(location(input.directory), { throwOnError: true })
|
||||||
|
.then((result) => result.data?.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (model) return { providerID: model.providerID, modelID: model.id }
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
|
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
|
||||||
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
|
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
|
||||||
return (result.data?.data ?? []).map(runAgent)
|
return (result.data?.data ?? []).map(runAgent)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { pathToFileURL } from "bun"
|
import { pathToFileURL } from "bun"
|
||||||
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||||
import { useRenderer } from "@opentui/solid"
|
import { useRenderer } from "@opentui/solid"
|
||||||
import { normalizePromptContent } from "@opencode-ai/tui/editor"
|
import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
|
|||||||
@@ -390,6 +390,10 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public event(next: FooterEvent): void {
|
public event(next: FooterEvent): void {
|
||||||
|
if (next.type === "model") {
|
||||||
|
this.setCurrentModel(next.selection)
|
||||||
|
}
|
||||||
|
|
||||||
if (next.type === "turn.duration") {
|
if (next.type === "turn.duration") {
|
||||||
const current = this.currentModel()
|
const current = this.currentModel()
|
||||||
this.flush()
|
this.flush()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type ModelInfo = {
|
|||||||
export type SessionInfo = {
|
export type SessionInfo = {
|
||||||
first: boolean
|
first: boolean
|
||||||
history: RunPrompt[]
|
history: RunPrompt[]
|
||||||
|
model?: NonNullable<RunInput["model"]>
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +142,8 @@ const layer = Layer.effect(
|
|||||||
return {
|
return {
|
||||||
first: session.first,
|
first: session.first,
|
||||||
history: sessionHistory(session),
|
history: sessionHistory(session),
|
||||||
variant: pickVariant(model, session),
|
model: session.model,
|
||||||
|
variant: pickVariant(model ?? session.model, session),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ import path from "path"
|
|||||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { openEditor } from "@opencode-ai/tui/editor"
|
|
||||||
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||||
import { Session as SessionApi } from "@/session/session"
|
import { isDefaultTitle } from "@/session/title"
|
||||||
import * as Locale from "@/util/locale"
|
import * as Locale from "@/util/locale"
|
||||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
import { resolveInteractiveStdin } from "./runtime.stdin"
|
||||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||||
@@ -64,7 +63,7 @@ export type LifecycleInput = {
|
|||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
model: RunInput["model"]
|
model: RunInput["model"]
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
tuiConfig: RunTuiConfig
|
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
|
||||||
backgroundSubagents: boolean
|
backgroundSubagents: boolean
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||||
@@ -108,7 +107,7 @@ function shutdown(renderer: CliRenderer): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
||||||
if (title && !SessionApi.isDefaultTitle(title)) {
|
if (title && !isDefaultTitle(title)) {
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
showSession: true,
|
showSession: true,
|
||||||
@@ -124,17 +123,9 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
|||||||
|
|
||||||
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
||||||
const agentLabel = Locale.titlecase(input.agent ?? "build")
|
const agentLabel = Locale.titlecase(input.agent ?? "build")
|
||||||
|
|
||||||
if (!input.model) {
|
|
||||||
return {
|
|
||||||
agentLabel,
|
|
||||||
modelLabel: "Model default",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agentLabel,
|
agentLabel,
|
||||||
modelLabel: formatModelLabel(input.model, input.variant),
|
modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +167,7 @@ function queueSplash(
|
|||||||
// the entry splash, RunFooter takes over the footer region.
|
// the entry splash, RunFooter takes over the footer region.
|
||||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
||||||
const source = resolveInteractiveStdin()
|
const source = resolveInteractiveStdin()
|
||||||
|
const footerTask = import("./footer")
|
||||||
let unregisterKeymap: (() => void) | undefined
|
let unregisterKeymap: (() => void) | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -194,10 +186,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
consoleMode: "disabled",
|
consoleMode: "disabled",
|
||||||
clearOnShutdown: false,
|
clearOnShutdown: false,
|
||||||
})
|
})
|
||||||
const theme = await resolveRunTheme(renderer)
|
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
||||||
renderer.setBackgroundColor(theme.background)
|
renderer.setBackgroundColor(theme.background)
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig)
|
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
||||||
const state: SplashState = {
|
const state: SplashState = {
|
||||||
entry: false,
|
entry: false,
|
||||||
exit: false,
|
exit: false,
|
||||||
@@ -212,7 +204,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
model: input.model,
|
model: input.model,
|
||||||
variant: input.variant,
|
variant: input.variant,
|
||||||
})
|
})
|
||||||
const footerTask = import("./footer")
|
|
||||||
const wrote = queueSplash(
|
const wrote = queueSplash(
|
||||||
renderer,
|
renderer,
|
||||||
state,
|
state,
|
||||||
@@ -244,9 +235,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
theme,
|
theme,
|
||||||
wrote,
|
wrote,
|
||||||
keymap,
|
keymap,
|
||||||
tuiConfig: input.tuiConfig,
|
tuiConfig,
|
||||||
backgroundSubagents: input.backgroundSubagents,
|
backgroundSubagents: input.backgroundSubagents,
|
||||||
diffStyle: input.tuiConfig.diff_style ?? "auto",
|
diffStyle: tuiConfig.diff_style ?? "auto",
|
||||||
onPermissionReply: input.onPermissionReply,
|
onPermissionReply: input.onPermissionReply,
|
||||||
onQuestionReply: input.onQuestionReply,
|
onQuestionReply: input.onQuestionReply,
|
||||||
onQuestionReject: input.onQuestionReject,
|
onQuestionReject: input.onQuestionReject,
|
||||||
@@ -260,6 +251,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { openEditor } = await import("@opencode-ai/tui/editor")
|
||||||
await renderer.idle().catch(() => {})
|
await renderer.idle().catch(() => {})
|
||||||
const ignore = () => {}
|
const ignore = () => {}
|
||||||
detachSigint()
|
detachSigint()
|
||||||
|
|||||||
@@ -15,8 +15,7 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { MessageID } from "@/session/schema"
|
import { MessageID } from "@/session/schema"
|
||||||
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
|
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
|
||||||
import { createRunDemo } from "./demo"
|
|
||||||
import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
||||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||||
import { trace } from "./trace"
|
import { trace } from "./trace"
|
||||||
@@ -91,6 +90,8 @@ type StreamState = {
|
|||||||
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
|
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]>
|
||||||
|
|
||||||
type ResolvedSession = {
|
type ResolvedSession = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
@@ -130,7 +131,7 @@ type RuntimeState = {
|
|||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
switching?: Promise<void>
|
switching?: Promise<void>
|
||||||
demo?: ReturnType<typeof createRunDemo>
|
demo?: RunDemo
|
||||||
selectSubagent?: (sessionID: string | undefined) => void
|
selectSubagent?: (sessionID: string | undefined) => void
|
||||||
session?: Promise<void>
|
session?: Promise<void>
|
||||||
stream?: Promise<StreamState>
|
stream?: Promise<StreamState>
|
||||||
@@ -181,21 +182,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
const log = trace()
|
const log = trace()
|
||||||
const tuiConfigTask = resolveRunTuiConfig()
|
const tuiConfigTask = resolveRunTuiConfig()
|
||||||
const ctx = await input.boot()
|
const ctx = await input.boot()
|
||||||
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
|
|
||||||
const sessionTask =
|
const sessionTask =
|
||||||
ctx.resume === true
|
ctx.resume === true
|
||||||
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
||||||
: Promise.resolve({
|
: Promise.resolve({
|
||||||
first: true,
|
first: true,
|
||||||
history: [],
|
history: [],
|
||||||
|
model: undefined,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
})
|
})
|
||||||
const savedTask = resolveSavedVariant(ctx.model)
|
const savedTask = resolveSavedVariant(ctx.model)
|
||||||
const [tuiConfig, session, savedVariant] = await Promise.all([tuiConfigTask, sessionTask, savedTask])
|
const [session, savedVariant] = await Promise.all([sessionTask, savedTask])
|
||||||
const state: RuntimeState = {
|
const state: RuntimeState = {
|
||||||
shown: !session.first,
|
shown: !session.first,
|
||||||
aborting: false,
|
aborting: false,
|
||||||
model: ctx.model,
|
model: ctx.model ?? session.model,
|
||||||
providers: [],
|
providers: [],
|
||||||
variants: [],
|
variants: [],
|
||||||
limits: {},
|
limits: {},
|
||||||
@@ -206,23 +207,43 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
sessionTitle: ctx.sessionTitle,
|
sessionTitle: ctx.sessionTitle,
|
||||||
agent: ctx.agent,
|
agent: ctx.agent,
|
||||||
}
|
}
|
||||||
const ensureSession = () => {
|
const loadModel = async () => {
|
||||||
if (!input.resolveSession || state.sessionID) {
|
if (state.model) {
|
||||||
return Promise.resolve()
|
return {
|
||||||
|
model: state.model,
|
||||||
|
savedVariant,
|
||||||
|
boot: true,
|
||||||
|
info: await resolveModelInfo(ctx.sdk, ctx.directory, state.model),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.session) {
|
const model = await waitForDefaultModel({
|
||||||
return state.session
|
sdk: ctx.sdk,
|
||||||
}
|
directory: ctx.directory,
|
||||||
|
active: () => !footer.isClosed,
|
||||||
state.session = input.resolveSession(ctx).then((next) => {
|
|
||||||
state.sessionID = next.sessionID
|
|
||||||
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
|
|
||||||
state.agent = next.agent
|
|
||||||
})
|
})
|
||||||
return state.session
|
if (footer.isClosed) return
|
||||||
}
|
const [fallbackSavedVariant, info] = await Promise.all([
|
||||||
|
resolveSavedVariant(model),
|
||||||
|
resolveModelInfo(ctx.sdk, ctx.directory, model),
|
||||||
|
])
|
||||||
|
if (!model || state.model) {
|
||||||
|
return {
|
||||||
|
model: state.model,
|
||||||
|
savedVariant: undefined,
|
||||||
|
boot: false,
|
||||||
|
info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.model = model
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
savedVariant: fallbackSavedVariant,
|
||||||
|
boot: true,
|
||||||
|
info,
|
||||||
|
}
|
||||||
|
}
|
||||||
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
|
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
findFiles: (query) =>
|
findFiles: (query) =>
|
||||||
@@ -240,7 +261,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
agent: state.agent,
|
agent: state.agent,
|
||||||
model: state.model,
|
model: state.model,
|
||||||
variant: state.activeVariant,
|
variant: state.activeVariant,
|
||||||
tuiConfig,
|
tuiConfig: tuiConfigTask,
|
||||||
backgroundSubagents: input.backgroundSubagents,
|
backgroundSubagents: input.backgroundSubagents,
|
||||||
onPermissionReply: async (next) => {
|
onPermissionReply: async (next) => {
|
||||||
if (state.demo?.permission(next)) {
|
if (state.demo?.permission(next)) {
|
||||||
@@ -345,9 +366,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.aborting = true
|
state.aborting = true
|
||||||
void (state.stream
|
void (
|
||||||
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
state.stream
|
||||||
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }))
|
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
||||||
|
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })
|
||||||
|
)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
state.aborting = false
|
state.aborting = false
|
||||||
@@ -374,6 +397,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const footer = shell.footer
|
const footer = shell.footer
|
||||||
|
const firstPaint = footer.idle().catch(() => {})
|
||||||
|
const modelTask = firstPaint.then(() => (footer.isClosed ? undefined : loadModel()))
|
||||||
|
const ensureSession = () => {
|
||||||
|
if (!input.resolveSession || state.sessionID) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.session) {
|
||||||
|
return state.session
|
||||||
|
}
|
||||||
|
|
||||||
|
state.session = input.resolveSession(ctx).then((next) => {
|
||||||
|
state.sessionID = next.sessionID
|
||||||
|
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
|
||||||
|
state.agent = next.agent
|
||||||
|
})
|
||||||
|
return state.session
|
||||||
|
}
|
||||||
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
|
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
|
||||||
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
|
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
|
||||||
}
|
}
|
||||||
@@ -417,12 +458,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
info: Awaited<ReturnType<typeof resolveModelInfo>>,
|
info: Awaited<ReturnType<typeof resolveModelInfo>>,
|
||||||
current: string | undefined,
|
current: string | undefined,
|
||||||
boot = false,
|
boot = false,
|
||||||
|
saved = savedVariant,
|
||||||
) => {
|
) => {
|
||||||
state.providers = info.providers
|
state.providers = info.providers
|
||||||
state.variants = variantsFor(state.providers, state.model)
|
state.variants = variantsFor(state.providers, state.model)
|
||||||
state.limits = info.limits
|
state.limits = info.limits
|
||||||
state.activeVariant = boot
|
state.activeVariant = boot
|
||||||
? resolveVariant(ctx.variant, current, savedVariant, state.variants)
|
? resolveVariant(ctx.variant, current, saved, state.variants)
|
||||||
: current && !state.variants.includes(current)
|
: current && !state.variants.includes(current)
|
||||||
? undefined
|
? undefined
|
||||||
: current
|
: current
|
||||||
@@ -430,7 +472,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
footer.event({ type: "models", providers: info.providers })
|
footer.event({ type: "models", providers: info.providers })
|
||||||
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
|
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
|
||||||
if (state.model)
|
if (state.model)
|
||||||
footer.event({ type: "model", model: formatModelLabel(state.model, state.activeVariant, state.providers) })
|
footer.event({
|
||||||
|
type: "model",
|
||||||
|
model: formatModelLabel(state.model, state.activeVariant, state.providers),
|
||||||
|
selection: state.model,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let catalogRefresh: Promise<void> | undefined
|
let catalogRefresh: Promise<void> | undefined
|
||||||
@@ -456,24 +502,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
void catalogRefresh.catch(() => {})
|
void catalogRefresh.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialCatalog = footer
|
const initialCatalog = firstPaint.then(() => (footer.isClosed ? undefined : loadCatalog())).catch(() => {})
|
||||||
.idle()
|
|
||||||
.then(loadCatalog)
|
|
||||||
.catch(() => {})
|
|
||||||
void initialCatalog
|
void initialCatalog
|
||||||
|
|
||||||
if (Flag.OPENCODE_SHOW_TTFD) {
|
if (Flag.OPENCODE_SHOW_TTFD) {
|
||||||
footer.append({
|
void firstPaint.then(() => {
|
||||||
kind: "system",
|
if (footer.isClosed) return
|
||||||
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
|
footer.append({
|
||||||
phase: "final",
|
kind: "system",
|
||||||
source: "system",
|
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
|
||||||
|
phase: "final",
|
||||||
|
source: "system",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.demo) {
|
const createDemo = async () => {
|
||||||
await ensureSession()
|
const { createRunDemo } = await import("./demo")
|
||||||
state.demo = createRunDemo({
|
return createRunDemo({
|
||||||
footer,
|
footer,
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
@@ -481,13 +527,35 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.afterPaint) {
|
if (input.demo) {
|
||||||
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
|
await firstPaint
|
||||||
|
if (!footer.isClosed) {
|
||||||
|
await ensureSession()
|
||||||
|
state.demo = await createDemo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void modelTask.then((info) => applyModelInfo(info, session.variant, true))
|
if (input.afterPaint) {
|
||||||
|
void firstPaint.then(() => (footer.isClosed ? undefined : input.afterPaint?.(ctx))).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const streamTask = deps.streamTransport ?? import("./stream-v2.transport")
|
void modelTask.then((result) => {
|
||||||
|
if (!result) return
|
||||||
|
const current = state.model
|
||||||
|
const boot =
|
||||||
|
result.boot &&
|
||||||
|
!!current &&
|
||||||
|
current.providerID === result.model?.providerID &&
|
||||||
|
current.modelID === result.model.modelID
|
||||||
|
applyModelInfo(result.info, boot ? session.variant : state.activeVariant, boot, result.savedVariant)
|
||||||
|
})
|
||||||
|
|
||||||
|
let streamTask = deps.streamTransport
|
||||||
|
const loadStreamTransport = () => {
|
||||||
|
if (streamTask) return streamTask
|
||||||
|
streamTask = import("./stream-v2.transport")
|
||||||
|
return streamTask
|
||||||
|
}
|
||||||
const ensureStream = () => {
|
const ensureStream = () => {
|
||||||
if (state.stream) {
|
if (state.stream) {
|
||||||
return state.stream
|
return state.stream
|
||||||
@@ -501,7 +569,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
throw new Error("runtime closed")
|
throw new Error("runtime closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
const mod = await streamTask
|
const mod = await loadStreamTransport()
|
||||||
if (footer.isClosed) {
|
if (footer.isClosed) {
|
||||||
throw new Error("runtime closed")
|
throw new Error("runtime closed")
|
||||||
}
|
}
|
||||||
@@ -570,6 +638,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
})
|
})
|
||||||
|
|
||||||
const runQueue = async () => {
|
const runQueue = async () => {
|
||||||
|
await firstPaint
|
||||||
|
if (footer.isClosed) return
|
||||||
let includeFiles = true
|
let includeFiles = true
|
||||||
if (state.demo) {
|
if (state.demo) {
|
||||||
await state.demo.start()
|
await state.demo.start()
|
||||||
@@ -615,14 +685,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
state.history = []
|
state.history = []
|
||||||
state.localRows = []
|
state.localRows = []
|
||||||
includeFiles = true
|
includeFiles = true
|
||||||
state.demo = input.demo
|
state.demo = input.demo ? await createDemo() : undefined
|
||||||
? createRunDemo({
|
|
||||||
footer,
|
|
||||||
sessionID: state.sessionID,
|
|
||||||
thinking: input.thinking,
|
|
||||||
limits: () => state.limits,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
log?.write("session.new", {
|
log?.write("session.new", {
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
})
|
})
|
||||||
@@ -727,6 +790,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
try {
|
try {
|
||||||
const eager = eagerStream(input, ctx)
|
const eager = eagerStream(input, ctx)
|
||||||
if (eager) {
|
if (eager) {
|
||||||
|
await firstPaint
|
||||||
|
if (footer.isClosed) return
|
||||||
if (input.replay && state.shown) {
|
if (input.replay && state.shown) {
|
||||||
// Replay commits immutable scrollback rows, so wait for provider names
|
// Replay commits immutable scrollback rows, so wait for provider names
|
||||||
// before bootstrapping existing session history.
|
// before bootstrapping existing session history.
|
||||||
@@ -737,13 +802,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!eager && input.resolveSession) {
|
if (!eager && input.resolveSession) {
|
||||||
queueMicrotask(() => {
|
void firstPaint
|
||||||
if (footer.isClosed) {
|
.then(() => {
|
||||||
return
|
if (footer.isClosed) {
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
void ensureStream().catch(() => {})
|
return ensureStream()
|
||||||
})
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export class RunScrollbackStream {
|
|||||||
) {
|
) {
|
||||||
this.diffStyle = options.diffStyle
|
this.diffStyle = options.diffStyle
|
||||||
this.sessionID = options.sessionID
|
this.sessionID = options.sessionID
|
||||||
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
|
this.treeSitterClient = options.treeSitterClient
|
||||||
this.wrote = options.wrote ?? false
|
this.wrote = options.wrote ?? false
|
||||||
this.onThemeRelease = options.onThemeRelease
|
this.onThemeRelease = options.onThemeRelease
|
||||||
}
|
}
|
||||||
@@ -151,6 +151,7 @@ export class RunScrollbackStream {
|
|||||||
startOnNewLine: entryFlags(commit).startOnNewLine,
|
startOnNewLine: entryFlags(commit).startOnNewLine,
|
||||||
})
|
})
|
||||||
const style = entryLook(commit, this.theme.entry)
|
const style = entryLook(commit, this.theme.entry)
|
||||||
|
const treeSitterClient = body.type === "text" ? undefined : (this.treeSitterClient ??= getTreeSitterClient())
|
||||||
const renderable =
|
const renderable =
|
||||||
body.type === "text"
|
body.type === "text"
|
||||||
? new TextRenderable(surface.renderContext, {
|
? new TextRenderable(surface.renderContext, {
|
||||||
@@ -170,7 +171,7 @@ export class RunScrollbackStream {
|
|||||||
drawUnstyledText: false,
|
drawUnstyledText: false,
|
||||||
streaming: true,
|
streaming: true,
|
||||||
fg: entryColor(commit, this.theme),
|
fg: entryColor(commit, this.theme),
|
||||||
treeSitterClient: this.treeSitterClient,
|
treeSitterClient,
|
||||||
})
|
})
|
||||||
: new MarkdownRenderable(surface.renderContext, {
|
: new MarkdownRenderable(surface.renderContext, {
|
||||||
content: "",
|
content: "",
|
||||||
@@ -180,7 +181,7 @@ export class RunScrollbackStream {
|
|||||||
internalBlockMode: "top-level",
|
internalBlockMode: "top-level",
|
||||||
tableOptions: { widthMode: "content" },
|
tableOptions: { widthMode: "content" },
|
||||||
fg: entryColor(commit, this.theme),
|
fg: entryColor(commit, this.theme),
|
||||||
treeSitterClient: this.treeSitterClient,
|
treeSitterClient,
|
||||||
})
|
})
|
||||||
|
|
||||||
surface.root.add(renderable)
|
surface.root.add(renderable)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type Turn = {
|
|||||||
export type RunSession = {
|
export type RunSession = {
|
||||||
first: boolean
|
first: boolean
|
||||||
turns: Turn[]
|
turns: Turn[]
|
||||||
|
model?: NonNullable<RunInput["model"]>
|
||||||
|
variant?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileName(url: string, filename?: string) {
|
function fileName(url: string, filename?: string) {
|
||||||
@@ -157,9 +159,11 @@ export async function resolveCurrentSession(
|
|||||||
sessionID: string,
|
sessionID: string,
|
||||||
limit = LIMIT,
|
limit = LIMIT,
|
||||||
): Promise<RunSession> {
|
): Promise<RunSession> {
|
||||||
const response = await sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true })
|
const [response, session] = await Promise.all([
|
||||||
|
sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true }),
|
||||||
|
sdk.v2.session.get({ sessionID }, { throwOnError: true }),
|
||||||
|
])
|
||||||
const messages = response.data.data.toReversed()
|
const messages = response.data.data.toReversed()
|
||||||
const session = await sdk.v2.session.get({ sessionID }, { throwOnError: true })
|
|
||||||
return {
|
return {
|
||||||
first: messages.length === 0,
|
first: messages.length === 0,
|
||||||
turns: messages.flatMap((message) => {
|
turns: messages.flatMap((message) => {
|
||||||
@@ -197,6 +201,13 @@ export async function resolveCurrentSession(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
|
...(session.data.data.model && {
|
||||||
|
model: {
|
||||||
|
providerID: session.data.data.model.providerID,
|
||||||
|
modelID: session.data.data.model.id,
|
||||||
|
},
|
||||||
|
variant: session.data.data.model.variant,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +234,10 @@ export function sessionVariant(session: RunSession, model: RunInput["model"]): s
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) {
|
||||||
|
return session.variant
|
||||||
|
}
|
||||||
|
|
||||||
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
|
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
|
||||||
const turn = session.turns[idx]
|
const turn = session.turns[idx]
|
||||||
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
|
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ export type FooterEvent =
|
|||||||
| {
|
| {
|
||||||
type: "model"
|
type: "model"
|
||||||
model: string
|
model: string
|
||||||
|
selection: NonNullable<RunInput["model"]>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "turn.send"
|
type: "turn.send"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export { isDefaultTitle } from "./title"
|
||||||
|
import { createDefaultTitle } from "./title"
|
||||||
|
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||||
import { Slug } from "@opencode-ai/core/util/slug"
|
import { Slug } from "@opencode-ai/core/util/slug"
|
||||||
@@ -43,15 +46,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
|
|
||||||
const parentTitlePrefix = "New session - "
|
|
||||||
const childTitlePrefix = "Child session - "
|
|
||||||
|
|
||||||
export function isDefaultTitle(title: string) {
|
|
||||||
return new RegExp(
|
|
||||||
`^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`,
|
|
||||||
).test(title)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionRow = typeof SessionTable.$inferSelect
|
type SessionRow = typeof SessionTable.$inferSelect
|
||||||
|
|
||||||
export function fromRow(row: SessionRow): Info {
|
export function fromRow(row: SessionRow): Info {
|
||||||
@@ -518,7 +512,7 @@ const layer: Layer.Layer<
|
|||||||
path: input.path,
|
path: input.path,
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
parentID: input.parentID,
|
parentID: input.parentID,
|
||||||
title: input.title ?? (input.parentID ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString(),
|
title: input.title ?? createDefaultTitle(!!input.parentID),
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const parentTitlePrefix = "New session - "
|
||||||
|
const childTitlePrefix = "Child session - "
|
||||||
|
const defaultTitle = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||||
|
|
||||||
|
export function createDefaultTitle(child: boolean) {
|
||||||
|
return (child ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDefaultTitle(title: string) {
|
||||||
|
return defaultTitle.test(title)
|
||||||
|
}
|
||||||
@@ -1,12 +1,37 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpencodeClient } from "@opencode-ai/sdk/v2"
|
import { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { loadRunReferences, runProviders } from "@/cli/cmd/run/catalog.shared"
|
import { loadRunReferences, runProviders, waitForDefaultModel } from "@/cli/cmd/run/catalog.shared"
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("run catalog shared", () => {
|
describe("run catalog shared", () => {
|
||||||
|
test("resolves the catalog-selected model for the footer", async () => {
|
||||||
|
const client = new OpencodeClient()
|
||||||
|
const selected = spyOn(client.v2.model, "default").mockImplementation(
|
||||||
|
() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: {
|
||||||
|
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
|
||||||
|
data: {
|
||||||
|
id: "gpt-5",
|
||||||
|
providerID: "openai",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: undefined,
|
||||||
|
request: new Request("https://opencode.test"),
|
||||||
|
response: new Response(),
|
||||||
|
}) as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(waitForDefaultModel({ sdk: client, directory: "/tmp" })).resolves.toEqual({
|
||||||
|
providerID: "openai",
|
||||||
|
modelID: "gpt-5",
|
||||||
|
})
|
||||||
|
expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true })
|
||||||
|
})
|
||||||
|
|
||||||
test("loads visible project references from the current reference catalog", async () => {
|
test("loads visible project references from the current reference catalog", async () => {
|
||||||
const client = new OpencodeClient()
|
const client = new OpencodeClient()
|
||||||
const list = spyOn(client.v2.reference, "list").mockImplementation(
|
const list = spyOn(client.v2.reference, "list").mockImplementation(
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ describe("run interactive runtime", () => {
|
|||||||
test("waits for provider metadata before eager replay transport bootstrap", async () => {
|
test("waits for provider metadata before eager replay transport bootstrap", async () => {
|
||||||
const providersStarted = defer<void>()
|
const providersStarted = defer<void>()
|
||||||
const providers = defer<void>()
|
const providers = defer<void>()
|
||||||
|
const lifecycleModels: unknown[] = []
|
||||||
|
|
||||||
const sdk = new OpencodeClient()
|
const sdk = new OpencodeClient()
|
||||||
const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused"))
|
const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused"))
|
||||||
@@ -249,23 +250,23 @@ describe("run interactive runtime", () => {
|
|||||||
replay: true,
|
replay: true,
|
||||||
replayLimit: 100,
|
replayLimit: 100,
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: {
|
model: undefined,
|
||||||
providerID: "openai",
|
|
||||||
modelID: "gpt-5",
|
|
||||||
},
|
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
files: [],
|
files: [],
|
||||||
thinking: true,
|
thinking: true,
|
||||||
backgroundSubagents: false,
|
backgroundSubagents: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
createRuntimeLifecycle: async () => ({
|
createRuntimeLifecycle: async (input) => {
|
||||||
footer: footer(),
|
lifecycleModels.push(input.model)
|
||||||
onResize: () => () => {},
|
return {
|
||||||
refreshTheme: () => {},
|
footer: footer(),
|
||||||
resetForReplay: () => Promise.resolve(),
|
onResize: () => () => {},
|
||||||
close: () => Promise.resolve(),
|
refreshTheme: () => {},
|
||||||
}),
|
resetForReplay: () => Promise.resolve(),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
}
|
||||||
|
},
|
||||||
streamTransport: Promise.resolve({
|
streamTransport: Promise.resolve({
|
||||||
createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => {
|
createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => {
|
||||||
transportProviders.push(input.providers?.() ?? [])
|
transportProviders.push(input.providers?.() ?? [])
|
||||||
@@ -293,12 +294,156 @@ describe("run interactive runtime", () => {
|
|||||||
|
|
||||||
await task
|
await task
|
||||||
|
|
||||||
|
expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }])
|
||||||
expect(transportProviders).toEqual([[provider]])
|
expect(transportProviders).toEqual([[provider]])
|
||||||
expect(legacyProviders).not.toHaveBeenCalled()
|
expect(legacyProviders).not.toHaveBeenCalled()
|
||||||
expect(legacyAgents).not.toHaveBeenCalled()
|
expect(legacyAgents).not.toHaveBeenCalled()
|
||||||
expect(legacyCommands).not.toHaveBeenCalled()
|
expect(legacyCommands).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("defers catalog-selected model resolution until after first paint", async () => {
|
||||||
|
const sdk = new OpencodeClient()
|
||||||
|
const defaultStarted = defer<void>()
|
||||||
|
const releaseDefault = defer<void>()
|
||||||
|
const lifecycleStarted = defer<void>()
|
||||||
|
const painted = defer<void>()
|
||||||
|
const modelShown = defer<void>()
|
||||||
|
let defaultRequested = false
|
||||||
|
const events: FooterEvent[] = []
|
||||||
|
const api = footer(events)
|
||||||
|
api.idle = () => painted.promise
|
||||||
|
const event = api.event
|
||||||
|
api.event = (value) => {
|
||||||
|
event(value)
|
||||||
|
if (value.type !== "model") return
|
||||||
|
modelShown.resolve()
|
||||||
|
api.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
spyOn(sdk.v2.model, "default").mockImplementation(async () => {
|
||||||
|
defaultRequested = true
|
||||||
|
defaultStarted.resolve()
|
||||||
|
await releaseDefault.promise
|
||||||
|
return ok({
|
||||||
|
location: { directory: "/tmp" },
|
||||||
|
data: { id: "gpt-5", providerID: "openai" },
|
||||||
|
}) as never
|
||||||
|
})
|
||||||
|
spyOn(sdk.v2.provider, "list").mockImplementation(() =>
|
||||||
|
ok({ location: { directory: "/tmp" }, data: [] }) as never,
|
||||||
|
)
|
||||||
|
spyOn(sdk.v2.model, "list").mockImplementation(() =>
|
||||||
|
ok({ location: { directory: "/tmp" }, data: [] }) as never,
|
||||||
|
)
|
||||||
|
spyOn(sdk.v2.agent, "list").mockImplementation(() =>
|
||||||
|
ok({ location: { directory: "/tmp" }, data: [] }) as never,
|
||||||
|
)
|
||||||
|
spyOn(sdk.v2.reference, "list").mockImplementation(() =>
|
||||||
|
ok({ location: { directory: "/tmp" }, data: [] }) as never,
|
||||||
|
)
|
||||||
|
spyOn(sdk.v2.command, "list").mockImplementation(() =>
|
||||||
|
ok({ location: { directory: "/tmp" }, data: [] }) as never,
|
||||||
|
)
|
||||||
|
spyOn(sdk.v2.skill, "list").mockImplementation(() =>
|
||||||
|
ok({ location: { directory: "/tmp" }, data: [] }) as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
const task = runInteractiveMode(
|
||||||
|
{
|
||||||
|
sdk,
|
||||||
|
directory: "/tmp",
|
||||||
|
sessionID: "ses-fresh",
|
||||||
|
resume: false,
|
||||||
|
agent: "build",
|
||||||
|
model: undefined,
|
||||||
|
variant: undefined,
|
||||||
|
files: [],
|
||||||
|
thinking: false,
|
||||||
|
backgroundSubagents: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createRuntimeLifecycle: async (input) => {
|
||||||
|
expect(input.model).toBeUndefined()
|
||||||
|
lifecycleStarted.resolve()
|
||||||
|
return {
|
||||||
|
footer: api,
|
||||||
|
onResize: () => () => {},
|
||||||
|
refreshTheme: () => {},
|
||||||
|
resetForReplay: () => Promise.resolve(),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
streamTransport: Promise.resolve({
|
||||||
|
createSessionTransport: async () => ({
|
||||||
|
runPromptTurn: async () => {},
|
||||||
|
interruptActiveTurn: async () => {},
|
||||||
|
selectSubagent: () => {},
|
||||||
|
replayOnResize: async () => false,
|
||||||
|
close: async () => {},
|
||||||
|
}),
|
||||||
|
formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await lifecycleStarted.promise
|
||||||
|
expect(defaultRequested).toBe(false)
|
||||||
|
painted.resolve()
|
||||||
|
await defaultStarted.promise
|
||||||
|
releaseDefault.resolve()
|
||||||
|
await modelShown.promise
|
||||||
|
await task
|
||||||
|
|
||||||
|
expect(events.find((event) => event.type === "model")).toEqual({
|
||||||
|
type: "model",
|
||||||
|
model: "gpt-5 · openai",
|
||||||
|
selection: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not start deferred work after the footer closes", async () => {
|
||||||
|
const sdk = new OpencodeClient()
|
||||||
|
const lifecycleStarted = defer<void>()
|
||||||
|
const painted = defer<void>()
|
||||||
|
const api = footer()
|
||||||
|
api.idle = () => painted.promise
|
||||||
|
const defaultModel = spyOn(sdk.v2.model, "default")
|
||||||
|
|
||||||
|
const task = runInteractiveMode(
|
||||||
|
{
|
||||||
|
sdk,
|
||||||
|
directory: "/tmp",
|
||||||
|
sessionID: "ses-closed",
|
||||||
|
resume: false,
|
||||||
|
agent: "build",
|
||||||
|
model: undefined,
|
||||||
|
variant: undefined,
|
||||||
|
files: [],
|
||||||
|
thinking: false,
|
||||||
|
backgroundSubagents: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createRuntimeLifecycle: async () => {
|
||||||
|
lifecycleStarted.resolve()
|
||||||
|
return {
|
||||||
|
footer: api,
|
||||||
|
onResize: () => () => {},
|
||||||
|
refreshTheme: () => {},
|
||||||
|
resetForReplay: () => Promise.resolve(),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await lifecycleStarted.promise
|
||||||
|
api.close()
|
||||||
|
painted.resolve()
|
||||||
|
await task
|
||||||
|
|
||||||
|
expect(defaultModel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
test("retains last-known-good state across failed coalesced refreshes and retries later", async () => {
|
test("retains last-known-good state across failed coalesced refreshes and retries later", async () => {
|
||||||
const sdk = new OpencodeClient()
|
const sdk = new OpencodeClient()
|
||||||
const refreshGate = defer<void>()
|
const refreshGate = defer<void>()
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ describe("run session shared", () => {
|
|||||||
|
|
||||||
const out = await resolveCurrentSession(client, "ses_1")
|
const out = await resolveCurrentSession(client, "ses_1")
|
||||||
|
|
||||||
|
expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
|
||||||
|
expect(out.variant).toBe("high")
|
||||||
expect(out.turns[0]?.prompt).toEqual({
|
expect(out.turns[0]?.prompt).toEqual({
|
||||||
text: "Review @note.ts",
|
text: "Review @note.ts",
|
||||||
parts: [
|
parts: [
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"./terminal-win32": "./src/terminal-win32.ts",
|
"./terminal-win32": "./src/terminal-win32.ts",
|
||||||
"./config/keybind": "./src/config/keybind.ts",
|
"./config/keybind": "./src/config/keybind.ts",
|
||||||
"./keymap": "./src/keymap.tsx",
|
"./keymap": "./src/keymap.tsx",
|
||||||
|
"./prompt/content": "./src/prompt/content.ts",
|
||||||
"./prompt/display": "./src/prompt/display.ts",
|
"./prompt/display": "./src/prompt/display.ts",
|
||||||
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
||||||
"./plugin/slots": "./src/plugin/slots.tsx",
|
"./plugin/slots": "./src/plugin/slots.tsx",
|
||||||
|
|||||||
@@ -7,22 +7,10 @@ import { spawn } from "node:child_process"
|
|||||||
import type { Stream } from "node:stream"
|
import type { Stream } from "node:stream"
|
||||||
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
|
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
|
||||||
|
|
||||||
|
export { normalizePromptContent } from "./prompt/content"
|
||||||
|
|
||||||
type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
|
type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
|
||||||
|
|
||||||
export function normalizePromptContent(content: string) {
|
|
||||||
if (content.endsWith("\r\n")) {
|
|
||||||
const body = content.slice(0, -2)
|
|
||||||
return !body.includes("\n") && !body.includes("\r") ? body : content
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.endsWith("\n")) {
|
|
||||||
const body = content.slice(0, -1)
|
|
||||||
return !body.includes("\n") && !body.includes("\r") ? body : content
|
|
||||||
}
|
|
||||||
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
|
export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
|
||||||
const editor = process.env.VISUAL || process.env.EDITOR
|
const editor = process.env.VISUAL || process.env.EDITOR
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function normalizePromptContent(content: string) {
|
||||||
|
if (content.endsWith("\r\n")) {
|
||||||
|
const body = content.slice(0, -2)
|
||||||
|
return !body.includes("\n") && !body.includes("\r") ? body : content
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.endsWith("\n")) {
|
||||||
|
const body = content.slice(0, -1)
|
||||||
|
return !body.includes("\n") && !body.includes("\r") ? body : content
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user