Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa4050673d | |||
| ba65426f19 |
@@ -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 CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["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]
|
||||
|
||||
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[]> {
|
||||
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
|
||||
return (result.data?.data ?? []).map(runAgent)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { pathToFileURL } from "bun"
|
||||
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
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 path from "path"
|
||||
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 {
|
||||
if (next.type === "model") {
|
||||
this.setCurrentModel(next.selection)
|
||||
}
|
||||
|
||||
if (next.type === "turn.duration") {
|
||||
const current = this.currentModel()
|
||||
this.flush()
|
||||
|
||||
@@ -26,6 +26,7 @@ export type ModelInfo = {
|
||||
export type SessionInfo = {
|
||||
first: boolean
|
||||
history: RunPrompt[]
|
||||
model?: NonNullable<RunInput["model"]>
|
||||
variant: string | undefined
|
||||
}
|
||||
|
||||
@@ -141,7 +142,8 @@ const layer = Layer.effect(
|
||||
return {
|
||||
first: session.first,
|
||||
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 { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { openEditor } from "@opencode-ai/tui/editor"
|
||||
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 { resolveInteractiveStdin } from "./runtime.stdin"
|
||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||
@@ -64,7 +63,7 @@ export type LifecycleInput = {
|
||||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
tuiConfig: RunTuiConfig
|
||||
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
|
||||
backgroundSubagents: boolean
|
||||
onPermissionReply: (input: PermissionReply) => 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[]) {
|
||||
if (title && !SessionApi.isDefaultTitle(title)) {
|
||||
if (title && !isDefaultTitle(title)) {
|
||||
return {
|
||||
title,
|
||||
showSession: true,
|
||||
@@ -124,17 +123,9 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
||||
|
||||
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
||||
const agentLabel = Locale.titlecase(input.agent ?? "build")
|
||||
|
||||
if (!input.model) {
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: "Model default",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
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.
|
||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
||||
const source = resolveInteractiveStdin()
|
||||
const footerTask = import("./footer")
|
||||
let unregisterKeymap: (() => void) | undefined
|
||||
|
||||
try {
|
||||
@@ -194,10 +186,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const theme = await resolveRunTheme(renderer)
|
||||
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
||||
renderer.setBackgroundColor(theme.background)
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig)
|
||||
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
||||
const state: SplashState = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
@@ -212,7 +204,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
})
|
||||
const footerTask = import("./footer")
|
||||
const wrote = queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
@@ -244,9 +235,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
theme,
|
||||
wrote,
|
||||
keymap,
|
||||
tuiConfig: input.tuiConfig,
|
||||
tuiConfig,
|
||||
backgroundSubagents: input.backgroundSubagents,
|
||||
diffStyle: input.tuiConfig.diff_style ?? "auto",
|
||||
diffStyle: tuiConfig.diff_style ?? "auto",
|
||||
onPermissionReply: input.onPermissionReply,
|
||||
onQuestionReply: input.onQuestionReply,
|
||||
onQuestionReject: input.onQuestionReject,
|
||||
@@ -260,6 +251,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
return
|
||||
}
|
||||
|
||||
const { openEditor } = await import("@opencode-ai/tui/editor")
|
||||
await renderer.idle().catch(() => {})
|
||||
const ignore = () => {}
|
||||
detachSigint()
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { MessageID } from "@/session/schema"
|
||||
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
|
||||
import { createRunDemo } from "./demo"
|
||||
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
|
||||
import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { trace } from "./trace"
|
||||
@@ -91,6 +90,8 @@ type StreamState = {
|
||||
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
|
||||
}
|
||||
|
||||
type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]>
|
||||
|
||||
type ResolvedSession = {
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
@@ -130,7 +131,7 @@ type RuntimeState = {
|
||||
sessionTitle?: string
|
||||
agent: string | undefined
|
||||
switching?: Promise<void>
|
||||
demo?: ReturnType<typeof createRunDemo>
|
||||
demo?: RunDemo
|
||||
selectSubagent?: (sessionID: string | undefined) => void
|
||||
session?: Promise<void>
|
||||
stream?: Promise<StreamState>
|
||||
@@ -181,21 +182,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
const log = trace()
|
||||
const tuiConfigTask = resolveRunTuiConfig()
|
||||
const ctx = await input.boot()
|
||||
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
|
||||
const sessionTask =
|
||||
ctx.resume === true
|
||||
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
||||
: Promise.resolve({
|
||||
first: true,
|
||||
history: [],
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
})
|
||||
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 = {
|
||||
shown: !session.first,
|
||||
aborting: false,
|
||||
model: ctx.model,
|
||||
model: ctx.model ?? session.model,
|
||||
providers: [],
|
||||
variants: [],
|
||||
limits: {},
|
||||
@@ -206,23 +207,43 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
sessionTitle: ctx.sessionTitle,
|
||||
agent: ctx.agent,
|
||||
}
|
||||
const ensureSession = () => {
|
||||
if (!input.resolveSession || state.sessionID) {
|
||||
return Promise.resolve()
|
||||
const loadModel = async () => {
|
||||
if (state.model) {
|
||||
return {
|
||||
model: state.model,
|
||||
savedVariant,
|
||||
boot: true,
|
||||
info: await resolveModelInfo(ctx.sdk, ctx.directory, state.model),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const model = await waitForDefaultModel({
|
||||
sdk: ctx.sdk,
|
||||
directory: ctx.directory,
|
||||
active: () => !footer.isClosed,
|
||||
})
|
||||
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)({
|
||||
directory: ctx.directory,
|
||||
findFiles: (query) =>
|
||||
@@ -240,7 +261,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
tuiConfig,
|
||||
tuiConfig: tuiConfigTask,
|
||||
backgroundSubagents: input.backgroundSubagents,
|
||||
onPermissionReply: async (next) => {
|
||||
if (state.demo?.permission(next)) {
|
||||
@@ -345,9 +366,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
}
|
||||
|
||||
state.aborting = true
|
||||
void (state.stream
|
||||
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
||||
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }))
|
||||
void (
|
||||
state.stream
|
||||
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
||||
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
state.aborting = false
|
||||
@@ -374,6 +397,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
},
|
||||
})
|
||||
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) => {
|
||||
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>>,
|
||||
current: string | undefined,
|
||||
boot = false,
|
||||
saved = savedVariant,
|
||||
) => {
|
||||
state.providers = info.providers
|
||||
state.variants = variantsFor(state.providers, state.model)
|
||||
state.limits = info.limits
|
||||
state.activeVariant = boot
|
||||
? resolveVariant(ctx.variant, current, savedVariant, state.variants)
|
||||
? resolveVariant(ctx.variant, current, saved, state.variants)
|
||||
: current && !state.variants.includes(current)
|
||||
? undefined
|
||||
: current
|
||||
@@ -430,7 +472,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
footer.event({ type: "models", providers: info.providers })
|
||||
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
|
||||
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
|
||||
@@ -456,24 +502,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
void catalogRefresh.catch(() => {})
|
||||
}
|
||||
|
||||
const initialCatalog = footer
|
||||
.idle()
|
||||
.then(loadCatalog)
|
||||
.catch(() => {})
|
||||
const initialCatalog = firstPaint.then(() => (footer.isClosed ? undefined : loadCatalog())).catch(() => {})
|
||||
void initialCatalog
|
||||
|
||||
if (Flag.OPENCODE_SHOW_TTFD) {
|
||||
footer.append({
|
||||
kind: "system",
|
||||
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
|
||||
phase: "final",
|
||||
source: "system",
|
||||
void firstPaint.then(() => {
|
||||
if (footer.isClosed) return
|
||||
footer.append({
|
||||
kind: "system",
|
||||
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
|
||||
phase: "final",
|
||||
source: "system",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (input.demo) {
|
||||
await ensureSession()
|
||||
state.demo = createRunDemo({
|
||||
const createDemo = async () => {
|
||||
const { createRunDemo } = await import("./demo")
|
||||
return createRunDemo({
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
@@ -481,13 +527,35 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
})
|
||||
}
|
||||
|
||||
if (input.afterPaint) {
|
||||
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
|
||||
if (input.demo) {
|
||||
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 = () => {
|
||||
if (state.stream) {
|
||||
return state.stream
|
||||
@@ -501,7 +569,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
const mod = await streamTask
|
||||
const mod = await loadStreamTransport()
|
||||
if (footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
@@ -570,6 +638,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
})
|
||||
|
||||
const runQueue = async () => {
|
||||
await firstPaint
|
||||
if (footer.isClosed) return
|
||||
let includeFiles = true
|
||||
if (state.demo) {
|
||||
await state.demo.start()
|
||||
@@ -615,14 +685,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
state.history = []
|
||||
state.localRows = []
|
||||
includeFiles = true
|
||||
state.demo = input.demo
|
||||
? createRunDemo({
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
})
|
||||
: undefined
|
||||
state.demo = input.demo ? await createDemo() : undefined
|
||||
log?.write("session.new", {
|
||||
sessionID: state.sessionID,
|
||||
})
|
||||
@@ -727,6 +790,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
try {
|
||||
const eager = eagerStream(input, ctx)
|
||||
if (eager) {
|
||||
await firstPaint
|
||||
if (footer.isClosed) return
|
||||
if (input.replay && state.shown) {
|
||||
// Replay commits immutable scrollback rows, so wait for provider names
|
||||
// before bootstrapping existing session history.
|
||||
@@ -737,13 +802,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
}
|
||||
|
||||
if (!eager && input.resolveSession) {
|
||||
queueMicrotask(() => {
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
void firstPaint
|
||||
.then(() => {
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
void ensureStream().catch(() => {})
|
||||
})
|
||||
return ensureStream()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -105,7 +105,7 @@ export class RunScrollbackStream {
|
||||
) {
|
||||
this.diffStyle = options.diffStyle
|
||||
this.sessionID = options.sessionID
|
||||
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
|
||||
this.treeSitterClient = options.treeSitterClient
|
||||
this.wrote = options.wrote ?? false
|
||||
this.onThemeRelease = options.onThemeRelease
|
||||
}
|
||||
@@ -151,6 +151,7 @@ export class RunScrollbackStream {
|
||||
startOnNewLine: entryFlags(commit).startOnNewLine,
|
||||
})
|
||||
const style = entryLook(commit, this.theme.entry)
|
||||
const treeSitterClient = body.type === "text" ? undefined : (this.treeSitterClient ??= getTreeSitterClient())
|
||||
const renderable =
|
||||
body.type === "text"
|
||||
? new TextRenderable(surface.renderContext, {
|
||||
@@ -170,7 +171,7 @@ export class RunScrollbackStream {
|
||||
drawUnstyledText: false,
|
||||
streaming: true,
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
treeSitterClient,
|
||||
})
|
||||
: new MarkdownRenderable(surface.renderContext, {
|
||||
content: "",
|
||||
@@ -180,7 +181,7 @@ export class RunScrollbackStream {
|
||||
internalBlockMode: "top-level",
|
||||
tableOptions: { widthMode: "content" },
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
treeSitterClient,
|
||||
})
|
||||
|
||||
surface.root.add(renderable)
|
||||
|
||||
@@ -20,6 +20,8 @@ type Turn = {
|
||||
export type RunSession = {
|
||||
first: boolean
|
||||
turns: Turn[]
|
||||
model?: NonNullable<RunInput["model"]>
|
||||
variant?: string
|
||||
}
|
||||
|
||||
function fileName(url: string, filename?: string) {
|
||||
@@ -157,9 +159,11 @@ export async function resolveCurrentSession(
|
||||
sessionID: string,
|
||||
limit = LIMIT,
|
||||
): 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 session = await sdk.v2.session.get({ sessionID }, { throwOnError: true })
|
||||
return {
|
||||
first: messages.length === 0,
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
const turn = session.turns[idx]
|
||||
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
|
||||
|
||||
@@ -312,6 +312,7 @@ export type FooterEvent =
|
||||
| {
|
||||
type: "model"
|
||||
model: string
|
||||
selection: NonNullable<RunInput["model"]>
|
||||
}
|
||||
| {
|
||||
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 { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
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 { 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
|
||||
|
||||
export function fromRow(row: SessionRow): Info {
|
||||
@@ -518,7 +512,7 @@ const layer: Layer.Layer<
|
||||
path: input.path,
|
||||
workspaceID: input.workspaceID,
|
||||
parentID: input.parentID,
|
||||
title: input.title ?? (input.parentID ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString(),
|
||||
title: input.title ?? createDefaultTitle(!!input.parentID),
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
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 { 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(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const client = new OpencodeClient()
|
||||
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 () => {
|
||||
const providersStarted = defer<void>()
|
||||
const providers = defer<void>()
|
||||
const lifecycleModels: unknown[] = []
|
||||
|
||||
const sdk = new OpencodeClient()
|
||||
const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused"))
|
||||
@@ -249,23 +250,23 @@ describe("run interactive runtime", () => {
|
||||
replay: true,
|
||||
replayLimit: 100,
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
files: [],
|
||||
thinking: true,
|
||||
backgroundSubagents: false,
|
||||
},
|
||||
{
|
||||
createRuntimeLifecycle: async () => ({
|
||||
footer: footer(),
|
||||
onResize: () => () => {},
|
||||
refreshTheme: () => {},
|
||||
resetForReplay: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
createRuntimeLifecycle: async (input) => {
|
||||
lifecycleModels.push(input.model)
|
||||
return {
|
||||
footer: footer(),
|
||||
onResize: () => () => {},
|
||||
refreshTheme: () => {},
|
||||
resetForReplay: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
streamTransport: Promise.resolve({
|
||||
createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => {
|
||||
transportProviders.push(input.providers?.() ?? [])
|
||||
@@ -293,12 +294,156 @@ describe("run interactive runtime", () => {
|
||||
|
||||
await task
|
||||
|
||||
expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }])
|
||||
expect(transportProviders).toEqual([[provider]])
|
||||
expect(legacyProviders).not.toHaveBeenCalled()
|
||||
expect(legacyAgents).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 () => {
|
||||
const sdk = new OpencodeClient()
|
||||
const refreshGate = defer<void>()
|
||||
|
||||
@@ -303,6 +303,8 @@ describe("run session shared", () => {
|
||||
|
||||
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({
|
||||
text: "Review @note.ts",
|
||||
parts: [
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"./terminal-win32": "./src/terminal-win32.ts",
|
||||
"./config/keybind": "./src/config/keybind.ts",
|
||||
"./keymap": "./src/keymap.tsx",
|
||||
"./prompt/content": "./src/prompt/content.ts",
|
||||
"./prompt/display": "./src/prompt/display.ts",
|
||||
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
||||
"./plugin/slots": "./src/plugin/slots.tsx",
|
||||
|
||||
@@ -7,22 +7,10 @@ import { spawn } from "node:child_process"
|
||||
import type { Stream } from "node:stream"
|
||||
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
|
||||
|
||||
export { normalizePromptContent } from "./prompt/content"
|
||||
|
||||
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 }) {
|
||||
const editor = process.env.VISUAL || process.env.EDITOR
|
||||
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