mini: add reconnect, forms, and shared targets (#37811)
Centralize session target resolution for mini and noninteractive run paths. Recover from transport drops, replace questions with forms, and keep tool/catalog state location-scoped with live progress and theme discovery.
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import { Effect, Option } from "effect"
|
import { Context, Effect, FileSystem, Option } from "effect"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { ServerConnection } from "../../services/server-connection"
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
|
import { Config } from "../../config"
|
||||||
|
import { resolve } from "@opencode-ai/tui/config"
|
||||||
|
|
||||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -9,9 +11,17 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
|||||||
yield* Effect.promise(async () => validateMiniTerminal())
|
yield* Effect.promise(async () => validateMiniTerminal())
|
||||||
const serverURL = Option.getOrUndefined(input.server)
|
const serverURL = Option.getOrUndefined(input.server)
|
||||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||||
|
const fileSystem = yield* FileSystem.FileSystem
|
||||||
|
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||||
|
const service = server.service
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
runMini({
|
runMini({
|
||||||
server,
|
server: {
|
||||||
|
endpoint: server.endpoint,
|
||||||
|
reconnect: service ? (signal) => runServicePromise(service.reconnect(), { signal }) : undefined,
|
||||||
|
},
|
||||||
continue: input.continue,
|
continue: input.continue,
|
||||||
session: Option.getOrUndefined(input.session),
|
session: Option.getOrUndefined(input.session),
|
||||||
fork: input.fork,
|
fork: input.fork,
|
||||||
@@ -21,6 +31,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
|||||||
replay: input.replay,
|
replay: input.replay,
|
||||||
replayLimit: Option.getOrUndefined(input.replayLimit),
|
replayLimit: Option.getOrUndefined(input.replayLimit),
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
|
tuiConfig: resolved,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ function preferences(statePath: string): MiniHost["preferences"] {
|
|||||||
return {
|
return {
|
||||||
async resolveVariant(model) {
|
async resolveVariant(model) {
|
||||||
if (!model) return
|
if (!model) return
|
||||||
return (await read()).variant?.[variantKey(model)]
|
const variant = (await read()).variant?.[variantKey(model)]
|
||||||
|
return variant === "default" ? undefined : variant
|
||||||
},
|
},
|
||||||
async saveVariant(model, variant) {
|
async saveVariant(model, variant) {
|
||||||
if (!model) return
|
if (!model) return
|
||||||
@@ -78,7 +79,7 @@ function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] {
|
|||||||
|
|
||||||
function createTrace(
|
function createTrace(
|
||||||
logPath: string,
|
logPath: string,
|
||||||
diagnostics: Pick<MiniHost["diagnostics"], "pid" | "cwd" | "argv">,
|
diagnostics: { pid: number; cwd: string; argv: string[] },
|
||||||
): MiniHost["diagnostics"]["trace"] {
|
): MiniHost["diagnostics"]["trace"] {
|
||||||
if (!process.env.OPENCODE_DIRECT_TRACE) return
|
if (!process.env.OPENCODE_DIRECT_TRACE) return
|
||||||
const stamp = new Date()
|
const stamp = new Date()
|
||||||
@@ -162,7 +163,7 @@ export async function usingInteractiveStdin<T>(
|
|||||||
export function createMiniHost(input: {
|
export function createMiniHost(input: {
|
||||||
terminal: InteractiveStdin
|
terminal: InteractiveStdin
|
||||||
directory: string
|
directory: string
|
||||||
paths?: MiniHost["paths"]
|
paths?: { home: string; state: string; log: string }
|
||||||
}): MiniHost {
|
}): MiniHost {
|
||||||
const paths = input.paths ?? {
|
const paths = input.paths ?? {
|
||||||
home: Global.Path.home,
|
home: Global.Path.home,
|
||||||
@@ -175,7 +176,7 @@ export function createMiniHost(input: {
|
|||||||
argv: process.argv.slice(2),
|
argv: process.argv.slice(2),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
terminal: input.terminal,
|
terminal: { stdin: input.terminal.stdin },
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
stdout: {
|
stdout: {
|
||||||
write(value) {
|
write(value) {
|
||||||
@@ -191,7 +192,7 @@ export function createMiniHost(input: {
|
|||||||
return openEditor(options)
|
return openEditor(options)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
paths,
|
paths: { home: paths.home },
|
||||||
signals: {
|
signals: {
|
||||||
sigint: signal("SIGINT"),
|
sigint: signal("SIGINT"),
|
||||||
sigusr2: signal("SIGUSR2"),
|
sigusr2: signal("SIGUSR2"),
|
||||||
@@ -201,7 +202,6 @@ export function createMiniHost(input: {
|
|||||||
now: () => performance.now(),
|
now: () => performance.now(),
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
...diagnostics,
|
|
||||||
trace: createTrace(paths.log, diagnostics),
|
trace: createTrace(paths.log, diagnostics),
|
||||||
},
|
},
|
||||||
preferences: preferences(paths.state),
|
preferences: preferences(paths.state),
|
||||||
|
|||||||
+165
-70
@@ -1,14 +1,17 @@
|
|||||||
import { Service } from "@opencode-ai/client/effect/service"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||||
import { setTimeout } from "node:timers/promises"
|
import { setTimeout } from "node:timers/promises"
|
||||||
import { ServerConnection } from "./services/server-connection"
|
|
||||||
import { waitForCatalogReady } from "./services/catalog"
|
import { waitForCatalogReady } from "./services/catalog"
|
||||||
import { readStdin } from "./util/io"
|
import { readStdin } from "./util/io"
|
||||||
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
|
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
|
||||||
|
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
|
||||||
|
|
||||||
export type MiniCommandInput = {
|
export type MiniCommandInput = {
|
||||||
server: ServerConnection.Resolved
|
server: {
|
||||||
|
endpoint: Endpoint
|
||||||
|
reconnect?: (signal: AbortSignal) => Promise<Endpoint>
|
||||||
|
}
|
||||||
continue?: boolean
|
continue?: boolean
|
||||||
session?: string
|
session?: string
|
||||||
fork?: boolean
|
fork?: boolean
|
||||||
@@ -21,7 +24,6 @@ export type MiniCommandInput = {
|
|||||||
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
|
||||||
type Model = MiniFrontendInput["model"]
|
type Model = MiniFrontendInput["model"]
|
||||||
|
|
||||||
class MiniInputError extends Error {}
|
class MiniInputError extends Error {}
|
||||||
@@ -33,42 +35,86 @@ export async function runMini(input: MiniCommandInput) {
|
|||||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
|
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
|
||||||
const frontendTask = import("@opencode-ai/tui/mini")
|
const frontendTask = import("@opencode-ai/tui/mini")
|
||||||
const directory = localDirectory()
|
const directory = localDirectory()
|
||||||
const sdk = OpenCode.make({
|
const connection = createMiniConnection(input.server)
|
||||||
baseUrl: input.server.endpoint.url,
|
const sdk = connection.sdk
|
||||||
headers: Service.headers(input.server.endpoint),
|
const requested = parseModel(input.model)
|
||||||
})
|
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined
|
||||||
const model = parseModel(input.model)
|
const prepare = prepareTarget(input.agent)
|
||||||
let agentTask: Promise<string | undefined> | undefined
|
const resolveTarget = async (initial: OpenCodeClient, signal: AbortSignal) => {
|
||||||
const resolveAgent = () => {
|
const resolved = await resolveMiniTarget({
|
||||||
agentTask ??= validateAgent(sdk, directory, input.agent)
|
sdk: initial,
|
||||||
return agentTask
|
reconnect: connection.reconnect,
|
||||||
}
|
signal,
|
||||||
const resolveSession = async () => {
|
resolve: (client) =>
|
||||||
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
|
resolveSessionTarget({
|
||||||
const readyModel =
|
client,
|
||||||
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
location: { directory },
|
||||||
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
|
continue: input.continue,
|
||||||
const session = selected ?? (await createSession(sdk, directory, agent, model))
|
session: input.session,
|
||||||
return { id: session.id, title: session.title, resume: selected !== undefined }
|
fork: input.fork,
|
||||||
|
model: requested,
|
||||||
|
agent: input.agent,
|
||||||
|
prepare,
|
||||||
|
signal,
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error instanceof Error && error.message === "Session not found")
|
||||||
|
throw new MiniInputError(error.message)
|
||||||
|
throw error
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const target = resolved.value
|
||||||
|
return {
|
||||||
|
sdk: resolved.sdk,
|
||||||
|
sessionID: target.session.id,
|
||||||
|
sessionTitle: target.session.title,
|
||||||
|
location: target.location,
|
||||||
|
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
|
||||||
|
variant: target.model?.variant,
|
||||||
|
agent: target.agent,
|
||||||
|
resume: target.resume,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const create = (
|
const create = (
|
||||||
_sdk: OpenCodeClient,
|
client: OpenCodeClient,
|
||||||
next: { agent: string | undefined; model: Model; variant: string | undefined },
|
next: {
|
||||||
) => createSession(sdk, directory, next.agent, next.model, next.variant)
|
location: { directory: string; workspaceID?: string }
|
||||||
|
agent: string | undefined
|
||||||
|
model: Model
|
||||||
|
variant: string | undefined
|
||||||
|
},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) =>
|
||||||
|
resolveSessionTarget({
|
||||||
|
client,
|
||||||
|
location: { directory: next.location.directory, workspace: next.location.workspaceID },
|
||||||
|
agent: next.agent,
|
||||||
|
model: next.model
|
||||||
|
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
||||||
|
: undefined,
|
||||||
|
prepare,
|
||||||
|
signal,
|
||||||
|
}).then((target) => ({
|
||||||
|
sessionID: target.session.id,
|
||||||
|
sessionTitle: target.session.title,
|
||||||
|
location: target.location,
|
||||||
|
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
|
||||||
|
variant: target.model?.variant,
|
||||||
|
agent: target.agent,
|
||||||
|
resume: false,
|
||||||
|
}))
|
||||||
const frontend = await frontendTask
|
const frontend = await frontendTask
|
||||||
return frontend.runMiniFrontend({
|
return frontend.runMiniFrontend({
|
||||||
host: createMiniHost({ terminal, directory }),
|
host: createMiniHost({ terminal, directory }),
|
||||||
sdk,
|
sdk,
|
||||||
directory,
|
directory,
|
||||||
resolveAgent,
|
target: resolveTarget,
|
||||||
session: resolveSession,
|
reconnect: connection.reconnect,
|
||||||
createSession: create,
|
createSession: create,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model,
|
model,
|
||||||
variant: undefined,
|
variant: requested?.variant,
|
||||||
files: [],
|
files: [],
|
||||||
initialInput,
|
initialInput,
|
||||||
thinking: true,
|
|
||||||
replay: input.replay ?? true,
|
replay: input.replay ?? true,
|
||||||
replayLimit: input.replayLimit,
|
replayLimit: input.replayLimit,
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
@@ -83,6 +129,51 @@ export async function runMini(input: MiniCommandInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for CLI boundary tests. */
|
||||||
|
export function createMiniConnection(input: MiniCommandInput["server"]) {
|
||||||
|
const make = (endpoint: Endpoint) =>
|
||||||
|
OpenCode.make({
|
||||||
|
baseUrl: endpoint.url,
|
||||||
|
headers: Service.headers(endpoint),
|
||||||
|
})
|
||||||
|
const reconnect = input.reconnect
|
||||||
|
return {
|
||||||
|
sdk: make(input.endpoint),
|
||||||
|
reconnect: reconnect
|
||||||
|
? async (signal: AbortSignal) => {
|
||||||
|
const endpoint = await reconnect(signal)
|
||||||
|
return make(endpoint)
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for reconnect lifecycle tests. */
|
||||||
|
export async function resolveMiniTarget<A>(input: {
|
||||||
|
sdk: OpenCodeClient
|
||||||
|
reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
|
||||||
|
signal: AbortSignal
|
||||||
|
resolve: (sdk: OpenCodeClient) => Promise<A>
|
||||||
|
}) {
|
||||||
|
let sdk = input.sdk
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return { sdk, value: await input.resolve(sdk) }
|
||||||
|
} catch (error) {
|
||||||
|
if (!input.reconnect || !(error instanceof ClientError) || error.reason !== "Transport") throw error
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
sdk = await input.reconnect(input.signal)
|
||||||
|
break
|
||||||
|
} catch (resolveError) {
|
||||||
|
if (input.signal.aborted) throw resolveError
|
||||||
|
await setTimeout(250, undefined, { signal: input.signal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function validateMiniTerminal() {
|
export function validateMiniTerminal() {
|
||||||
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
|
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
|
||||||
}
|
}
|
||||||
@@ -112,28 +203,63 @@ function localDirectory(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseModel(value?: string): Model {
|
function parseModel(value?: string) {
|
||||||
if (!value) return
|
try {
|
||||||
const [providerID, ...rest] = value.split("/")
|
return parseSessionTargetModel(value)
|
||||||
const modelID = rest.join("/")
|
} catch {
|
||||||
if (!providerID || !modelID) throw new MiniInputError("--model must use the format provider/model")
|
throw new MiniInputError("--model must use the format provider/model[#variant]")
|
||||||
return { providerID, modelID }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
|
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
|
||||||
|
return async (input) => {
|
||||||
|
if (input.model)
|
||||||
|
await waitForCatalogReady({
|
||||||
|
sdk: input.client,
|
||||||
|
directory: input.location.directory,
|
||||||
|
workspace: input.location.workspaceID,
|
||||||
|
model: { providerID: input.model.providerID, modelID: input.model.id },
|
||||||
|
signal: input.signal,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
model: input.model,
|
||||||
|
agent: requestedAgent
|
||||||
|
? await validateAgent(
|
||||||
|
input.client,
|
||||||
|
input.location.directory,
|
||||||
|
input.location.workspaceID,
|
||||||
|
requestedAgent,
|
||||||
|
input.signal,
|
||||||
|
)
|
||||||
|
: input.agent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateAgent(
|
||||||
|
sdk: OpenCodeClient,
|
||||||
|
directory: string,
|
||||||
|
workspace: string | undefined,
|
||||||
|
name?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const deadline = Date.now() + 5_000
|
const deadline = Date.now() + 5_000
|
||||||
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline && !signal?.aborted) {
|
||||||
agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
|
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
|
||||||
|
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
const agent = agents?.data.find((item) => item.id === name)
|
const agent = agents?.data.find((item) => item.id === name)
|
||||||
if (agent?.mode === "subagent") {
|
if (agent?.mode === "subagent") {
|
||||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (agent) return name
|
if (agent) return name
|
||||||
await setTimeout(25)
|
await setTimeout(25, undefined, { signal }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
if (signal?.aborted) return
|
||||||
if (!agents) {
|
if (!agents) {
|
||||||
warning("failed to list agents. Falling back to default agent")
|
warning("failed to list agents. Falling back to default agent")
|
||||||
return
|
return
|
||||||
@@ -141,37 +267,6 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
|
|||||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
|
|
||||||
const selected =
|
|
||||||
preselected ??
|
|
||||||
(input.session
|
|
||||||
? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
|
|
||||||
: input.continue
|
|
||||||
? await sdk.session
|
|
||||||
.list({ directory, parentID: null, limit: 1, order: "desc" })
|
|
||||||
.then((result) => result.data[0])
|
|
||||||
: undefined)
|
|
||||||
if (input.session && !selected) throw new MiniInputError("Session not found")
|
|
||||||
if (!selected) return
|
|
||||||
if (!input.fork) return selected
|
|
||||||
return sdk.session.fork({ sessionID: selected.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSession(
|
|
||||||
sdk: OpenCodeClient,
|
|
||||||
directory: string,
|
|
||||||
agent: string | undefined,
|
|
||||||
model: Model,
|
|
||||||
variant?: string,
|
|
||||||
): Promise<Session> {
|
|
||||||
if (model) await waitForCatalogReady({ sdk, directory, model })
|
|
||||||
return sdk.session.create({
|
|
||||||
agent,
|
|
||||||
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
|
|
||||||
location: { directory },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function warning(message: string) {
|
function warning(message: string) {
|
||||||
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
|
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
import type {
|
||||||
|
EventSubscribeOutput,
|
||||||
|
JsonValue,
|
||||||
|
LLMToolContent,
|
||||||
|
LocationRef,
|
||||||
|
OpenCodeClient,
|
||||||
|
SessionMessageAssistantTool,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
@@ -19,6 +26,7 @@ type File = {
|
|||||||
type Input = {
|
type Input = {
|
||||||
client: OpenCodeClient
|
client: OpenCodeClient
|
||||||
sessionID: string
|
sessionID: string
|
||||||
|
location: LocationRef
|
||||||
message: string
|
message: string
|
||||||
files: File[]
|
files: File[]
|
||||||
agent?: string
|
agent?: string
|
||||||
@@ -30,8 +38,8 @@ type Input = {
|
|||||||
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
||||||
attached: boolean
|
attached: boolean
|
||||||
compatibility?: "v1"
|
compatibility?: "v1"
|
||||||
renderTool: (part: MiniToolPart) => Promise<void>
|
renderTool: (part: SessionMessageAssistantTool) => Promise<void>
|
||||||
renderToolError: (part: MiniToolPart) => Promise<void>
|
renderToolError: (part: SessionMessageAssistantTool) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type StartedPart = {
|
type StartedPart = {
|
||||||
@@ -42,9 +50,12 @@ type StartedPart = {
|
|||||||
type ToolState = StartedPart & {
|
type ToolState = StartedPart & {
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
tool: string
|
tool: string
|
||||||
input: Record<string, unknown>
|
input: Record<string, JsonValue>
|
||||||
raw?: string
|
raw?: string
|
||||||
provider?: unknown
|
provider?: unknown
|
||||||
|
providerState?: SessionMessageAssistantTool["providerState"]
|
||||||
|
structured: Record<string, JsonValue>
|
||||||
|
content: LLMToolContent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type V2Event = EventSubscribeOutput
|
type V2Event = EventSubscribeOutput
|
||||||
@@ -67,7 +78,6 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
let submitted = false
|
let submitted = false
|
||||||
let promoted = false
|
let promoted = false
|
||||||
let emittedError = false
|
let emittedError = false
|
||||||
let questionRejected = false
|
|
||||||
let permissionRejected = false
|
let permissionRejected = false
|
||||||
let formCancelled = false
|
let formCancelled = false
|
||||||
let interrupted = false
|
let interrupted = false
|
||||||
@@ -126,14 +136,16 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectQuestion = async (request: { id: string }) => {
|
|
||||||
questionRejected = true
|
|
||||||
await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
|
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
|
||||||
|
try {
|
||||||
|
await input.client.form.cancel(
|
||||||
|
{ sessionID: request.sessionID, formID: request.id },
|
||||||
|
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (!formAlreadySettled(error)) throw error
|
||||||
|
}
|
||||||
formCancelled = true
|
formCancelled = true
|
||||||
await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const consume = async () => {
|
const consume = async () => {
|
||||||
@@ -152,15 +164,13 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
await replyPermission(event.data)
|
await replyPermission(event.data)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
|
|
||||||
await rejectQuestion(event.data)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
event.type === "form.created" &&
|
event.type === "form.created" &&
|
||||||
submitted &&
|
submitted &&
|
||||||
(event.data.form.sessionID === input.sessionID ||
|
(event.data.form.sessionID === input.sessionID ||
|
||||||
(!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID))
|
(!input.attached &&
|
||||||
|
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
|
||||||
|
sameLocation(event.location, input.location)))
|
||||||
) {
|
) {
|
||||||
await cancelForm(event.data.form)
|
await cancelForm(event.data.form)
|
||||||
continue
|
continue
|
||||||
@@ -177,7 +187,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
if (
|
if (
|
||||||
event.type === "session.execution.interrupted" &&
|
event.type === "session.execution.interrupted" &&
|
||||||
event.data.reason === "user" &&
|
event.data.reason === "user" &&
|
||||||
(interrupted || permissionRejected || questionRejected || formCancelled)
|
(interrupted || permissionRejected || formCancelled)
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -260,24 +270,32 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
|
|
||||||
if (event.type === "session.tool.input.started") {
|
if (event.type === "session.tool.input.started") {
|
||||||
flushStep()
|
flushStep()
|
||||||
tools.set(event.data.callID, {
|
tools.set(toolKey(event.data.assistantMessageID, event.data.callID), {
|
||||||
id: partID(event.id),
|
id: partID(event.id),
|
||||||
timestamp: time,
|
timestamp: time,
|
||||||
assistantMessageID: event.data.assistantMessageID,
|
assistantMessageID: event.data.assistantMessageID,
|
||||||
tool: event.data.name,
|
tool: event.data.name,
|
||||||
input: {},
|
input: {},
|
||||||
|
structured: {},
|
||||||
|
content: [],
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.input.ended") {
|
if (event.type === "session.tool.input.ended") {
|
||||||
const current = tools.get(event.data.callID)
|
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||||
if (current) current.raw = event.data.text
|
if (current) current.raw = event.data.text
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.tool.input.delta") {
|
||||||
|
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||||
|
if (current) current.raw = (current.raw ?? "") + event.data.delta
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (event.type === "session.tool.called") {
|
if (event.type === "session.tool.called") {
|
||||||
flushStep()
|
flushStep()
|
||||||
const current = tools.get(event.data.callID)
|
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||||
tools.set(event.data.callID, {
|
const current = tools.get(key)
|
||||||
|
tools.set(key, {
|
||||||
id: current?.id ?? partID(event.id),
|
id: current?.id ?? partID(event.id),
|
||||||
timestamp: current?.timestamp ?? time,
|
timestamp: current?.timestamp ?? time,
|
||||||
assistantMessageID: event.data.assistantMessageID,
|
assistantMessageID: event.data.assistantMessageID,
|
||||||
@@ -285,11 +303,39 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
input: event.data.input,
|
input: event.data.input,
|
||||||
raw: current?.raw,
|
raw: current?.raw,
|
||||||
provider: { executed: event.data.executed, state: event.data.state },
|
provider: { executed: event.data.executed, state: event.data.state },
|
||||||
|
providerState: event.data.state,
|
||||||
|
structured: {},
|
||||||
|
content: [],
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.tool.progress") {
|
||||||
|
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||||
|
if (current) {
|
||||||
|
current.structured = event.data.structured
|
||||||
|
current.content = event.data.content
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (event.type === "session.tool.success") {
|
if (event.type === "session.tool.success") {
|
||||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||||
|
const current = tools.get(key) ?? fallbackTool(event)
|
||||||
|
const tool: SessionMessageAssistantTool = {
|
||||||
|
type: "tool",
|
||||||
|
id: event.data.callID,
|
||||||
|
name: current.tool,
|
||||||
|
executed: event.data.executed,
|
||||||
|
providerState: current.providerState,
|
||||||
|
providerResultState: event.data.resultState,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: current.input,
|
||||||
|
structured: event.data.structured,
|
||||||
|
content: event.data.content,
|
||||||
|
result: event.data.result,
|
||||||
|
},
|
||||||
|
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||||
|
}
|
||||||
const part: MiniToolPart = {
|
const part: MiniToolPart = {
|
||||||
id: current.id,
|
id: current.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -313,13 +359,31 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
time: { start: current.timestamp, end: time },
|
time: { start: current.timestamp, end: time },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
tools.delete(event.data.callID)
|
tools.delete(key)
|
||||||
if (!emit("tool_use", time, { part })) await input.renderTool(part)
|
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.failed") {
|
if (event.type === "session.tool.failed") {
|
||||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||||
|
const current = tools.get(key) ?? fallbackTool(event)
|
||||||
const error = event.data.error.message
|
const error = event.data.error.message
|
||||||
|
const tool: SessionMessageAssistantTool = {
|
||||||
|
type: "tool",
|
||||||
|
id: event.data.callID,
|
||||||
|
name: current.tool,
|
||||||
|
executed: event.data.executed,
|
||||||
|
providerState: current.providerState,
|
||||||
|
providerResultState: event.data.resultState,
|
||||||
|
state: {
|
||||||
|
status: "error",
|
||||||
|
input: current.input,
|
||||||
|
structured: current.structured,
|
||||||
|
content: current.content,
|
||||||
|
error: event.data.error,
|
||||||
|
result: event.data.result,
|
||||||
|
},
|
||||||
|
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||||
|
}
|
||||||
const part: MiniToolPart = {
|
const part: MiniToolPart = {
|
||||||
id: current.id,
|
id: current.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -340,10 +404,21 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
time: { start: current.timestamp, end: time },
|
time: { start: current.timestamp, end: time },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
tools.delete(event.data.callID)
|
tools.delete(key)
|
||||||
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
|
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
||||||
if (!emit("tool_use", time, { part })) {
|
if (!emit("tool_use", time, { part })) {
|
||||||
await input.renderToolError(part)
|
if (toolOutputText(current.tool, current.content).trim())
|
||||||
|
await input.renderTool({
|
||||||
|
...tool,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: current.input,
|
||||||
|
structured: current.structured,
|
||||||
|
content: current.content,
|
||||||
|
result: event.data.result,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await input.renderToolError(tool)
|
||||||
UI.error(error)
|
UI.error(error)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -373,7 +448,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
v1InvalidOutput = true
|
v1InvalidOutput = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
|
if (interrupted || permissionRejected || formCancelled) continue
|
||||||
flushStep()
|
flushStep()
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
@@ -381,13 +456,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.failed") {
|
if (event.type === "session.execution.failed") {
|
||||||
if (
|
if (input.compatibility === "v1" && (v1InvalidOutput || permissionRejected || formCancelled)) return
|
||||||
input.compatibility === "v1" &&
|
|
||||||
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
flushStep()
|
flushStep()
|
||||||
if (!emittedError && !questionRejected && !formCancelled) {
|
if (!emittedError && !formCancelled) {
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||||
@@ -395,7 +466,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.interrupted") {
|
if (event.type === "session.execution.interrupted") {
|
||||||
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return
|
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
|
||||||
if (event.data.reason === "user" && interrupted) process.exitCode = 130
|
if (event.data.reason === "user" && interrupted) process.exitCode = 130
|
||||||
if (event.data.reason !== "user" && !emittedError) {
|
if (event.data.reason !== "user" && !emittedError) {
|
||||||
emittedError = true
|
emittedError = true
|
||||||
@@ -470,19 +541,23 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
if (!response) return
|
if (!response) return
|
||||||
if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||||
|
|
||||||
const [permissions, questions, forms] = await Promise.all([
|
const [permissions, forms, globals] = await Promise.all([
|
||||||
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
|
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
|
||||||
input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
|
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined),
|
||||||
Promise.all(
|
input.attached
|
||||||
(input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
|
? Promise.resolve(undefined)
|
||||||
input.client.form.list({ sessionID }).catch(() => undefined),
|
: input.client.form.request
|
||||||
),
|
.list({
|
||||||
),
|
location: { directory: input.location.directory, workspace: input.location.workspaceID },
|
||||||
|
})
|
||||||
|
.catch(() => undefined),
|
||||||
])
|
])
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...(permissions ?? []).map(replyPermission),
|
...(permissions ?? []).map(replyPermission),
|
||||||
...(questions ?? []).map(rejectQuestion),
|
...(forms ?? []).map(cancelForm),
|
||||||
...forms.flatMap((response) => response ?? []).map(cancelForm),
|
...(globals && sameLocation(globals.location, input.location)
|
||||||
|
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
|
||||||
|
: []),
|
||||||
])
|
])
|
||||||
await completed
|
await completed
|
||||||
} finally {
|
} finally {
|
||||||
@@ -492,10 +567,34 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameLocation(left: LocationRef | undefined, right: LocationRef) {
|
||||||
|
return !!left && left.directory === right.directory && left.workspaceID === right.workspaceID
|
||||||
|
}
|
||||||
|
|
||||||
|
function formRequestOptions(location: LocationRef | undefined): [] | [{ headers: Record<string, string> }] {
|
||||||
|
if (!location) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"x-opencode-directory": encodeURIComponent(location.directory),
|
||||||
|
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formAlreadySettled(error: unknown) {
|
||||||
|
return !!error && typeof error === "object" && Reflect.get(error, "_tag") === "FormAlreadySettledError"
|
||||||
|
}
|
||||||
|
|
||||||
function partID(eventID: string) {
|
function partID(eventID: string) {
|
||||||
return `prt_${eventID.replace(/^evt_/, "")}`
|
return `prt_${eventID.replace(/^evt_/, "")}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolKey(messageID: string, callID: string) {
|
||||||
|
return `${messageID}\u0000${callID}`
|
||||||
|
}
|
||||||
|
|
||||||
function fallbackTool(event: {
|
function fallbackTool(event: {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -507,6 +606,8 @@ function fallbackTool(event: {
|
|||||||
assistantMessageID: event.data.assistantMessageID,
|
assistantMessageID: event.data.assistantMessageID,
|
||||||
tool: "tool",
|
tool: "tool",
|
||||||
input: {},
|
input: {},
|
||||||
|
structured: {},
|
||||||
|
content: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+81
-72
@@ -1,13 +1,13 @@
|
|||||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
|
||||||
import { open } from "node:fs/promises"
|
import { open } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { readStdin } from "../util/io"
|
import { readStdin } from "../util/io"
|
||||||
import { ServerConnection } from "../services/server-connection"
|
import { ServerConnection } from "../services/server-connection"
|
||||||
import { waitForCatalogReady } from "../services/catalog"
|
import { waitForCatalogReady } from "../services/catalog"
|
||||||
import { toolInlineInfo, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
|
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
|
||||||
|
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
|
||||||
import { runNonInteractivePrompt } from "./noninteractive"
|
import { runNonInteractivePrompt } from "./noninteractive"
|
||||||
import { UI } from "./ui"
|
import { UI } from "./ui"
|
||||||
|
|
||||||
@@ -47,6 +47,15 @@ type ExecutionOptions = {
|
|||||||
compatibility?: "v1"
|
compatibility?: "v1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RunTargetError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly sessionID?: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
|
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
export function runNonInteractive(input: RunCommandInput) {
|
export function runNonInteractive(input: RunCommandInput) {
|
||||||
@@ -72,50 +81,74 @@ async function run(input: RunCommandInput, options: ExecutionOptions) {
|
|||||||
|
|
||||||
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
|
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
|
||||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
|
||||||
if (!requestedDirectory) fail("Failed to resolve server directory")
|
|
||||||
const session = await selectSession(client, requestedDirectory, input)
|
|
||||||
const cwd = session?.location.directory ?? requestedDirectory
|
|
||||||
const workspace = session?.location.workspaceID
|
|
||||||
const explicit = parseRunModel(input.model)
|
const explicit = parseRunModel(input.model)
|
||||||
const explicitModel = explicit?.model
|
const target = await resolveSessionTarget({
|
||||||
const variant = options.variant ?? explicit?.variant
|
client,
|
||||||
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
|
location: prepared.directory ? { directory: prepared.directory } : undefined,
|
||||||
const defaultModel =
|
continue: input.continue,
|
||||||
!explicitModel && !sessionModel
|
session: input.session,
|
||||||
? await client.model
|
fork: input.fork,
|
||||||
.default({ location: { directory: cwd, workspace } })
|
model: explicit
|
||||||
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
? { providerID: explicit.model.providerID, id: explicit.model.modelID, variant: explicit.variant }
|
||||||
: undefined
|
: undefined,
|
||||||
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
|
agent: input.agent,
|
||||||
if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id)
|
prepare: async (next) => {
|
||||||
if (model) {
|
const selected =
|
||||||
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
next.model ??
|
||||||
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
(await client.model
|
||||||
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
|
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
||||||
return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
.then((result) => result.data))
|
||||||
}
|
const model = selected
|
||||||
const agent = await validateAgent(client, cwd, input.agent)
|
? {
|
||||||
const selected =
|
providerID: selected.providerID,
|
||||||
session ??
|
id: selected.id,
|
||||||
(await client.session.create({
|
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
|
||||||
agent,
|
}
|
||||||
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
|
: undefined
|
||||||
location: { directory: cwd },
|
if ((options.variant ?? explicit?.variant) && !model)
|
||||||
}))
|
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
|
||||||
if (!session && input.title !== undefined) {
|
if (model) {
|
||||||
|
await waitForCatalogReady({
|
||||||
|
sdk: client,
|
||||||
|
directory: next.location.directory,
|
||||||
|
workspace: next.location.workspaceID,
|
||||||
|
model: { providerID: model.providerID, modelID: model.id },
|
||||||
|
})
|
||||||
|
const available = await client.model.list({
|
||||||
|
location: { directory: next.location.directory, workspace: next.location.workspaceID },
|
||||||
|
})
|
||||||
|
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id))
|
||||||
|
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
agent: input.agent
|
||||||
|
? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent)
|
||||||
|
: next.agent,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).catch((error) => {
|
||||||
|
if (!(error instanceof RunTargetError)) throw error
|
||||||
|
reportRunError(input, error.message, error.sessionID)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!target) return
|
||||||
|
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
|
||||||
|
const variant = target.model?.variant
|
||||||
|
if (!target.resume && input.title !== undefined) {
|
||||||
await client.session.rename({
|
await client.session.rename({
|
||||||
sessionID: selected.id,
|
sessionID: target.session.id,
|
||||||
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
|
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await runNonInteractivePrompt({
|
await runNonInteractivePrompt({
|
||||||
client,
|
client,
|
||||||
sessionID: selected.id,
|
sessionID: target.session.id,
|
||||||
|
location: target.location,
|
||||||
message: prepared.message,
|
message: prepared.message,
|
||||||
files: prepared.files,
|
files: prepared.files,
|
||||||
agent,
|
agent: target.agent,
|
||||||
model,
|
model,
|
||||||
variant,
|
variant,
|
||||||
thinking: input.thinking ?? false,
|
thinking: input.thinking ?? false,
|
||||||
@@ -123,9 +156,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||||||
auto: input.auto ?? false,
|
auto: input.auto ?? false,
|
||||||
attached: options.attached ?? true,
|
attached: options.attached ?? true,
|
||||||
compatibility: options.compatibility,
|
compatibility: options.compatibility,
|
||||||
renderTool,
|
renderTool: (part) => renderTool(part, target.location.directory),
|
||||||
renderToolError,
|
renderToolError: (part) => renderToolError(part, target.location.directory),
|
||||||
}).catch((error) => reportRunError(input, errorMessage(error), selected.id))
|
}).catch((error) => reportRunError(input, errorMessage(error), target.session.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeInput(message: string | undefined, piped: string | undefined) {
|
export function mergeInput(message: string | undefined, piped: string | undefined) {
|
||||||
@@ -134,17 +167,6 @@ export function mergeInput(message: string | undefined, piped: string | undefine
|
|||||||
return message + "\n" + piped
|
return message + "\n" + piped
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickRunModel(
|
|
||||||
explicit: { providerID: string; modelID: string } | undefined,
|
|
||||||
variant: string | undefined,
|
|
||||||
session: { providerID: string; modelID: string } | undefined,
|
|
||||||
fallback: { providerID: string; modelID: string } | undefined,
|
|
||||||
) {
|
|
||||||
if (explicit) return explicit
|
|
||||||
if (!variant) return
|
|
||||||
return session ?? fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMessage(message: string[]) {
|
function formatMessage(message: string[]) {
|
||||||
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
|
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
|
||||||
return value || undefined
|
return value || undefined
|
||||||
@@ -160,18 +182,18 @@ function localDirectory(root: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseRunModel(value?: string) {
|
export function parseRunModel(value?: string) {
|
||||||
if (!value) return
|
const ref = parseSessionTargetModel(value)
|
||||||
const ref = Model.Ref.parse(value)
|
if (!ref) return
|
||||||
return {
|
return {
|
||||||
model: { providerID: ref.providerID, modelID: ref.id },
|
model: { providerID: ref.providerID, modelID: ref.id },
|
||||||
variant: ref.variant,
|
variant: ref.variant,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateAgent(client: OpenCodeClient, directory: string, name?: string) {
|
async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const agents = await client.agent
|
const agents = await client.agent
|
||||||
.list({ location: { directory } })
|
.list({ location: { directory, workspace } })
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!agents) {
|
if (!agents) {
|
||||||
@@ -190,19 +212,6 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) {
|
|
||||||
const selected = input.session
|
|
||||||
? await client.session.get({ sessionID: input.session }).catch(() => undefined)
|
|
||||||
: input.continue
|
|
||||||
? await client.session
|
|
||||||
.list({ directory, parentID: null, limit: 1, order: "desc" })
|
|
||||||
.then((result) => result.data[0])
|
|
||||||
: undefined
|
|
||||||
if (input.session && !selected) fail("Session not found")
|
|
||||||
if (!selected || !input.fork) return selected
|
|
||||||
return client.session.fork({ sessionID: selected.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
|
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
|
||||||
const file = path.resolve(directory, input)
|
const file = path.resolve(directory, input)
|
||||||
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
||||||
@@ -244,8 +253,8 @@ function isBinaryContent(bytes: Uint8Array) {
|
|||||||
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
|
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderTool(part: MiniToolPart) {
|
async function renderTool(part: SessionMessageAssistantTool, directory: string) {
|
||||||
const info = toolInlineInfo(part)
|
const info = toolInlineInfo(part, directory)
|
||||||
if (info.mode === "block") {
|
if (info.mode === "block") {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
|
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
|
||||||
@@ -260,8 +269,8 @@ async function renderTool(part: MiniToolPart) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderToolError(part: MiniToolPart) {
|
async function renderToolError(part: SessionMessageAssistantTool, directory: string) {
|
||||||
const info = toolInlineInfo(part)
|
const info = toolInlineInfo(part, directory)
|
||||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
// Location plugins initialize asynchronously, so explicit model selection must
|
// Location plugins initialize asynchronously, so explicit model selection must
|
||||||
// wait for that exact model before prompt admission. The execution path owns
|
// wait for that exact model before prompt admission. The execution path owns
|
||||||
@@ -9,14 +9,35 @@ export async function waitForCatalogReady(input: {
|
|||||||
workspace?: string
|
workspace?: string
|
||||||
model: { providerID: string; modelID: string }
|
model: { providerID: string; modelID: string }
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
|
signal?: AbortSignal
|
||||||
}) {
|
}) {
|
||||||
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline && !input.signal?.aborted) {
|
||||||
const models = await input.sdk.model
|
const models = await input.sdk.model
|
||||||
.list({ location: { directory: input.directory, workspace: input.workspace } })
|
.list(
|
||||||
|
{ location: { directory: input.directory, workspace: input.workspace } },
|
||||||
|
{ signal: input.signal },
|
||||||
|
)
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined)
|
.catch((error) => {
|
||||||
|
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
|
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
await wait(25, input.signal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wait(delay: number, signal?: AbortSignal) {
|
||||||
|
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
|
||||||
|
if (signal.aborted) return Promise.resolve()
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(done, delay)
|
||||||
|
signal.addEventListener("abort", done, { once: true })
|
||||||
|
function done() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal?.removeEventListener("abort", done)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import type { LocationGetOutput, ModelRef, OpenCodeClient, SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
|
|
||||||
|
const SESSION_PAGE_LIMIT = 50
|
||||||
|
|
||||||
|
export type SessionTarget = {
|
||||||
|
session: SessionInfo
|
||||||
|
location: LocationGetOutput
|
||||||
|
model: ModelRef | undefined
|
||||||
|
agent: string | undefined
|
||||||
|
resume: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionTargetPreparation = (input: {
|
||||||
|
client: OpenCodeClient
|
||||||
|
location: LocationGetOutput
|
||||||
|
session: SessionInfo | undefined
|
||||||
|
model: ModelRef | undefined
|
||||||
|
agent: string | undefined
|
||||||
|
signal?: AbortSignal
|
||||||
|
}) => Promise<{ model: ModelRef | undefined; agent: string | undefined }>
|
||||||
|
|
||||||
|
export class SessionTargetMutationError extends Error {
|
||||||
|
override readonly name = "SessionTargetMutationError"
|
||||||
|
|
||||||
|
constructor(cause: unknown) {
|
||||||
|
super(cause instanceof Error ? cause.message : "Session target mutation failed", { cause })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSessionTarget(input: {
|
||||||
|
client: OpenCodeClient
|
||||||
|
location?: { directory?: string; workspace?: string }
|
||||||
|
continue?: boolean
|
||||||
|
session?: string
|
||||||
|
fork?: boolean
|
||||||
|
model?: ModelRef
|
||||||
|
agent?: string
|
||||||
|
prepare: SessionTargetPreparation
|
||||||
|
signal?: AbortSignal
|
||||||
|
}): Promise<SessionTarget> {
|
||||||
|
const selection = await selectSession(input)
|
||||||
|
const selected = selection.session
|
||||||
|
const location =
|
||||||
|
selection.location ??
|
||||||
|
(await resolveLocation(
|
||||||
|
input.client,
|
||||||
|
selected ? { directory: selected.location.directory, workspace: selected.location.workspaceID } : input.location,
|
||||||
|
input.signal,
|
||||||
|
))
|
||||||
|
const prepared = await input.prepare({
|
||||||
|
client: input.client,
|
||||||
|
location,
|
||||||
|
session: selected,
|
||||||
|
model: input.model ?? selected?.model,
|
||||||
|
agent: input.agent ?? selected?.agent,
|
||||||
|
signal: input.signal,
|
||||||
|
})
|
||||||
|
const session =
|
||||||
|
selected ??
|
||||||
|
(await input.client.session
|
||||||
|
.create(
|
||||||
|
{
|
||||||
|
agent: prepared.agent,
|
||||||
|
model: prepared.model,
|
||||||
|
location: { directory: location.directory, workspaceID: location.workspaceID },
|
||||||
|
},
|
||||||
|
...requestOptions(input.signal),
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
throw new SessionTargetMutationError(error)
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
location,
|
||||||
|
model: prepared.model,
|
||||||
|
agent: prepared.agent,
|
||||||
|
resume: selected !== undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionTargetModel(value?: string): ModelRef | undefined {
|
||||||
|
if (!value) return
|
||||||
|
const model = Model.Ref.parse(value)
|
||||||
|
return { providerID: model.providerID, id: model.id, variant: model.variant }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectSession(input: {
|
||||||
|
client: OpenCodeClient
|
||||||
|
location?: { directory?: string; workspace?: string }
|
||||||
|
continue?: boolean
|
||||||
|
session?: string
|
||||||
|
fork?: boolean
|
||||||
|
signal?: AbortSignal
|
||||||
|
}) {
|
||||||
|
const explicit = input.session
|
||||||
|
? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => {
|
||||||
|
if (error && typeof error === "object" && Reflect.get(error, "_tag") === "SessionNotFoundError")
|
||||||
|
return undefined
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
if (input.session && !explicit) throw new Error("Session not found")
|
||||||
|
if (explicit)
|
||||||
|
return {
|
||||||
|
session: input.fork
|
||||||
|
? await input.client.session
|
||||||
|
.fork({ sessionID: explicit.id }, ...requestOptions(input.signal))
|
||||||
|
.catch((error) => {
|
||||||
|
throw new SessionTargetMutationError(error)
|
||||||
|
})
|
||||||
|
: explicit,
|
||||||
|
}
|
||||||
|
if (!input.continue) return { session: undefined }
|
||||||
|
|
||||||
|
const location = await resolveLocation(input.client, input.location, input.signal)
|
||||||
|
const selected = await latestSession(input.client, location, undefined, input.signal)
|
||||||
|
if (!selected) return { session: undefined, location }
|
||||||
|
return {
|
||||||
|
session: input.fork
|
||||||
|
? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => {
|
||||||
|
throw new SessionTargetMutationError(error)
|
||||||
|
})
|
||||||
|
: selected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestSession(
|
||||||
|
client: OpenCodeClient,
|
||||||
|
location: LocationGetOutput,
|
||||||
|
cursor?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<SessionInfo | undefined> {
|
||||||
|
const page = await client.session.list(
|
||||||
|
{
|
||||||
|
directory: location.directory,
|
||||||
|
workspace: location.workspaceID,
|
||||||
|
parentID: null,
|
||||||
|
limit: SESSION_PAGE_LIMIT,
|
||||||
|
order: "desc",
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
|
},
|
||||||
|
...requestOptions(signal),
|
||||||
|
)
|
||||||
|
const selected = page.data.find(
|
||||||
|
(session) =>
|
||||||
|
session.location.directory === location.directory && session.location.workspaceID === location.workspaceID,
|
||||||
|
)
|
||||||
|
if (selected) return selected
|
||||||
|
if (!page.cursor.next || page.data.length === 0) return
|
||||||
|
return latestSession(client, location, page.cursor.next, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocation(
|
||||||
|
client: OpenCodeClient,
|
||||||
|
location?: { directory?: string; workspace?: string },
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
if (!location && !signal) return client.location.get()
|
||||||
|
if (!location) return client.location.get(undefined, { signal })
|
||||||
|
return client.location.get({ location }, ...requestOptions(signal))
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] {
|
||||||
|
return signal ? [{ signal }] : []
|
||||||
|
}
|
||||||
@@ -91,6 +91,31 @@ export default defineScript({
|
|||||||
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
|
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
|
||||||
await Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized)
|
await Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized)
|
||||||
|
|
||||||
|
llm.queue(
|
||||||
|
llm.toolCall({
|
||||||
|
index: 0,
|
||||||
|
id: "mini-question",
|
||||||
|
name: "question",
|
||||||
|
input: {
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Drive form",
|
||||||
|
question: "Choose the Mini Form answer",
|
||||||
|
options: [{ label: "Accepted", description: "Continue the run" }],
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
llm.finish("tool-calls"),
|
||||||
|
)
|
||||||
|
llm.queue(llm.text("drive mini form complete"))
|
||||||
|
await tmux(["send-keys", "-t", session, "-l", "exercise the form"])
|
||||||
|
await tmux(["send-keys", "-H", "-t", session, "0d"])
|
||||||
|
await waitForPane(session, "Choose the Mini Form answer", 20_000)
|
||||||
|
await tmux(["send-keys", "-H", "-t", session, "0d"])
|
||||||
|
await waitForPane(session, "drive mini form complete", 20_000)
|
||||||
|
|
||||||
llm.queue(
|
llm.queue(
|
||||||
llm.toolCall({
|
llm.toolCall({
|
||||||
index: 0,
|
index: 0,
|
||||||
|
|||||||
@@ -145,11 +145,11 @@ describe("Mini CLI host", () => {
|
|||||||
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
|
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes paths, platform, timing, and diagnostic context", async () => {
|
test("passes frontend host capabilities", async () => {
|
||||||
const directory = await root()
|
const directory = await root()
|
||||||
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
||||||
|
|
||||||
expect(input.paths).toEqual({ home: directory, state: directory, log: directory })
|
expect(input.paths).toEqual({ home: directory })
|
||||||
expect(input.platform).toBe(process.platform)
|
expect(input.platform).toBe(process.platform)
|
||||||
expect(typeof input.files.readText).toBe("function")
|
expect(typeof input.files.readText).toBe("function")
|
||||||
const file = path.join(directory, "attachment.txt")
|
const file = path.join(directory, "attachment.txt")
|
||||||
@@ -157,7 +157,6 @@ describe("Mini CLI host", () => {
|
|||||||
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
|
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
|
||||||
expect(typeof input.startup.showTiming).toBe("boolean")
|
expect(typeof input.startup.showTiming).toBe("boolean")
|
||||||
expect(typeof input.startup.now()).toBe("number")
|
expect(typeof input.startup.now()).toBe("number")
|
||||||
expect(input.diagnostics).toMatchObject({ pid: process.pid, cwd: directory })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges, clears, and repairs persisted model variants", async () => {
|
test("merges, clears, and repairs persisted model variants", async () => {
|
||||||
@@ -186,6 +185,9 @@ describe("Mini CLI host", () => {
|
|||||||
variant: { "openai/gpt-4.1": "low" },
|
variant: { "openai/gpt-4.1": "low" },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await Bun.write(file, JSON.stringify({ variant: { "openai/gpt-5": "default" } }))
|
||||||
|
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
|
||||||
|
|
||||||
await Bun.write(file, "{")
|
await Bun.write(file, "{")
|
||||||
await input.preferences.saveVariant(model, "high")
|
await input.preferences.saveVariant(model, "high")
|
||||||
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
|
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { mergeInput as mergeInteractiveInput } from "../src/mini"
|
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
|
||||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
|
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
|
||||||
|
import { parseSessionTargetModel } from "../src/session-target"
|
||||||
|
|
||||||
async function cli(args: string[]) {
|
async function cli(args: string[]) {
|
||||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||||
@@ -24,26 +26,91 @@ describe("mini command", () => {
|
|||||||
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("constructs a fresh authenticated client for a replacement endpoint", async () => {
|
||||||
|
const authorization: Array<string | null> = []
|
||||||
|
const initial = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch() {
|
||||||
|
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const replacement = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
authorization.push(request.headers.get("authorization"))
|
||||||
|
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const controller = new AbortController()
|
||||||
|
let signal: AbortSignal | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connection = createMiniConnection({
|
||||||
|
endpoint: { url: initial.url.toString() },
|
||||||
|
reconnect: async (next) => {
|
||||||
|
signal = next
|
||||||
|
return {
|
||||||
|
url: replacement.url.toString(),
|
||||||
|
auth: { type: "basic", username: "replacement", password: "secret" },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const client = await connection.reconnect?.(controller.signal)
|
||||||
|
if (!client) throw new Error("Expected a replacement client")
|
||||||
|
await client.health.get()
|
||||||
|
|
||||||
|
expect(client).not.toBe(connection.sdk)
|
||||||
|
expect(signal).toBe(controller.signal)
|
||||||
|
expect(authorization).toEqual([`Basic ${btoa("replacement:secret")}`])
|
||||||
|
expect(createMiniConnection({ endpoint: { url: initial.url.toString() } }).reconnect).toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
initial.stop(true)
|
||||||
|
replacement.stop(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("re-resolves a managed target when the endpoint moves before transport construction", async () => {
|
||||||
|
const initial = OpenCode.make({ baseUrl: "https://initial.opencode.test" })
|
||||||
|
const replacement = OpenCode.make({ baseUrl: "https://replacement.opencode.test" })
|
||||||
|
const controller = new AbortController()
|
||||||
|
const seen: (typeof initial)[] = []
|
||||||
|
let reconnects = 0
|
||||||
|
|
||||||
|
const result = await resolveMiniTarget({
|
||||||
|
sdk: initial,
|
||||||
|
reconnect: async (signal) => {
|
||||||
|
expect(signal).toBe(controller.signal)
|
||||||
|
reconnects++
|
||||||
|
if (reconnects === 1) throw new Error("service still moving")
|
||||||
|
return replacement
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
resolve: async (sdk) => {
|
||||||
|
seen.push(sdk)
|
||||||
|
if (sdk === initial) throw new ClientError("Transport")
|
||||||
|
return "ses-replacement"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(seen).toEqual([initial, replacement])
|
||||||
|
expect(reconnects).toBe(2)
|
||||||
|
expect(result).toEqual({ sdk: replacement, value: "ses-replacement" })
|
||||||
|
})
|
||||||
|
|
||||||
test("merges non-interactive argument and stdin input", () => {
|
test("merges non-interactive argument and stdin input", () => {
|
||||||
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
|
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
|
||||||
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
|
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies a variant to a resumed session's model", () => {
|
|
||||||
expect(
|
|
||||||
pickRunModel(
|
|
||||||
undefined,
|
|
||||||
"high",
|
|
||||||
{ providerID: "session-provider", modelID: "session-model" },
|
|
||||||
{ providerID: "default-provider", modelID: "default-model" },
|
|
||||||
),
|
|
||||||
).toEqual({ providerID: "session-provider", modelID: "session-model" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses model variants from the model reference", () => {
|
test("parses model variants from the model reference", () => {
|
||||||
expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
|
expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
|
||||||
JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
|
JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
|
||||||
)
|
)
|
||||||
|
expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual({
|
||||||
|
providerID: "openrouter",
|
||||||
|
id: "openai/gpt-5",
|
||||||
|
variant: "high",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("is registered in the preview CLI", async () => {
|
test("is registered in the preview CLI", async () => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
|
import { OpenCode, type EventSubscribeOutput, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||||
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
||||||
|
|
||||||
type V2Event = EventSubscribeOutput
|
type V2Event = EventSubscribeOutput
|
||||||
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||||
|
const location = { directory: "/work tree", workspaceID: "wrk_1" }
|
||||||
|
|
||||||
function ok<T>(data: T) {
|
function ok<T>(data: T) {
|
||||||
return Promise.resolve(data)
|
return Promise.resolve(data)
|
||||||
@@ -18,8 +19,8 @@ function form(id: string, sessionID: string): FormInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formCreated(info: FormInfo): V2Event {
|
function formCreated(info: FormInfo, eventLocation = location): V2Event {
|
||||||
return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } }
|
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function prompted(inputID: string): V2Event {
|
function prompted(inputID: string): V2Event {
|
||||||
@@ -92,6 +93,64 @@ function executionFailed(message: string): V2Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function failedTool(inputID: string): V2Event[] {
|
||||||
|
return [
|
||||||
|
prompted(inputID),
|
||||||
|
{
|
||||||
|
id: "evt_failed_tool_input",
|
||||||
|
created: 1,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_failed_tool",
|
||||||
|
callID: "call_failed_tool",
|
||||||
|
name: "shell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evt_failed_tool_called",
|
||||||
|
created: 2,
|
||||||
|
type: "session.tool.called",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_failed_tool",
|
||||||
|
callID: "call_failed_tool",
|
||||||
|
input: { command: "printf partial && false" },
|
||||||
|
executed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evt_failed_tool_progress",
|
||||||
|
created: 3,
|
||||||
|
type: "session.tool.progress",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_failed_tool",
|
||||||
|
callID: "call_failed_tool",
|
||||||
|
structured: { checkpoint: 1 },
|
||||||
|
content: [{ type: "text", text: "partial output" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evt_failed_tool_terminal",
|
||||||
|
created: 4,
|
||||||
|
type: "session.tool.failed",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_failed_tool",
|
||||||
|
callID: "call_failed_tool",
|
||||||
|
error: { type: "unknown", message: "tool failed" },
|
||||||
|
executed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settled(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
||||||
// live events the prompt admission triggers, keyed by the generated message ID.
|
// live events the prompt admission triggers, keyed by the generated message ID.
|
||||||
async function run(input: {
|
async function run(input: {
|
||||||
@@ -100,6 +159,9 @@ async function run(input: {
|
|||||||
attached?: boolean
|
attached?: boolean
|
||||||
format?: "default" | "json"
|
format?: "default" | "json"
|
||||||
compatibility?: "v1"
|
compatibility?: "v1"
|
||||||
|
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
|
||||||
|
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||||
|
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||||
@@ -119,10 +181,18 @@ async function run(input: {
|
|||||||
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
|
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
|
||||||
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
||||||
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
|
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
|
||||||
|
spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never)
|
||||||
spyOn(sdk.form, "list").mockImplementation(
|
spyOn(sdk.form, "list").mockImplementation(
|
||||||
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
||||||
)
|
)
|
||||||
spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
|
spyOn(sdk.form.request, "list").mockImplementation(
|
||||||
|
() =>
|
||||||
|
ok({
|
||||||
|
location: { ...location, project: { id: "proj_1", directory: location.directory } },
|
||||||
|
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
|
||||||
|
}) as never,
|
||||||
|
)
|
||||||
|
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
|
||||||
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
||||||
const messageID = request.id ?? "msg_prompt"
|
const messageID = request.id ?? "msg_prompt"
|
||||||
values.push(...input.turn(messageID))
|
values.push(...input.turn(messageID))
|
||||||
@@ -133,6 +203,7 @@ async function run(input: {
|
|||||||
await runNonInteractivePrompt({
|
await runNonInteractivePrompt({
|
||||||
client: sdk,
|
client: sdk,
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
|
location,
|
||||||
message: "hello",
|
message: "hello",
|
||||||
files: [],
|
files: [],
|
||||||
thinking: false,
|
thinking: false,
|
||||||
@@ -140,8 +211,8 @@ async function run(input: {
|
|||||||
auto: false,
|
auto: false,
|
||||||
attached: input.attached ?? false,
|
attached: input.attached ?? false,
|
||||||
compatibility: input.compatibility,
|
compatibility: input.compatibility,
|
||||||
renderTool: () => Promise.resolve(),
|
renderTool: input.renderTool ?? (() => Promise.resolve()),
|
||||||
renderToolError: () => Promise.resolve(),
|
renderToolError: input.renderToolError ?? (() => Promise.resolve()),
|
||||||
})
|
})
|
||||||
return sdk
|
return sdk
|
||||||
}
|
}
|
||||||
@@ -180,9 +251,20 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
// which must not leave the consume loop waiting forever.
|
// which must not leave the consume loop waiting forever.
|
||||||
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
|
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
|
||||||
})
|
})
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
|
const globalOptions = {
|
||||||
|
headers: {
|
||||||
|
"x-opencode-directory": "%2Fwork%20tree",
|
||||||
|
"x-opencode-workspace": "wrk_1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
|
||||||
|
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
||||||
|
location: { directory: "/work tree", workspace: "wrk_1" },
|
||||||
|
})
|
||||||
|
expect(sdk.question.list).not.toHaveBeenCalled()
|
||||||
|
expect(sdk.question.reject).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("attach mode cancels only session-owned forms", async () => {
|
test("attach mode cancels only session-owned forms", async () => {
|
||||||
@@ -192,9 +274,12 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
|
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
|
||||||
})
|
})
|
||||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||||
expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
|
expect(sdk.form.request.list).not.toHaveBeenCalled()
|
||||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
|
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
|
||||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
|
expect(sdk.form.cancel).not.toHaveBeenCalledWith(
|
||||||
|
{ sessionID: "global", formID: "frm_pending_global" },
|
||||||
|
expect.anything(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
|
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
|
||||||
@@ -250,4 +335,69 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
|
|
||||||
expect(output).toEqual({ stdout: "", stderr: "" })
|
expect(output).toEqual({ stdout: "", stderr: "" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("renders native failed tool output before the terminal error", async () => {
|
||||||
|
const rendered: SessionMessageAssistantTool[] = []
|
||||||
|
const failed: SessionMessageAssistantTool[] = []
|
||||||
|
await capture({
|
||||||
|
turn: failedTool,
|
||||||
|
renderTool: (part) => {
|
||||||
|
rendered.push(part)
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
renderToolError: (part) => {
|
||||||
|
failed.push(part)
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rendered).toMatchObject([
|
||||||
|
{
|
||||||
|
id: "call_failed_tool",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
structured: { checkpoint: 1 },
|
||||||
|
content: [{ type: "text", text: "partial output" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(failed).toMatchObject([
|
||||||
|
{
|
||||||
|
id: "call_failed_tool",
|
||||||
|
state: {
|
||||||
|
status: "error",
|
||||||
|
structured: { checkpoint: 1 },
|
||||||
|
content: [{ type: "text", text: "partial output" }],
|
||||||
|
error: { message: "tool failed" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps failed tool partial output out of the explicit V1 JSON bridge shape", async () => {
|
||||||
|
const output = await capture({ compatibility: "v1", format: "json", turn: failedTool })
|
||||||
|
const events = output.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "tool_use",
|
||||||
|
part: {
|
||||||
|
type: "tool",
|
||||||
|
callID: "call_failed_tool",
|
||||||
|
tool: "shell",
|
||||||
|
state: {
|
||||||
|
status: "error",
|
||||||
|
input: { command: "printf partial && false" },
|
||||||
|
error: "tool failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(events[0].part.state.output).toBeUndefined()
|
||||||
|
expect(events[0].part.state.metadata.structured).toBeUndefined()
|
||||||
|
expect(events[0].part.state.metadata.content).toBeUndefined()
|
||||||
|
expect(output.stderr).toBe("")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
|
import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
|
||||||
|
|
||||||
|
function location(directory: string, workspaceID?: string): LocationGetOutput {
|
||||||
|
return { directory, workspaceID, project: { id: "project", directory } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
projectID: "project",
|
||||||
|
title: id,
|
||||||
|
location: { directory, workspaceID },
|
||||||
|
model,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepare = async (input: { model: ModelRef | undefined; agent: string | undefined }) => ({
|
||||||
|
model: input.model,
|
||||||
|
agent: input.agent,
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => mock.restore())
|
||||||
|
|
||||||
|
describe("session target resolver", () => {
|
||||||
|
test("adopts an explicit Session location and model", async () => {
|
||||||
|
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
|
const selected = session("ses_resume", "/session", "work_1", { providerID: "openai", id: "gpt-5" })
|
||||||
|
spyOn(client.session, "get").mockResolvedValue(selected)
|
||||||
|
spyOn(client.location, "get").mockResolvedValue(location("/session", "work_1"))
|
||||||
|
|
||||||
|
const target = await resolveSessionTarget({ client, session: selected.id, prepare })
|
||||||
|
expect(target).toMatchObject({
|
||||||
|
session: { id: "ses_resume" },
|
||||||
|
location: { directory: "/session", workspaceID: "work_1" },
|
||||||
|
model: { providerID: "openai", id: "gpt-5" },
|
||||||
|
resume: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("paginates to continue the exact implicit workspace", async () => {
|
||||||
|
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
|
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||||
|
const explicit = Array.from({ length: 50 }, (_, index) => session(`ses_${index}`, "/project", `work_${index}`))
|
||||||
|
const list = spyOn(client.session, "list")
|
||||||
|
.mockResolvedValueOnce({ data: explicit, cursor: { next: "page_2" } })
|
||||||
|
.mockResolvedValueOnce({ data: [session("ses_implicit", "/project")], cursor: {} })
|
||||||
|
|
||||||
|
const target = await resolveSessionTarget({ client, location: { directory: "/project" }, continue: true, prepare })
|
||||||
|
expect(list).toHaveBeenCalledTimes(2)
|
||||||
|
expect(target.session.id).toBe("ses_implicit")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prepares a fresh Session at the server Location before creation", async () => {
|
||||||
|
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
|
const order: string[] = []
|
||||||
|
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
|
||||||
|
const create = spyOn(client.session, "create").mockImplementation(async (input) => {
|
||||||
|
order.push("create")
|
||||||
|
expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } })
|
||||||
|
return session("ses_fresh", "/server", "work_1")
|
||||||
|
})
|
||||||
|
|
||||||
|
await resolveSessionTarget({
|
||||||
|
client,
|
||||||
|
agent: "requested",
|
||||||
|
prepare: async (input) => {
|
||||||
|
order.push("prepare")
|
||||||
|
expect(input.location.workspaceID).toBe("work_1")
|
||||||
|
return { model: input.model, agent: "prepared" }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(create).toHaveBeenCalledTimes(1)
|
||||||
|
expect(order).toEqual(["prepare", "create"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not retry an ambiguous Session creation", async () => {
|
||||||
|
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
|
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||||
|
spyOn(client.session, "create").mockRejectedValue(new Error("connection closed after create"))
|
||||||
|
await expect(resolveSessionTarget({ client, prepare })).rejects.toBeInstanceOf(SessionTargetMutationError)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"./context/runtime": "./src/context/runtime.tsx",
|
"./context/runtime": "./src/context/runtime.tsx",
|
||||||
"./context/client": "./src/context/client.tsx",
|
"./context/client": "./src/context/client.tsx",
|
||||||
"./context/theme": "./src/context/theme.tsx",
|
"./context/theme": "./src/context/theme.tsx",
|
||||||
|
"./theme/discovery": "./src/theme/discovery.ts",
|
||||||
"./context/editor": "./src/context/editor.ts",
|
"./context/editor": "./src/context/editor.ts",
|
||||||
"./context/clipboard": "./src/context/clipboard.tsx",
|
"./context/clipboard": "./src/context/clipboard.tsx",
|
||||||
"./attention": "./src/attention.ts",
|
"./attention": "./src/attention.ts",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type ThemeJson,
|
type ThemeJson,
|
||||||
} from "../theme"
|
} from "../theme"
|
||||||
import { generateSystem, terminalMode } from "../theme/system"
|
import { generateSystem, terminalMode } from "../theme/system"
|
||||||
|
import { discoverThemes, themeDirectories } from "../theme/discovery"
|
||||||
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
||||||
import { resolveThemeFile } from "../theme/v2/resolve"
|
import { resolveThemeFile } from "../theme/v2/resolve"
|
||||||
import { migrateV1 } from "../theme/v2/v1-migrate"
|
import { migrateV1 } from "../theme/v2/v1-migrate"
|
||||||
@@ -25,9 +26,6 @@ import { createStore, produce } from "solid-js/store"
|
|||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Glob } from "@opencode-ai/core/util/glob"
|
|
||||||
import { readFile } from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
|
||||||
import { DevTools } from "../devtools"
|
import { DevTools } from "../devtools"
|
||||||
|
|
||||||
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
|
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
|
||||||
@@ -39,12 +37,7 @@ export type ThemeSource = Readonly<{
|
|||||||
|
|
||||||
const themeSource: ThemeSource = {
|
const themeSource: ThemeSource = {
|
||||||
async discover() {
|
async discover() {
|
||||||
const directories = [Global.Path.config]
|
return discoverThemes(themeDirectories(Global.Path.config, process.cwd()))
|
||||||
for (let current = process.cwd(); ; current = path.dirname(current)) {
|
|
||||||
directories.push(path.join(current, ".opencode"))
|
|
||||||
if (path.dirname(current) === current) break
|
|
||||||
}
|
|
||||||
return discoverThemes(directories)
|
|
||||||
},
|
},
|
||||||
subscribeRefresh(refresh) {
|
subscribeRefresh(refresh) {
|
||||||
process.on("SIGUSR2", refresh)
|
process.on("SIGUSR2", refresh)
|
||||||
@@ -52,16 +45,7 @@ const themeSource: ThemeSource = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverThemes(directories: string[]) {
|
export { discoverThemes } from "../theme/discovery"
|
||||||
const result: Record<string, unknown> = {}
|
|
||||||
for (const directory of directories) {
|
|
||||||
const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true })
|
|
||||||
for (const file of files) {
|
|
||||||
result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DEFAULT_THEMES,
|
DEFAULT_THEMES,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentListOutput,
|
AgentListOutput,
|
||||||
CommandListOutput,
|
CommandListOutput,
|
||||||
|
LocationRef,
|
||||||
ModelListOutput,
|
ModelListOutput,
|
||||||
OpenCodeClient,
|
OpenCodeClient,
|
||||||
ProviderListOutput,
|
ProviderListOutput,
|
||||||
@@ -14,45 +15,38 @@ type CurrentSkill = SkillListOutput["data"][number]
|
|||||||
type CurrentProvider = ProviderListOutput["data"][number]
|
type CurrentProvider = ProviderListOutput["data"][number]
|
||||||
type CurrentModel = ModelListOutput["data"][number]
|
type CurrentModel = ModelListOutput["data"][number]
|
||||||
|
|
||||||
function location(directory: string, workspace?: string) {
|
function location(ref: LocationRef) {
|
||||||
return {
|
return {
|
||||||
location: {
|
location: {
|
||||||
directory,
|
directory: ref.directory,
|
||||||
workspace,
|
workspace: ref.workspaceID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultCost(model: CurrentModel) {
|
function defaultCost(model: CurrentModel) {
|
||||||
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
|
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
|
||||||
if (!picked) {
|
if (!picked) return
|
||||||
return undefined
|
return model.cost.every((cost) => cost.input === 0) ? 0 : picked.input
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...picked,
|
|
||||||
input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runAgent(input: CurrentAgent): RunAgent {
|
function runAgent(input: CurrentAgent): RunAgent {
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
|
||||||
mode: input.mode,
|
mode: input.mode,
|
||||||
hidden: input.hidden,
|
hidden: input.hidden,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runCommand(input: CurrentCommand): RunCommand {
|
function runCommand(input: CurrentCommand): RunCommand {
|
||||||
return {
|
return {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runSkill(input: CurrentSkill): RunCommand {
|
function runSkill(input: CurrentSkill): RunCommand {
|
||||||
return {
|
return {
|
||||||
name: input.id,
|
name: input.id,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
@@ -77,13 +71,10 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
|
|||||||
name: model.providerID,
|
name: model.providerID,
|
||||||
models: {},
|
models: {},
|
||||||
}
|
}
|
||||||
|
const cost = defaultCost(model)
|
||||||
provider.models[model.id] = {
|
provider.models[model.id] = {
|
||||||
id: model.id,
|
|
||||||
providerID: model.providerID,
|
|
||||||
name: model.name,
|
name: model.name,
|
||||||
capabilities: model.capabilities,
|
cost: cost === undefined ? undefined : { input: cost },
|
||||||
cost: defaultCost(model),
|
|
||||||
limit: model.limit,
|
|
||||||
status: model.status,
|
status: model.status,
|
||||||
variants: Object.fromEntries((model.variants ?? []).map((variant) => [variant.id, {}])),
|
variants: Object.fromEntries((model.variants ?? []).map((variant) => [variant.id, {}])),
|
||||||
}
|
}
|
||||||
@@ -95,43 +86,103 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
|
|||||||
|
|
||||||
export async function waitForDefaultModel(input: {
|
export async function waitForDefaultModel(input: {
|
||||||
sdk: OpenCodeClient
|
sdk: OpenCodeClient
|
||||||
directory: string
|
location: LocationRef
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
|
requestTimeoutMs?: number
|
||||||
active?: () => boolean
|
active?: () => boolean
|
||||||
|
signal?: AbortSignal
|
||||||
}): Promise<{ providerID: string; modelID: string } | undefined> {
|
}): Promise<{ providerID: string; modelID: string } | undefined> {
|
||||||
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
||||||
while (Date.now() < deadline && (input.active?.() ?? true)) {
|
while (Date.now() < deadline && !input.signal?.aborted && (input.active?.() ?? true)) {
|
||||||
const model = await input.sdk.model
|
const controller = new AbortController()
|
||||||
.default(location(input.directory))
|
const timeout = setTimeout(
|
||||||
.then((result) => result.data)
|
() => controller.abort(),
|
||||||
.catch(() => undefined)
|
Math.min(input.requestTimeoutMs ?? 1_000, Math.max(1, deadline - Date.now())),
|
||||||
|
)
|
||||||
|
const abort = () => controller.abort()
|
||||||
|
input.signal?.addEventListener("abort", abort, { once: true })
|
||||||
|
const model = await abortable(
|
||||||
|
input.sdk.model
|
||||||
|
.default(location(input.location), { signal: controller.signal })
|
||||||
|
.then((result) => result.data)
|
||||||
|
.catch(() => undefined),
|
||||||
|
controller.signal,
|
||||||
|
).finally(() => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
input.signal?.removeEventListener("abort", abort)
|
||||||
|
})
|
||||||
if (model) return { providerID: model.providerID, modelID: model.id }
|
if (model) return { providerID: model.providerID, modelID: model.id }
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
await wait(25, input.signal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
|
function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefined> {
|
||||||
const result = await sdk.agent.list(location(directory))
|
if (signal.aborted) return Promise.resolve(undefined)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const abort = () => {
|
||||||
|
signal.removeEventListener("abort", abort)
|
||||||
|
resolve(undefined)
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", abort, { once: true })
|
||||||
|
void task.then((value) => {
|
||||||
|
signal.removeEventListener("abort", abort)
|
||||||
|
resolve(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(delay: number, signal?: AbortSignal) {
|
||||||
|
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
|
||||||
|
if (signal.aborted) return Promise.resolve()
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(done, delay)
|
||||||
|
signal.addEventListener("abort", done, { once: true })
|
||||||
|
function done() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal?.removeEventListener("abort", done)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRunAgents(sdk: OpenCodeClient, ref: LocationRef, signal?: AbortSignal): Promise<RunAgent[]> {
|
||||||
|
const result = await sdk.agent.list(location(ref), ...requestOptions(signal))
|
||||||
return result.data.map(runAgent)
|
return result.data.map(runAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise<RunCommand[]> {
|
export async function loadRunCommands(
|
||||||
|
sdk: OpenCodeClient,
|
||||||
|
ref: LocationRef,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RunCommand[]> {
|
||||||
const [commands, skills] = await Promise.all([
|
const [commands, skills] = await Promise.all([
|
||||||
sdk.command.list(location(directory)),
|
sdk.command.list(location(ref), ...requestOptions(signal)),
|
||||||
sdk.skill.list(location(directory)),
|
sdk.skill.list(location(ref), ...requestOptions(signal)),
|
||||||
])
|
])
|
||||||
return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)]
|
return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise<RunReference[]> {
|
export async function loadRunReferences(
|
||||||
const result = await sdk.reference.list(location(directory))
|
sdk: OpenCodeClient,
|
||||||
|
ref: LocationRef,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RunReference[]> {
|
||||||
|
const result = await sdk.reference.list(location(ref), ...requestOptions(signal))
|
||||||
return result.data.filter((reference) => !reference.hidden)
|
return result.data.filter((reference) => !reference.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise<RunProvider[]> {
|
export async function loadRunProviders(
|
||||||
|
sdk: OpenCodeClient,
|
||||||
|
ref: LocationRef,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RunProvider[]> {
|
||||||
const [providers, models] = await Promise.all([
|
const [providers, models] = await Promise.all([
|
||||||
sdk.provider.list(location(directory)),
|
sdk.provider.list(location(ref), ...requestOptions(signal)),
|
||||||
sdk.model.list(location(directory)),
|
sdk.model.list(location(ref), ...requestOptions(signal)),
|
||||||
])
|
])
|
||||||
return runProviders([...providers.data], [...models.data])
|
return runProviders([...providers.data], [...models.data])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] {
|
||||||
|
return signal ? [{ signal }] : []
|
||||||
|
}
|
||||||
|
|||||||
+239
-239
@@ -2,28 +2,30 @@
|
|||||||
//
|
//
|
||||||
// Enabled with `--demo`. Intercepts prompt submissions and drives the same
|
// Enabled with `--demo`. Intercepts prompt submissions and drives the same
|
||||||
// presentation commits and footer actions as the live transport. This
|
// presentation commits and footer actions as the live transport. This
|
||||||
// lets you test scrollback formatting, permission UI, question UI, and tool
|
// lets you test scrollback formatting, permission UI, canonical Form UI, and tool
|
||||||
// snapshots without making actual model calls. Pass a demo slash command as
|
// snapshots without making actual model calls. Pass a demo slash command as
|
||||||
// the initial interactive message to trigger a preview immediately.
|
// the initial interactive message to trigger a preview immediately.
|
||||||
//
|
//
|
||||||
// Slash commands:
|
// Slash commands:
|
||||||
// /permission [kind] → triggers a permission request variant
|
// /permission [kind] → triggers a permission request variant
|
||||||
// /question [kind] → triggers a question request variant
|
// /form [kind] → triggers a canonical Form request variant
|
||||||
// /fmt <kind> → emits a specific tool/text type (text, reasoning, shell,
|
// /fmt <kind> → emits a specific tool/text type (text, reasoning, shell,
|
||||||
// write, edit, patch, task, question, error, mix)
|
// write, edit, patch, subagent, question, error, mix)
|
||||||
//
|
//
|
||||||
// Demo mode also handles permission and question replies locally, completing
|
// Demo mode handles permission and Form replies locally, completing or failing
|
||||||
// or failing the synthetic tool parts as appropriate.
|
// the synthetic tool parts through the same callbacks used by the live footer.
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
|
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||||
import { writeSessionOutput } from "./stream"
|
import { writeSessionOutput } from "./stream"
|
||||||
import { toolCommit } from "./stream-v2.subagent"
|
import { toolCommit } from "./stream-v2.subagent"
|
||||||
import type {
|
import type {
|
||||||
FooterApi,
|
FooterApi,
|
||||||
MiniToolPart,
|
FooterView,
|
||||||
|
FormCancel,
|
||||||
|
FormReply,
|
||||||
|
MiniFormRequest,
|
||||||
|
MiniPermissionRequest,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
QuestionReject,
|
|
||||||
QuestionReply,
|
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
StreamCommit,
|
StreamCommit,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
@@ -37,25 +39,25 @@ const KINDS = [
|
|||||||
"write",
|
"write",
|
||||||
"edit",
|
"edit",
|
||||||
"patch",
|
"patch",
|
||||||
"task",
|
"subagent",
|
||||||
"question",
|
"question",
|
||||||
"error",
|
"error",
|
||||||
"mix",
|
"mix",
|
||||||
]
|
]
|
||||||
const PERMISSIONS = ["edit", "shell", "read", "task", "external", "doom"] as const
|
const PERMISSIONS = ["edit", "shell", "read", "subagent", "external", "doom"] as const
|
||||||
const QUESTIONS = ["multi", "single", "checklist", "custom"] as const
|
const FORMS = ["question", "external"] as const
|
||||||
|
|
||||||
type PermissionKind = (typeof PERMISSIONS)[number]
|
type PermissionKind = (typeof PERMISSIONS)[number]
|
||||||
type QuestionKind = (typeof QUESTIONS)[number]
|
type FormKind = (typeof FORMS)[number]
|
||||||
|
|
||||||
function permissionKind(value: string | undefined): PermissionKind | undefined {
|
function permissionKind(value: string | undefined): PermissionKind | undefined {
|
||||||
const next = (value || "edit").toLowerCase()
|
const next = (value || "edit").toLowerCase()
|
||||||
return PERMISSIONS.find((item) => item === next)
|
return PERMISSIONS.find((item) => item === next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function questionKind(value: string | undefined): QuestionKind | undefined {
|
function formKind(value: string | undefined): FormKind | undefined {
|
||||||
const next = (value || "multi").toLowerCase()
|
const next = (value || "question").toLowerCase()
|
||||||
return QUESTIONS.find((item) => item === next)
|
return FORMS.find((item) => item === next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAMPLE_MARKDOWN = [
|
const SAMPLE_MARKDOWN = [
|
||||||
@@ -111,12 +113,14 @@ type Ref = {
|
|||||||
part: string
|
part: string
|
||||||
call: string
|
call: string
|
||||||
tool: string
|
tool: string
|
||||||
input: Record<string, unknown>
|
input: Record<string, JsonValue>
|
||||||
start: number
|
start: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type Ask = {
|
type FormRequest = {
|
||||||
ref: Ref
|
ref: Ref
|
||||||
|
kind: FormKind
|
||||||
|
request: MiniFormRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
type Perm = {
|
type Perm = {
|
||||||
@@ -124,7 +128,7 @@ type Perm = {
|
|||||||
done: {
|
done: {
|
||||||
title: string
|
title: string
|
||||||
output: string
|
output: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, JsonValue>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +136,7 @@ type Permit = {
|
|||||||
ref: Ref
|
ref: Ref
|
||||||
permission: string
|
permission: string
|
||||||
patterns: string[]
|
patterns: string[]
|
||||||
metadata?: PermissionV2Request["metadata"]
|
metadata?: MiniPermissionRequest["metadata"]
|
||||||
always: string[]
|
always: string[]
|
||||||
done: Perm["done"]
|
done: Perm["done"]
|
||||||
}
|
}
|
||||||
@@ -145,9 +149,9 @@ type State = {
|
|||||||
part: number
|
part: number
|
||||||
call: number
|
call: number
|
||||||
perm: number
|
perm: number
|
||||||
ask: number
|
form: number
|
||||||
perms: Map<string, Perm>
|
perms: Map<string, Perm>
|
||||||
asks: Map<string, Ask>
|
forms: Map<string, FormRequest>
|
||||||
started: Set<string>
|
started: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +177,7 @@ function clearSubagent(footer: FooterApi): void {
|
|||||||
tabs: [],
|
tabs: [],
|
||||||
details: {},
|
details: {},
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
forms: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -182,13 +186,10 @@ function showSubagent(
|
|||||||
state: State,
|
state: State,
|
||||||
input: {
|
input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
partID: string
|
|
||||||
callID: string
|
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
status: "running" | "completed" | "cancelled" | "error"
|
status: "running" | "completed" | "cancelled" | "error"
|
||||||
title?: string
|
title?: string
|
||||||
toolCalls?: number
|
|
||||||
commits: StreamCommit[]
|
commits: StreamCommit[]
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -198,24 +199,20 @@ function showSubagent(
|
|||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
partID: input.partID,
|
|
||||||
callID: input.callID,
|
|
||||||
label: input.label,
|
label: input.label,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
toolCalls: input.toolCalls,
|
|
||||||
lastUpdatedAt: Date.now(),
|
lastUpdatedAt: Date.now(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
[input.sessionID]: {
|
[input.sessionID]: {
|
||||||
sessionID: input.sessionID,
|
|
||||||
commits: input.commits,
|
commits: input.commits,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
forms: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -256,20 +253,20 @@ function split(text: string): string[] {
|
|||||||
return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)]
|
return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string {
|
function take(state: State, key: "msg" | "part" | "call" | "perm", prefix: string): string {
|
||||||
state[key] += 1
|
state[key] += 1
|
||||||
return `demo_${prefix}_${state[key]}`
|
return `demo_${prefix}_${state[key]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void {
|
function present(state: State, commits: StreamCommit[], view?: FooterView): void {
|
||||||
writeSessionOutput(
|
writeSessionOutput(
|
||||||
{ footer: state.footer },
|
{ footer: state.footer },
|
||||||
{
|
{
|
||||||
commits,
|
commits,
|
||||||
footer: view
|
footer: view
|
||||||
? {
|
? {
|
||||||
view: "action" in view ? { type: "permission", request: view } : { type: "question", request: view },
|
view,
|
||||||
patch: { status: "action" in view ? "awaiting permission" : "awaiting answer" },
|
patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
@@ -295,7 +292,9 @@ async function emitText(state: State, body: string, signal?: AbortSignal): Promi
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }])
|
present(state, [
|
||||||
|
{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part },
|
||||||
|
])
|
||||||
await wait(45, signal)
|
await wait(45, signal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +325,7 @@ async function emitReasoning(state: State, body: string, signal?: AbortSignal):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function make(state: State, tool: string, input: Record<string, unknown>): Ref {
|
function make(state: State, tool: string, input: Record<string, JsonValue>): Ref {
|
||||||
return {
|
return {
|
||||||
msg: open(state),
|
msg: open(state),
|
||||||
part: take(state, "part", "part"),
|
part: take(state, "part", "part"),
|
||||||
@@ -337,28 +336,21 @@ function make(state: State, tool: string, input: Record<string, unknown>): Ref {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
|
function startTool(state: State, ref: Ref, structured: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
|
||||||
state.started.add(ref.part)
|
state.started.add(ref.part)
|
||||||
present(
|
const part = {
|
||||||
state,
|
type: "tool" as const,
|
||||||
[
|
id: ref.call,
|
||||||
toolCommit(
|
name: ref.tool,
|
||||||
{
|
state: { status: "running" as const, input: ref.input, structured, content: [] },
|
||||||
id: ref.part,
|
time: { created: ref.start, ran: ref.start },
|
||||||
sessionID: state.id,
|
}
|
||||||
messageID: ref.msg,
|
present(state, [toolCommit(part, ref.msg, "start")])
|
||||||
callID: ref.call,
|
return part
|
||||||
tool: ref.tool,
|
|
||||||
state: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
|
|
||||||
},
|
|
||||||
"start",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function askPermission(state: State, item: Permit): void {
|
function askPermission(state: State, item: Permit): void {
|
||||||
startTool(state, item.ref)
|
const tool = startTool(state, item.ref)
|
||||||
|
|
||||||
const id = take(state, "perm", "perm")
|
const id = take(state, "perm", "perm")
|
||||||
state.perms.set(id, {
|
state.perms.set(id, {
|
||||||
@@ -367,13 +359,17 @@ function askPermission(state: State, item: Permit): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
present(state, [], {
|
present(state, [], {
|
||||||
id,
|
type: "permission",
|
||||||
sessionID: state.id,
|
request: {
|
||||||
action: item.permission,
|
id,
|
||||||
resources: item.patterns,
|
sessionID: state.id,
|
||||||
metadata: item.metadata ?? {},
|
action: item.permission,
|
||||||
save: item.always,
|
resources: item.patterns,
|
||||||
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
|
metadata: item.metadata ?? {},
|
||||||
|
save: item.always,
|
||||||
|
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
|
||||||
|
tool,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,52 +379,46 @@ function doneTool(
|
|||||||
output: {
|
output: {
|
||||||
title: string
|
title: string
|
||||||
output: string
|
output: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, JsonValue>
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||||
const part: MiniToolPart = {
|
const part: SessionMessageAssistantTool = {
|
||||||
id: ref.part,
|
type: "tool",
|
||||||
sessionID: state.id,
|
id: ref.call,
|
||||||
messageID: ref.msg,
|
name: ref.tool,
|
||||||
callID: ref.call,
|
|
||||||
tool: ref.tool,
|
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: ref.input,
|
input: ref.input,
|
||||||
output: output.output,
|
content: output.output ? [{ type: "text", text: output.output }] : [],
|
||||||
title: output.title,
|
structured: output.metadata ?? {},
|
||||||
metadata: output.metadata ?? {},
|
|
||||||
time: { start: ref.start, end: Date.now() },
|
|
||||||
},
|
},
|
||||||
|
time: { created: ref.start, ran: ref.start, completed: Date.now() },
|
||||||
}
|
}
|
||||||
present(state, [toolCommit(part, output.output ? "progress" : "final")])
|
present(state, [toolCommit(part, ref.msg, output.output ? "progress" : "final")])
|
||||||
}
|
}
|
||||||
|
|
||||||
function failTool(state: State, ref: Ref, error: string): void {
|
function failTool(state: State, ref: Ref, error: string): void {
|
||||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||||
present(
|
present(state, [
|
||||||
state,
|
toolCommit(
|
||||||
[
|
{
|
||||||
toolCommit(
|
type: "tool",
|
||||||
{
|
id: ref.call,
|
||||||
id: ref.part,
|
name: ref.tool,
|
||||||
sessionID: state.id,
|
state: {
|
||||||
messageID: ref.msg,
|
status: "error",
|
||||||
callID: ref.call,
|
input: ref.input,
|
||||||
tool: ref.tool,
|
error: { type: "unknown", message: error },
|
||||||
state: {
|
structured: {},
|
||||||
status: "error",
|
content: [],
|
||||||
input: ref.input,
|
|
||||||
error,
|
|
||||||
metadata: {},
|
|
||||||
time: { start: ref.start, end: Date.now() },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"final",
|
time: { created: ref.start, ran: ref.start, completed: Date.now() },
|
||||||
),
|
},
|
||||||
],
|
ref.msg,
|
||||||
)
|
"final",
|
||||||
|
),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitError(state: State, text: string): void {
|
function emitError(state: State, text: string): void {
|
||||||
@@ -447,7 +437,7 @@ async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
|
|||||||
title: "git status",
|
title: "git status",
|
||||||
output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
|
output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
|
||||||
metadata: {
|
metadata: {
|
||||||
exitCode: 0,
|
exit: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -455,7 +445,7 @@ async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
|
|||||||
function emitWrite(state: State): void {
|
function emitWrite(state: State): void {
|
||||||
const file = path.join(process.cwd(), "src", "demo-format.ts")
|
const file = path.join(process.cwd(), "src", "demo-format.ts")
|
||||||
const ref = make(state, "write", {
|
const ref = make(state, "write", {
|
||||||
filePath: file,
|
path: file,
|
||||||
content: "export const demo = 42\n",
|
content: "export const demo = 42\n",
|
||||||
})
|
})
|
||||||
doneTool(state, ref, {
|
doneTool(state, ref, {
|
||||||
@@ -468,13 +458,19 @@ function emitWrite(state: State): void {
|
|||||||
function emitEdit(state: State): void {
|
function emitEdit(state: State): void {
|
||||||
const file = path.join(process.cwd(), "src", "demo-format.ts")
|
const file = path.join(process.cwd(), "src", "demo-format.ts")
|
||||||
const ref = make(state, "edit", {
|
const ref = make(state, "edit", {
|
||||||
filePath: file,
|
path: file,
|
||||||
})
|
})
|
||||||
doneTool(state, ref, {
|
doneTool(state, ref, {
|
||||||
title: "edit",
|
title: "edit",
|
||||||
output: "",
|
output: "",
|
||||||
metadata: {
|
metadata: {
|
||||||
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
files: [
|
||||||
|
{
|
||||||
|
file,
|
||||||
|
status: "modified",
|
||||||
|
patch: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -490,17 +486,15 @@ function emitPatch(state: State): void {
|
|||||||
metadata: {
|
metadata: {
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
type: "update",
|
status: "modified",
|
||||||
filePath: file,
|
file,
|
||||||
relativePath: "src/demo-format.ts",
|
patch: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
||||||
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
|
||||||
deletions: 1,
|
deletions: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "add",
|
status: "added",
|
||||||
filePath: path.join(process.cwd(), "README-demo.md"),
|
file: path.join(process.cwd(), "README-demo.md"),
|
||||||
relativePath: "README-demo.md",
|
patch: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
|
||||||
diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
|
|
||||||
deletions: 0,
|
deletions: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -509,46 +503,41 @@ function emitPatch(state: State): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitTask(state: State): void {
|
function emitTask(state: State): void {
|
||||||
const ref = make(state, "task", {
|
const ref = make(state, "subagent", {
|
||||||
description: "Scan run/* for reducer touchpoints",
|
description: "Scan run/* for reducer touchpoints",
|
||||||
subagent_type: "explore",
|
agent: "explore",
|
||||||
})
|
})
|
||||||
doneTool(state, ref, {
|
doneTool(state, ref, {
|
||||||
title: "Reducer touchpoints found",
|
title: "Reducer touchpoints found",
|
||||||
output: "",
|
output: "",
|
||||||
metadata: {
|
metadata: {
|
||||||
toolcalls: 4,
|
sessionID: "sub_demo_1",
|
||||||
sessionId: "sub_demo_1",
|
status: "completed",
|
||||||
|
output: "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const part = {
|
const part = {
|
||||||
id: "sub_demo_tool_1",
|
|
||||||
type: "tool",
|
type: "tool",
|
||||||
sessionID: "sub_demo_1",
|
id: "sub_demo_call_1",
|
||||||
messageID: "sub_demo_msg_tool",
|
name: "read",
|
||||||
callID: "sub_demo_call_1",
|
|
||||||
tool: "read",
|
|
||||||
state: {
|
state: {
|
||||||
status: "running",
|
status: "running",
|
||||||
input: {
|
input: {
|
||||||
filePath: "packages/tui/src/mini/stream.ts",
|
path: "packages/tui/src/mini/stream.ts",
|
||||||
offset: 1,
|
offset: 1,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
},
|
},
|
||||||
time: {
|
structured: {},
|
||||||
start: Date.now(),
|
content: [],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} satisfies MiniToolPart
|
time: { created: Date.now(), ran: Date.now() },
|
||||||
|
} satisfies SessionMessageAssistantTool
|
||||||
showSubagent(state, {
|
showSubagent(state, {
|
||||||
sessionID: "sub_demo_1",
|
sessionID: "sub_demo_1",
|
||||||
partID: ref.part,
|
|
||||||
callID: ref.call,
|
|
||||||
label: "Explore",
|
label: "Explore",
|
||||||
description: "Scan run/* for reducer touchpoints",
|
description: "Scan run/* for reducer touchpoints",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
title: "Reducer touchpoints found",
|
title: "Reducer touchpoints found",
|
||||||
toolCalls: 4,
|
|
||||||
commits: [
|
commits: [
|
||||||
{
|
{
|
||||||
kind: "user",
|
kind: "user",
|
||||||
@@ -597,6 +586,7 @@ function emitQuestionTool(state: State): void {
|
|||||||
{ label: "Code", description: "Show code block" },
|
{ label: "Code", description: "Show code block" },
|
||||||
],
|
],
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
custom: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Extras",
|
header: "Extras",
|
||||||
@@ -639,7 +629,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
|||||||
title: "git status --short",
|
title: "git status --short",
|
||||||
output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
|
output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
|
||||||
metadata: {
|
metadata: {
|
||||||
exitCode: 0,
|
exit: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -649,7 +639,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
|||||||
if (kind === "read") {
|
if (kind === "read") {
|
||||||
const target = path.join(root, "package.json")
|
const target = path.join(root, "package.json")
|
||||||
const ref = make(state, "read", {
|
const ref = make(state, "read", {
|
||||||
filePath: target,
|
path: target,
|
||||||
offset: 1,
|
offset: 1,
|
||||||
limit: 80,
|
limit: 80,
|
||||||
})
|
})
|
||||||
@@ -667,22 +657,23 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "task") {
|
if (kind === "subagent") {
|
||||||
const ref = make(state, "task", {
|
const ref = make(state, "subagent", {
|
||||||
description: "Inspect footer spacing across direct-mode prompts",
|
description: "Inspect footer spacing across direct-mode prompts",
|
||||||
subagent_type: "explore",
|
agent: "explore",
|
||||||
})
|
})
|
||||||
askPermission(state, {
|
askPermission(state, {
|
||||||
ref,
|
ref,
|
||||||
permission: "task",
|
permission: "subagent",
|
||||||
patterns: ["explore"],
|
patterns: ["explore"],
|
||||||
always: ["*"],
|
always: ["*"],
|
||||||
done: {
|
done: {
|
||||||
title: "Footer spacing checked",
|
title: "Footer spacing checked",
|
||||||
output: "",
|
output: "",
|
||||||
metadata: {
|
metadata: {
|
||||||
toolcalls: 3,
|
sessionID: "sub_demo_perm_1",
|
||||||
sessionId: "sub_demo_perm_1",
|
status: "completed",
|
||||||
|
output: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -693,7 +684,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
|||||||
const dir = path.join(path.dirname(root), "demo-shared")
|
const dir = path.join(path.dirname(root), "demo-shared")
|
||||||
const target = path.join(dir, "README.md")
|
const target = path.join(dir, "README.md")
|
||||||
const ref = make(state, "read", {
|
const ref = make(state, "read", {
|
||||||
filePath: target,
|
path: target,
|
||||||
offset: 1,
|
offset: 1,
|
||||||
limit: 40,
|
limit: 40,
|
||||||
})
|
})
|
||||||
@@ -716,9 +707,9 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "doom") {
|
if (kind === "doom") {
|
||||||
const ref = make(state, "task", {
|
const ref = make(state, "subagent", {
|
||||||
description: "Retry the formatter after repeated failures",
|
description: "Retry the formatter after repeated failures",
|
||||||
subagent_type: "general",
|
agent: "general",
|
||||||
})
|
})
|
||||||
askPermission(state, {
|
askPermission(state, {
|
||||||
ref,
|
ref,
|
||||||
@@ -736,9 +727,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
|||||||
|
|
||||||
const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n"
|
const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n"
|
||||||
const ref = make(state, "edit", {
|
const ref = make(state, "edit", {
|
||||||
filePath: file,
|
path: file,
|
||||||
filepath: file,
|
|
||||||
diff,
|
|
||||||
})
|
})
|
||||||
askPermission(state, {
|
askPermission(state, {
|
||||||
ref,
|
ref,
|
||||||
@@ -749,96 +738,98 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
|||||||
title: "edit",
|
title: "edit",
|
||||||
output: "",
|
output: "",
|
||||||
metadata: {
|
metadata: {
|
||||||
diff,
|
files: [{ file, status: "modified", patch: diff }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
|
function demoForm(kind: FormKind): { title: string; fields: MiniFormRequest["fields"]; questions?: JsonValue[] } {
|
||||||
const questions = (() => {
|
if (kind === "question") {
|
||||||
if (kind === "single") {
|
const questions: JsonValue[] = [
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: "Mode",
|
|
||||||
question: "Which footer should be the reference for spacing checks?",
|
|
||||||
options: [
|
|
||||||
{ label: "Permission", description: "Inspect the permission footer" },
|
|
||||||
{ label: "Question", description: "Keep this question footer open" },
|
|
||||||
{ label: "Prompt", description: "Return to the normal composer" },
|
|
||||||
],
|
|
||||||
multiple: false,
|
|
||||||
custom: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "checklist") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: "Checks",
|
|
||||||
question: "Select the direct-mode cases you want to inspect next",
|
|
||||||
options: [
|
|
||||||
{ label: "Diff", description: "Show an edit diff in the footer" },
|
|
||||||
{ label: "Task", description: "Show a structured task summary" },
|
|
||||||
{ label: "Error", description: "Show an error transcript row" },
|
|
||||||
],
|
|
||||||
multiple: true,
|
|
||||||
custom: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "custom") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: "Reply",
|
|
||||||
question: "What custom answer should appear in the footer preview?",
|
|
||||||
options: [
|
|
||||||
{ label: "Short note", description: "Keep the answer to one line" },
|
|
||||||
{ label: "Wrapped note", description: "Use a longer answer to test wrapping" },
|
|
||||||
],
|
|
||||||
multiple: false,
|
|
||||||
custom: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
header: "Layout",
|
header: "Layout",
|
||||||
question: "Which footer view should stay active while testing?",
|
question: "Which footer view should be the reference for spacing checks?",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Prompt", description: "Return to prompt" },
|
{ label: "Form", description: "Inspect the canonical Form footer" },
|
||||||
{ label: "Question", description: "Keep question open" },
|
{ label: "Prompt", description: "Return to the normal composer" },
|
||||||
],
|
],
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
custom: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Rows",
|
header: "Checks",
|
||||||
question: "Pick formatting previews",
|
question: "Pick formatting previews",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Diff", description: "Emit edit diff" },
|
{ label: "Diff", description: "Emit an edit diff" },
|
||||||
{ label: "Task", description: "Emit task card" },
|
{ label: "Subagent", description: "Emit a subagent card" },
|
||||||
],
|
],
|
||||||
multiple: true,
|
multiple: true,
|
||||||
custom: true,
|
custom: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})()
|
return {
|
||||||
|
title: "Questions",
|
||||||
|
questions,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "q0",
|
||||||
|
title: "Layout",
|
||||||
|
description: "Which footer view should be the reference for spacing checks?",
|
||||||
|
type: "string",
|
||||||
|
options: [
|
||||||
|
{ value: "Form", label: "Form", description: "Inspect the canonical Form footer" },
|
||||||
|
{ value: "Prompt", label: "Prompt", description: "Return to the normal composer" },
|
||||||
|
],
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "q1",
|
||||||
|
title: "Checks",
|
||||||
|
description: "Pick formatting previews",
|
||||||
|
type: "multiselect",
|
||||||
|
options: [
|
||||||
|
{ value: "Diff", label: "Diff", description: "Emit an edit diff" },
|
||||||
|
{ value: "Subagent", label: "Subagent", description: "Emit a subagent card" },
|
||||||
|
],
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: "MCP authorization",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "authorization",
|
||||||
|
type: "external",
|
||||||
|
url: "https://example.com/opencode-demo",
|
||||||
|
title: "Authorize demo MCP server",
|
||||||
|
description: "Complete authorization in your browser",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ref = make(state, "question", { questions })
|
function emitForm(state: State, kind: FormKind = "question"): void {
|
||||||
startTool(state, ref)
|
const form = demoForm(kind)
|
||||||
|
const ref = make(state, kind === "question" ? "question" : "mcp_demo", {
|
||||||
const id = take(state, "ask", "ask")
|
...(form.questions ? { questions: form.questions } : { form: kind }),
|
||||||
state.asks.set(id, { ref })
|
|
||||||
|
|
||||||
present(state, [], {
|
|
||||||
id,
|
|
||||||
sessionID: state.id,
|
|
||||||
questions,
|
|
||||||
tool: { messageID: ref.msg, callID: ref.call },
|
|
||||||
})
|
})
|
||||||
|
startTool(state, ref)
|
||||||
|
state.form++
|
||||||
|
const request: MiniFormRequest = {
|
||||||
|
id: `frm_demo_${state.form}`,
|
||||||
|
sessionID: state.id,
|
||||||
|
title: form.title,
|
||||||
|
metadata:
|
||||||
|
kind === "question"
|
||||||
|
? { kind: "question", tool: { messageID: ref.msg, callID: ref.call } }
|
||||||
|
: { kind: "mcp", message: `Synthetic ${kind} MCP elicitation` },
|
||||||
|
fields: form.fields,
|
||||||
|
}
|
||||||
|
state.forms.set(request.id, { ref, kind, request })
|
||||||
|
present(state, [], { type: "form", request })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
|
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
|
||||||
@@ -882,7 +873,7 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "task") {
|
if (kind === "subagent") {
|
||||||
emitTask(state)
|
emitTask(state)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -921,11 +912,12 @@ function intro(state: State): void {
|
|||||||
[
|
[
|
||||||
"Demo slash commands enabled for interactive mode.",
|
"Demo slash commands enabled for interactive mode.",
|
||||||
`- /permission [kind] (${PERMISSIONS.join(", ")})`,
|
`- /permission [kind] (${PERMISSIONS.join(", ")})`,
|
||||||
`- /question [kind] (${QUESTIONS.join(", ")})`,
|
`- /form [kind] (${FORMS.join(", ")})`,
|
||||||
`- /fmt <kind> (${KINDS.join(", ")})`,
|
`- /fmt <kind> (${KINDS.join(", ")})`,
|
||||||
"Examples:",
|
"Examples:",
|
||||||
"- /permission shell",
|
"- /permission shell",
|
||||||
"- /question custom",
|
"- /form question",
|
||||||
|
"- /form external",
|
||||||
"- /fmt markdown",
|
"- /fmt markdown",
|
||||||
"- /fmt table",
|
"- /fmt table",
|
||||||
"- /fmt text your custom text",
|
"- /fmt text your custom text",
|
||||||
@@ -942,9 +934,9 @@ export function createRunDemo(input: Input) {
|
|||||||
part: 0,
|
part: 0,
|
||||||
call: 0,
|
call: 0,
|
||||||
perm: 0,
|
perm: 0,
|
||||||
ask: 0,
|
form: 0,
|
||||||
perms: new Map(),
|
perms: new Map(),
|
||||||
asks: new Map(),
|
forms: new Map(),
|
||||||
started: new Set(),
|
started: new Set(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,14 +967,14 @@ export function createRunDemo(input: Input) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd === "/question") {
|
if (cmd === "/form") {
|
||||||
const kind = questionKind(list[1])
|
const kind = formKind(list[1])
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`)
|
note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
emitQuestion(state, kind)
|
emitForm(state, kind)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,33 +1016,41 @@ export function createRunDemo(input: Input) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const questionReply = (input: QuestionReply): boolean => {
|
const formReply = (input: FormReply): boolean => {
|
||||||
const ask = state.asks.get(input.requestID)
|
const form = state.forms.get(input.formID)
|
||||||
if (!ask || !input.answers) {
|
if (!form || input.sessionID !== form.request.sessionID) return false
|
||||||
return false
|
state.forms.delete(input.formID)
|
||||||
}
|
|
||||||
|
|
||||||
state.asks.delete(input.requestID)
|
|
||||||
clearBlocker(state)
|
clearBlocker(state)
|
||||||
doneTool(state, ask.ref, {
|
if (form.kind === "question") {
|
||||||
title: "question",
|
doneTool(state, form.ref, {
|
||||||
output: "",
|
title: "question",
|
||||||
metadata: {
|
output: "",
|
||||||
answers: input.answers,
|
metadata: {
|
||||||
},
|
answers: form.request.fields.map((field) => {
|
||||||
|
const value = input.answer[field.key]
|
||||||
|
if (value === undefined) return []
|
||||||
|
return Array.isArray(value) ? [...value] : [String(value)]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
doneTool(state, form.ref, {
|
||||||
|
title: form.request.title,
|
||||||
|
output: `Form submitted: ${Object.entries(input.answer)
|
||||||
|
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(", ") : String(value)}`)
|
||||||
|
.join("; ")}\n`,
|
||||||
|
metadata: { answer: input.answer },
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const questionReject = (input: QuestionReject): boolean => {
|
const formCancel = (input: FormCancel): boolean => {
|
||||||
const ask = state.asks.get(input.requestID)
|
const form = state.forms.get(input.formID)
|
||||||
if (!ask) {
|
if (!form || input.sessionID !== form.request.sessionID) return false
|
||||||
return false
|
state.forms.delete(input.formID)
|
||||||
}
|
|
||||||
|
|
||||||
state.asks.delete(input.requestID)
|
|
||||||
clearBlocker(state)
|
clearBlocker(state)
|
||||||
failTool(state, ask.ref, "question rejected")
|
failTool(state, form.ref, "form cancelled")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,7 +1058,7 @@ export function createRunDemo(input: Input) {
|
|||||||
start,
|
start,
|
||||||
prompt,
|
prompt,
|
||||||
permission,
|
permission,
|
||||||
questionReply,
|
formReply,
|
||||||
questionReject,
|
formCancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ export type EntryFlags = {
|
|||||||
trailingNewline: boolean
|
trailingNewline: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RUN_ENTRY_NONE: RunEntryBody = {
|
const RUN_ENTRY_NONE: RunEntryBody = {
|
||||||
type: "none",
|
type: "none",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanRunText(text: string): string {
|
function cleanRunText(text: string): string {
|
||||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import type { TextareaRenderable } from "@opentui/core"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
|
import {
|
||||||
|
createFormBodyState,
|
||||||
|
formAcknowledge,
|
||||||
|
formCommitInput,
|
||||||
|
formConfirm,
|
||||||
|
formCurrent,
|
||||||
|
formCustom,
|
||||||
|
formDisplay,
|
||||||
|
formErrorMessage,
|
||||||
|
formInput,
|
||||||
|
formLabel,
|
||||||
|
formMove,
|
||||||
|
formPick,
|
||||||
|
formPlaceholder,
|
||||||
|
formReply,
|
||||||
|
formRows,
|
||||||
|
formSetError,
|
||||||
|
formSetExternalReady,
|
||||||
|
formSetDraft,
|
||||||
|
formSetField,
|
||||||
|
formSetSelected,
|
||||||
|
formSetSubmitting,
|
||||||
|
formSingle,
|
||||||
|
formSync,
|
||||||
|
formTextual,
|
||||||
|
formUnsupported,
|
||||||
|
formValidate,
|
||||||
|
formValidateValue,
|
||||||
|
} from "./form.shared"
|
||||||
|
import type { FormBodyState } from "./form.shared"
|
||||||
|
import type { RunFooterTheme } from "./theme"
|
||||||
|
import type { FormCancel, FormReply, MiniFormRequest } from "./types"
|
||||||
|
|
||||||
|
export function RunFormBody(props: {
|
||||||
|
request: MiniFormRequest
|
||||||
|
theme: RunFooterTheme
|
||||||
|
onReply: (input: FormReply) => void | Promise<void>
|
||||||
|
onCancel: (input: FormCancel) => void | Promise<void>
|
||||||
|
openExternal?: (url: string) => Promise<unknown>
|
||||||
|
state?: FormBodyState
|
||||||
|
onState?: (state: FormBodyState) => void
|
||||||
|
}) {
|
||||||
|
const [state, setLocalState] = createSignal(props.state ?? createFormBodyState(props.request))
|
||||||
|
const setState = (next: FormBodyState | ((previous: FormBodyState) => FormBodyState)) => {
|
||||||
|
const value = typeof next === "function" ? next(state()) : next
|
||||||
|
setLocalState(value)
|
||||||
|
props.onState?.(value)
|
||||||
|
}
|
||||||
|
const unsupported = createMemo(() => formUnsupported(props.request))
|
||||||
|
const current = createMemo(() => formCurrent(props.request, state()))
|
||||||
|
const answerField = createMemo(() => {
|
||||||
|
const field = current()
|
||||||
|
return field?.type === "external" ? undefined : field
|
||||||
|
})
|
||||||
|
const externalField = createMemo(() => {
|
||||||
|
const field = current()
|
||||||
|
return field?.type === "external" ? field : undefined
|
||||||
|
})
|
||||||
|
const confirm = createMemo(() => formConfirm(props.request, state()))
|
||||||
|
const rows = createMemo(() => formRows(current()))
|
||||||
|
const custom = createMemo(() => formCustom(current()))
|
||||||
|
const textual = createMemo(() => formTextual(current()))
|
||||||
|
const multiple = createMemo(() => current()?.type === "multiselect")
|
||||||
|
const message = createMemo(() => {
|
||||||
|
const value = props.request.metadata?.message
|
||||||
|
return typeof value === "string" ? value : undefined
|
||||||
|
})
|
||||||
|
let area: TextareaRenderable | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setState((previous) => formSync(previous, props.request))
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
const currentArea = area
|
||||||
|
if (!currentArea || currentArea.isDestroyed) return
|
||||||
|
setState((previous) => formSetDraft(previous, current(), currentArea.plainText))
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!state().editing || !area || area.isDestroyed) return
|
||||||
|
const value = formInput(state(), current())
|
||||||
|
if (area.plainText !== value) {
|
||||||
|
area.setText(value)
|
||||||
|
area.cursorOffset = value.length
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!area || area.isDestroyed || !state().editing) return
|
||||||
|
area.focus()
|
||||||
|
area.cursorOffset = area.plainText.length
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const beginReply = async (input: FormReply) => {
|
||||||
|
const formID = props.request.id
|
||||||
|
setState((previous) => formSetSubmitting(previous, true))
|
||||||
|
try {
|
||||||
|
await props.onReply(input)
|
||||||
|
} catch (error) {
|
||||||
|
setState((previous) => (previous.formID === formID ? formSetError(previous, formErrorMessage(error)) : previous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = (next = state()) => {
|
||||||
|
const invalid = formValidate(props.request, next)
|
||||||
|
if (invalid) {
|
||||||
|
setState((previous) => formSetError(previous, invalid))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reply = formReply(props.request, next)
|
||||||
|
if (reply) void beginReply(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = async () => {
|
||||||
|
const formID = props.request.id
|
||||||
|
setState((previous) => formSetSubmitting(previous, true))
|
||||||
|
try {
|
||||||
|
await props.onCancel({
|
||||||
|
sessionID: props.request.sessionID,
|
||||||
|
formID: props.request.id,
|
||||||
|
location: props.request.location,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
setState((previous) => (previous.formID === formID ? formSetError(previous, formErrorMessage(error)) : previous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitInput = () => {
|
||||||
|
const next = formCommitInput(state(), props.request, area?.plainText ?? formInput(state(), current()))
|
||||||
|
setState(next)
|
||||||
|
if (next.error) return
|
||||||
|
if (formSingle(props.request)) {
|
||||||
|
submit(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setState(formSetField(next, props.request, next.field + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const choose = (selected = state().selected) => {
|
||||||
|
const base = formSetSelected(state(), selected)
|
||||||
|
const next = formPick(base, props.request)
|
||||||
|
setState(next)
|
||||||
|
if (next.editing || multiple()) return
|
||||||
|
if (formSingle(props.request)) submit(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveField = (direction: -1 | 1) => {
|
||||||
|
const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
|
||||||
|
if (direction < 0 || confirm()) {
|
||||||
|
setState((previous) => formSetField(previous, props.request, next))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const field = current()
|
||||||
|
if (field?.type === "external") {
|
||||||
|
if (state().answers[field.key] !== true) {
|
||||||
|
setState((previous) => formSetError(previous, `Acknowledge ${formLabel(field)}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (field) {
|
||||||
|
const invalid = formValidateValue(field, state().answers[field.key])
|
||||||
|
if (invalid) {
|
||||||
|
setState((previous) => formSetError(previous, invalid))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState((previous) => formSetField(previous, props.request, next))
|
||||||
|
}
|
||||||
|
|
||||||
|
const external = async () => {
|
||||||
|
const field = current()
|
||||||
|
if (field?.type !== "external") return
|
||||||
|
if (state().answers[field.key] === true) {
|
||||||
|
if (formSingle(props.request)) submit()
|
||||||
|
else moveField(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state().externalReady[field.key]) {
|
||||||
|
const next = formAcknowledge(state(), props.request)
|
||||||
|
setState(next)
|
||||||
|
if (formSingle(props.request)) submit(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (props.openExternal) await props.openExternal(field.url)
|
||||||
|
else {
|
||||||
|
const { default: open } = await import("open")
|
||||||
|
await open(field.url)
|
||||||
|
}
|
||||||
|
setState((previous) => formSetExternalReady(previous, field.key))
|
||||||
|
} catch {
|
||||||
|
setState((previous) => formSetExternalReady(previous, field.key))
|
||||||
|
setState((previous) => formSetError(previous, "Could not open the URL. Open it manually, then press enter."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboard((event) => {
|
||||||
|
if (state().submitting) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.name === "escape") {
|
||||||
|
void cancel()
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (unsupported()) return
|
||||||
|
if (state().editing) return
|
||||||
|
if (
|
||||||
|
event.name === "tab" ||
|
||||||
|
event.name === "left" ||
|
||||||
|
event.name === "right" ||
|
||||||
|
event.name === "h" ||
|
||||||
|
event.name === "l"
|
||||||
|
) {
|
||||||
|
const direction = event.shift || event.name === "left" || event.name === "h" ? -1 : 1
|
||||||
|
moveField(direction)
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (confirm() && event.name === "return") {
|
||||||
|
submit()
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (current()?.type === "external" && event.name === "return") {
|
||||||
|
void external()
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const total = rows().length + (custom() ? 1 : 0)
|
||||||
|
const digit = Number(event.name)
|
||||||
|
if (!Number.isNaN(digit) && digit >= 1 && digit <= Math.min(total, 9)) {
|
||||||
|
choose(digit - 1)
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.name === "up" || event.name === "k") {
|
||||||
|
setState((previous) => formMove(previous, props.request, -1))
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.name === "down" || event.name === "j") {
|
||||||
|
setState((previous) => formMove(previous, props.request, 1))
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.name === "return") {
|
||||||
|
choose()
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
|
||||||
|
<box flexDirection="column" gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} flexGrow={1} flexShrink={1}>
|
||||||
|
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||||
|
<text fg={unsupported() ? props.theme.warning : props.theme.highlight}>◆</text>
|
||||||
|
<text fg={props.theme.text}>{props.request.title}</text>
|
||||||
|
<Show when={!unsupported() && !formSingle(props.request)}>
|
||||||
|
<text fg={props.theme.muted}>
|
||||||
|
{confirm()
|
||||||
|
? "Review"
|
||||||
|
: `${Math.min(state().field + 1, props.request.fields.length)}/${props.request.fields.length}`}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<Show when={message()}>{(value) => <text fg={props.theme.muted}>{value()}</text>}</Show>
|
||||||
|
<Show when={unsupported()}>
|
||||||
|
{(value) => (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={props.theme.warning} wrapMode="word">
|
||||||
|
{value()}
|
||||||
|
</text>
|
||||||
|
<text fg={props.theme.muted}>This request remains pending until you dismiss it.</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={!unsupported() && externalField()}>
|
||||||
|
{(field) => (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={props.theme.text}>{field().description ?? formLabel(field())}</text>
|
||||||
|
<text fg={props.theme.highlight} wrapMode="word">
|
||||||
|
{field().url}
|
||||||
|
</text>
|
||||||
|
<text fg={props.theme.muted}>
|
||||||
|
{state().answers[field().key] === true
|
||||||
|
? "Acknowledged"
|
||||||
|
: state().externalReady[field().key]
|
||||||
|
? "Press enter to acknowledge completion"
|
||||||
|
: "Press enter to open the URL"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={!unsupported() && answerField() && !confirm()}>
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={props.theme.text} wrapMode="word">
|
||||||
|
{answerField()!.description ?? formLabel(answerField()!)}
|
||||||
|
{answerField()!.required ? " (required)" : ""}
|
||||||
|
{multiple() ? " (select all that apply)" : ""}
|
||||||
|
</text>
|
||||||
|
<Show when={textual() || state().editing}>
|
||||||
|
<textarea
|
||||||
|
ref={(item: TextareaRenderable) => {
|
||||||
|
area = item
|
||||||
|
}}
|
||||||
|
width="100%"
|
||||||
|
minHeight={1}
|
||||||
|
maxHeight={3}
|
||||||
|
initialValue={formInput(state(), current())}
|
||||||
|
placeholder={formPlaceholder(answerField())}
|
||||||
|
placeholderColor={props.theme.muted}
|
||||||
|
textColor={props.theme.text}
|
||||||
|
focusedTextColor={props.theme.text}
|
||||||
|
backgroundColor={props.theme.surface}
|
||||||
|
focusedBackgroundColor={props.theme.surface}
|
||||||
|
cursorColor={props.theme.text}
|
||||||
|
focused
|
||||||
|
onSubmit={commitInput}
|
||||||
|
onContentChange={() => {
|
||||||
|
const currentArea = area
|
||||||
|
if (!currentArea || currentArea.isDestroyed) return
|
||||||
|
setState((previous) => formSetDraft(previous, current(), currentArea.plainText))
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.name === "escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
void cancel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={!textual() && !state().editing}>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={rows()}>
|
||||||
|
{(row, index) => {
|
||||||
|
const active = () => state().selected === index()
|
||||||
|
const picked = () => {
|
||||||
|
const field = current()
|
||||||
|
if (!field) return false
|
||||||
|
const value = state().answers[field.key]
|
||||||
|
return Array.isArray(value) ? value.includes(String(row.value)) : value === row.value
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
|
||||||
|
onMouseUp={() => choose(index())}
|
||||||
|
>
|
||||||
|
<text fg={active() ? props.theme.highlight : props.theme.muted}>{index() + 1}.</text>
|
||||||
|
<text fg={active() ? props.theme.text : props.theme.muted}>
|
||||||
|
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
|
||||||
|
{row.label}
|
||||||
|
{!multiple() && picked() ? " *" : ""}
|
||||||
|
</text>
|
||||||
|
<Show when={row.description}>
|
||||||
|
<text fg={props.theme.muted}>{row.description}</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={custom()}>
|
||||||
|
<box flexDirection="row" gap={1} onMouseUp={() => choose(rows().length)}>
|
||||||
|
<text fg={state().selected === rows().length ? props.theme.highlight : props.theme.muted}>
|
||||||
|
{rows().length + 1}.
|
||||||
|
</text>
|
||||||
|
<text fg={state().selected === rows().length ? props.theme.text : props.theme.muted}>
|
||||||
|
Type your own answer
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={!unsupported() && confirm()}>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={props.request.fields}>
|
||||||
|
{(field) => (
|
||||||
|
<text fg={props.theme.muted} wrapMode="none" truncate>
|
||||||
|
{formLabel(field)}:{" "}
|
||||||
|
{field.type === "external"
|
||||||
|
? state().answers[field.key] === true
|
||||||
|
? "acknowledged"
|
||||||
|
: "required"
|
||||||
|
: formDisplay(field, state().answers[field.key]) || "(not answered)"}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={3}
|
||||||
|
paddingBottom={1}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
|
<text fg={props.theme.muted}>
|
||||||
|
{state().submitting
|
||||||
|
? "submitting..."
|
||||||
|
: unsupported()
|
||||||
|
? "esc dismiss"
|
||||||
|
: confirm()
|
||||||
|
? "enter submit esc dismiss"
|
||||||
|
: textual() || state().editing
|
||||||
|
? "enter save esc dismiss"
|
||||||
|
: "↑↓ select enter choose tab next esc dismiss"}
|
||||||
|
</text>
|
||||||
|
<Show when={state().error}>
|
||||||
|
<text fg={props.theme.error} wrapMode="none" truncate>
|
||||||
|
{state().error}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
import type { TextareaRenderable } from "@opentui/core"
|
import type { TextareaRenderable } from "@opentui/core"
|
||||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
|
||||||
import {
|
import {
|
||||||
createPermissionBodyState,
|
createPermissionBodyState,
|
||||||
permissionAlwaysLines,
|
permissionAlwaysLines,
|
||||||
@@ -32,7 +31,7 @@ import {
|
|||||||
import { footerWidthPolicy } from "./footer.width"
|
import { footerWidthPolicy } from "./footer.width"
|
||||||
import { toolFiletype } from "./tool"
|
import { toolFiletype } from "./tool"
|
||||||
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||||
import type { PermissionReply, RunDiffStyle } from "./types"
|
import type { MiniPermissionRequest, PermissionReply } from "./types"
|
||||||
|
|
||||||
function buttons(
|
function buttons(
|
||||||
list: PermissionOption[],
|
list: PermissionOption[],
|
||||||
@@ -130,15 +129,15 @@ export function RejectField(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RunPermissionBody(props: {
|
export function RunPermissionBody(props: {
|
||||||
request: PermissionV2Request
|
request: MiniPermissionRequest
|
||||||
|
directory?: () => string
|
||||||
theme: RunFooterTheme
|
theme: RunFooterTheme
|
||||||
block: RunBlockTheme
|
block: RunBlockTheme
|
||||||
diffStyle?: RunDiffStyle
|
|
||||||
onReply: (input: PermissionReply) => void | Promise<void>
|
onReply: (input: PermissionReply) => void | Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const dims = useTerminalDimensions()
|
const dims = useTerminalDimensions()
|
||||||
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
|
const [state, setState] = createSignal(createPermissionBodyState(props.request))
|
||||||
const info = createMemo(() => permissionInfo(props.request))
|
const info = createMemo(() => permissionInfo(props.request, props.directory?.()))
|
||||||
const ft = createMemo(() => toolFiletype(info().file))
|
const ft = createMemo(() => toolFiletype(info().file))
|
||||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||||
const opts = createMemo(() =>
|
const opts = createMemo(() =>
|
||||||
@@ -163,7 +162,7 @@ export function RunPermissionBody(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(createPermissionBodyState(id))
|
setState(createPermissionBodyState(props.request))
|
||||||
})
|
})
|
||||||
|
|
||||||
const shift = (dir: -1 | 1) => {
|
const shift = (dir: -1 | 1) => {
|
||||||
@@ -361,15 +360,42 @@ export function RunPermissionBody(props: {
|
|||||||
<Show
|
<Show
|
||||||
when={info().diff}
|
when={info().diff}
|
||||||
fallback={
|
fallback={
|
||||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
<Show
|
||||||
<For each={info().lines}>
|
when={info().patch}
|
||||||
{(line) => (
|
fallback={
|
||||||
<text fg={props.theme.text} wrapMode="word">
|
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||||
{line}
|
<For each={info().lines}>
|
||||||
</text>
|
{(line) => (
|
||||||
)}
|
<text fg={props.theme.text} wrapMode="word">
|
||||||
</For>
|
{line}
|
||||||
</box>
|
</text>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(patch) => (
|
||||||
|
<Show
|
||||||
|
when={props.block.syntax}
|
||||||
|
fallback={
|
||||||
|
<text fg={props.theme.muted} wrapMode="word">
|
||||||
|
{patch()}
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(syntax) => (
|
||||||
|
<code
|
||||||
|
filetype="diff"
|
||||||
|
drawUnstyledText={false}
|
||||||
|
streaming={true}
|
||||||
|
syntaxStyle={syntax()}
|
||||||
|
content={patch()}
|
||||||
|
fg={props.theme.muted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<diff
|
<diff
|
||||||
@@ -392,7 +418,7 @@ export function RunPermissionBody(props: {
|
|||||||
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!info().diff && info().lines.length === 0}>
|
<Show when={!info().diff && !info().patch && info().lines.length === 0}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={props.theme.muted}>No diff provided</text>
|
<text fg={props.theme.muted}>No diff provided</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
isNewCommand,
|
isNewCommand,
|
||||||
movePromptHistory,
|
movePromptHistory,
|
||||||
pushPromptHistory,
|
pushPromptHistory,
|
||||||
|
slashHead,
|
||||||
} from "./prompt.shared"
|
} from "./prompt.shared"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||||
@@ -57,7 +58,7 @@ type PromptOption = Auto | SlashOption
|
|||||||
type MenuMode = false | "mention" | "slash"
|
type MenuMode = false | "mention" | "slash"
|
||||||
|
|
||||||
type PromptInput = {
|
type PromptInput = {
|
||||||
directory: string
|
directory: Accessor<string>
|
||||||
findFiles: (query: string) => Promise<string[]>
|
findFiles: (query: string) => Promise<string[]>
|
||||||
agents: Accessor<RunAgent[]>
|
agents: Accessor<RunAgent[]>
|
||||||
references: Accessor<RunReference[]>
|
references: Accessor<RunReference[]>
|
||||||
@@ -95,7 +96,6 @@ export type PromptState = {
|
|||||||
openEditor: (input?: { value?: string }) => Promise<void>
|
openEditor: (input?: { value?: string }) => Promise<void>
|
||||||
onKeyDown: (event: KeyEvent) => void
|
onKeyDown: (event: KeyEvent) => void
|
||||||
onContentChange: () => void
|
onContentChange: () => void
|
||||||
replaceDraft: (text: string) => void
|
|
||||||
replacePrompt: (prompt: RunPrompt) => void
|
replacePrompt: (prompt: RunPrompt) => void
|
||||||
bind: (area?: TextareaRenderable) => void
|
bind: (area?: TextareaRenderable) => void
|
||||||
}
|
}
|
||||||
@@ -140,23 +140,6 @@ function extractLineRange(input: string) {
|
|||||||
return { base, line: { start, end } }
|
return { base, line: { start, end } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function slashHead(text: string) {
|
|
||||||
if (!text.startsWith("/")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 1; i < text.length; i++) {
|
|
||||||
switch (text[i]) {
|
|
||||||
case " ":
|
|
||||||
case "\t":
|
|
||||||
case "\n":
|
|
||||||
return { name: text.slice(1, i), arguments: text.slice(i + 1), end: i }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name: text.slice(1), arguments: "", end: text.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
function slashQuery(text: string, cursor: number) {
|
function slashQuery(text: string, cursor: number) {
|
||||||
const head = slashHead(text.slice(0, cursor))
|
const head = slashHead(text.slice(0, cursor))
|
||||||
if (!head || head.end !== cursor) {
|
if (!head || head.end !== cursor) {
|
||||||
@@ -379,7 +362,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
const next = extractLineRange(value)
|
const next = extractLineRange(value)
|
||||||
const list = await input.findFiles(next.base)
|
const list = await input.findFiles(next.base)
|
||||||
return list.map((item): Auto => {
|
return list.map((item): Auto => {
|
||||||
const url = pathToFileURL(path.resolve(input.directory, item))
|
const url = pathToFileURL(path.resolve(input.directory(), item))
|
||||||
let filename = item
|
let filename = item
|
||||||
if (next.line && !item.endsWith("/")) {
|
if (next.line && !item.endsWith("/")) {
|
||||||
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
|
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
|
||||||
@@ -634,21 +617,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
area.focus()
|
area.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceDraft = (text: string) => {
|
|
||||||
draft = shell() ? { text, parts: [], mode: "shell" } : { text, parts: [] }
|
|
||||||
if (!area || area.isDestroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hide()
|
|
||||||
area.setText(text)
|
|
||||||
clearParts()
|
|
||||||
draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] }
|
|
||||||
area.cursorOffset = Math.min(stringWidth(text), stringWidth(area.plainText))
|
|
||||||
scheduleRows()
|
|
||||||
area.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
return
|
return
|
||||||
@@ -1296,7 +1264,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
refresh()
|
refresh()
|
||||||
scheduleRows()
|
scheduleRows()
|
||||||
},
|
},
|
||||||
replaceDraft,
|
|
||||||
replacePrompt: restore,
|
replacePrompt: restore,
|
||||||
bind,
|
bind,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,573 +0,0 @@
|
|||||||
// Question UI body for the direct-mode footer.
|
|
||||||
//
|
|
||||||
// Renders inside the footer when the reducer pushes a FooterView of type
|
|
||||||
// "question". Supports single-question and multi-question flows:
|
|
||||||
//
|
|
||||||
// Single question: options list with up/down selection, digit shortcuts,
|
|
||||||
// and optional custom text input.
|
|
||||||
//
|
|
||||||
// Multi-question: tabbed interface where each question is a tab, plus a
|
|
||||||
// final "Confirm" tab that shows all answers for review. Tab/shift-tab
|
|
||||||
// or left/right to navigate between questions.
|
|
||||||
//
|
|
||||||
// All state logic lives in question.shared.ts as a pure state machine.
|
|
||||||
// This component just renders it and dispatches keyboard events.
|
|
||||||
/** @jsxImportSource @opentui/solid */
|
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
|
||||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
|
||||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
|
||||||
import type { QuestionV2Request } from "@opencode-ai/client/promise"
|
|
||||||
import {
|
|
||||||
createQuestionBodyState,
|
|
||||||
questionConfirm,
|
|
||||||
questionCustom,
|
|
||||||
questionInfo,
|
|
||||||
questionInput,
|
|
||||||
questionMove,
|
|
||||||
questionOther,
|
|
||||||
questionPicked,
|
|
||||||
questionReject,
|
|
||||||
questionSave,
|
|
||||||
questionSelect,
|
|
||||||
questionSetEditing,
|
|
||||||
questionSetSelected,
|
|
||||||
questionSetSubmitting,
|
|
||||||
questionSetTab,
|
|
||||||
questionSingle,
|
|
||||||
questionStoreCustom,
|
|
||||||
questionSubmit,
|
|
||||||
questionSync,
|
|
||||||
questionTabs,
|
|
||||||
questionTotal,
|
|
||||||
} from "./question.shared"
|
|
||||||
import { footerWidthPolicy } from "./footer.width"
|
|
||||||
import type { RunFooterTheme } from "./theme"
|
|
||||||
import type { QuestionReject, QuestionReply } from "./types"
|
|
||||||
|
|
||||||
export function RunQuestionBody(props: {
|
|
||||||
request: QuestionV2Request
|
|
||||||
theme: RunFooterTheme
|
|
||||||
onReply: (input: QuestionReply) => void | Promise<void>
|
|
||||||
onReject: (input: QuestionReject) => void | Promise<void>
|
|
||||||
}) {
|
|
||||||
const dims = useTerminalDimensions()
|
|
||||||
const [state, setState] = createSignal(createQuestionBodyState(props.request.id))
|
|
||||||
const single = createMemo(() => questionSingle(props.request))
|
|
||||||
const confirm = createMemo(() => questionConfirm(props.request, state()))
|
|
||||||
const info = createMemo(() => questionInfo(props.request, state()))
|
|
||||||
const input = createMemo(() => questionInput(state()))
|
|
||||||
const other = createMemo(() => questionOther(props.request, state()))
|
|
||||||
const picked = createMemo(() => questionPicked(state()))
|
|
||||||
const disabled = createMemo(() => state().submitting)
|
|
||||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
|
||||||
const verb = createMemo(() => {
|
|
||||||
if (confirm()) {
|
|
||||||
return "submit"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info()?.multiple) {
|
|
||||||
return "toggle"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (single()) {
|
|
||||||
return "submit"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "confirm"
|
|
||||||
})
|
|
||||||
let area: TextareaRenderable | undefined
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
setState((prev) => questionSync(prev, props.request.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
const setTab = (tab: number) => {
|
|
||||||
setState((prev) => questionSetTab(prev, tab))
|
|
||||||
}
|
|
||||||
|
|
||||||
const move = (dir: -1 | 1) => {
|
|
||||||
setState((prev) => questionMove(prev, props.request, dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
const beginReply = async (input: QuestionReply) => {
|
|
||||||
setState((prev) => questionSetSubmitting(prev, true))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await props.onReply(input)
|
|
||||||
} catch {
|
|
||||||
setState((prev) => questionSetSubmitting(prev, false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const beginReject = async (input: QuestionReject) => {
|
|
||||||
setState((prev) => questionSetSubmitting(prev, true))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await props.onReject(input)
|
|
||||||
} catch {
|
|
||||||
setState((prev) => questionSetSubmitting(prev, false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveCustom = () => {
|
|
||||||
const cur = state()
|
|
||||||
const next = questionSave(cur, props.request)
|
|
||||||
if (next.state !== cur) {
|
|
||||||
setState(next.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!next.reply) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void beginReply(next.reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
const choose = (selected: number) => {
|
|
||||||
const base = state()
|
|
||||||
const cur = questionSetSelected(base, selected)
|
|
||||||
const next = questionSelect(cur, props.request)
|
|
||||||
if (next.state !== base) {
|
|
||||||
setState(next.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!next.reply) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void beginReply(next.reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mark = (selected: number) => {
|
|
||||||
setState((prev) => questionSetSelected(prev, selected))
|
|
||||||
}
|
|
||||||
|
|
||||||
const select = () => {
|
|
||||||
const cur = state()
|
|
||||||
const next = questionSelect(cur, props.request)
|
|
||||||
if (next.state !== cur) {
|
|
||||||
setState(next.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!next.reply) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void beginReply(next.reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
void beginReply(questionSubmit(props.request, state()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const reject = () => {
|
|
||||||
void beginReject(questionReject(props.request))
|
|
||||||
}
|
|
||||||
|
|
||||||
useKeyboard((event) => {
|
|
||||||
const cur = state()
|
|
||||||
if (cur.submitting) {
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cur.editing) {
|
|
||||||
if (event.name === "escape") {
|
|
||||||
setState((prev) => questionSetEditing(prev, false))
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!single() && (event.name === "left" || event.name === "h")) {
|
|
||||||
setTab((cur.tab - 1 + questionTabs(props.request)) % questionTabs(props.request))
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!single() && (event.name === "right" || event.name === "l")) {
|
|
||||||
setTab((cur.tab + 1) % questionTabs(props.request))
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!single() && event.name === "tab") {
|
|
||||||
const dir = event.shift ? -1 : 1
|
|
||||||
setTab((cur.tab + dir + questionTabs(props.request)) % questionTabs(props.request))
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionConfirm(props.request, cur)) {
|
|
||||||
if (event.name === "return") {
|
|
||||||
submit()
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "escape") {
|
|
||||||
reject()
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = questionTotal(props.request, cur)
|
|
||||||
const max = Math.min(total, 9)
|
|
||||||
const digit = Number(event.name)
|
|
||||||
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
|
|
||||||
choose(digit - 1)
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "up" || event.name === "k") {
|
|
||||||
move(-1)
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "down" || event.name === "j") {
|
|
||||||
move(1)
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "return") {
|
|
||||||
select()
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "escape") {
|
|
||||||
reject()
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!state().editing || !area || area.isDestroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (area.plainText !== input()) {
|
|
||||||
area.setText(input())
|
|
||||||
area.cursorOffset = input().length
|
|
||||||
}
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (!area || area.isDestroyed || !state().editing) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
area.focus()
|
|
||||||
area.cursorOffset = area.plainText.length
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box width="100%" height="100%" flexDirection="column">
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={3}
|
|
||||||
paddingTop={1}
|
|
||||||
flexGrow={1}
|
|
||||||
flexShrink={1}
|
|
||||||
backgroundColor={props.theme.surface}
|
|
||||||
>
|
|
||||||
<Show when={!single()}>
|
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
|
||||||
<For each={props.request.questions}>
|
|
||||||
{(item, index) => {
|
|
||||||
const active = () => state().tab === index()
|
|
||||||
const answered = () => (state().answers[index()]?.length ?? 0) > 0
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={active() ? props.theme.highlight : props.theme.surface}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (!disabled()) setTab(index())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={active() ? props.theme.surface : answered() ? props.theme.text : props.theme.muted}>
|
|
||||||
{item.header}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<box
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (!disabled()) setTab(props.request.questions.length)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={confirm() ? props.theme.surface : props.theme.muted}>Confirm</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={!confirm()}
|
|
||||||
fallback={
|
|
||||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1}>
|
|
||||||
<scrollbox
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
verticalScrollbarOptions={{
|
|
||||||
trackOptions: {
|
|
||||||
backgroundColor: props.theme.surface,
|
|
||||||
foregroundColor: props.theme.line,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box width="100%" flexDirection="column" gap={1}>
|
|
||||||
<box paddingLeft={1}>
|
|
||||||
<text fg={props.theme.text}>Review</text>
|
|
||||||
</box>
|
|
||||||
<For each={props.request.questions}>
|
|
||||||
{(item, index) => {
|
|
||||||
const value = () => state().answers[index()]?.join(", ") ?? ""
|
|
||||||
const answered = () => Boolean(value())
|
|
||||||
return (
|
|
||||||
<box paddingLeft={1}>
|
|
||||||
<text wrapMode="word">
|
|
||||||
<span style={{ fg: props.theme.muted }}>{item.header}:</span>{" "}
|
|
||||||
<span style={{ fg: answered() ? props.theme.text : props.theme.error }}>
|
|
||||||
{answered() ? value() : "(not answered)"}
|
|
||||||
</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} gap={1}>
|
|
||||||
<box>
|
|
||||||
<text fg={props.theme.text} wrapMode="word">
|
|
||||||
{info()?.question}
|
|
||||||
{info()?.multiple ? " (select all that apply)" : ""}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexGrow={1} flexShrink={1}>
|
|
||||||
<scrollbox
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
verticalScrollbarOptions={{
|
|
||||||
trackOptions: {
|
|
||||||
backgroundColor: props.theme.surface,
|
|
||||||
foregroundColor: props.theme.line,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box width="100%" flexDirection="column">
|
|
||||||
<For each={info()?.options ?? []}>
|
|
||||||
{(item, index) => {
|
|
||||||
const active = () => state().selected === index()
|
|
||||||
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
onMouseOver={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
mark(index())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseDown={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
mark(index())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
choose(index())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box flexDirection="row">
|
|
||||||
<box backgroundColor={active() ? props.theme.line : undefined} paddingRight={1}>
|
|
||||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>{`${index() + 1}.`}</text>
|
|
||||||
</box>
|
|
||||||
<box backgroundColor={active() ? props.theme.line : undefined}>
|
|
||||||
<text
|
|
||||||
fg={active() ? props.theme.highlight : hit() ? props.theme.success : props.theme.text}
|
|
||||||
>
|
|
||||||
{info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show when={!info()?.multiple}>
|
|
||||||
<text fg={props.theme.success}>{hit() ? " ✓" : ""}</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text fg={props.theme.muted} wrapMode="word">
|
|
||||||
{item.description}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
<Show when={questionCustom(props.request, state())}>
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
onMouseOver={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
mark(info()?.options.length ?? 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseDown={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
mark(info()?.options.length ?? 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
choose(info()?.options.length ?? 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box flexDirection="row">
|
|
||||||
<box backgroundColor={other() ? props.theme.line : undefined} paddingRight={1}>
|
|
||||||
<text
|
|
||||||
fg={other() ? props.theme.highlight : props.theme.muted}
|
|
||||||
>{`${(info()?.options.length ?? 0) + 1}.`}</text>
|
|
||||||
</box>
|
|
||||||
<box backgroundColor={other() ? props.theme.line : undefined}>
|
|
||||||
<text
|
|
||||||
fg={other() ? props.theme.highlight : picked() ? props.theme.success : props.theme.text}
|
|
||||||
>
|
|
||||||
{info()?.multiple
|
|
||||||
? `[${picked() ? "✓" : " "}] Type your own answer`
|
|
||||||
: "Type your own answer"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show when={!info()?.multiple}>
|
|
||||||
<text fg={props.theme.success}>{picked() ? " ✓" : ""}</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<Show
|
|
||||||
when={state().editing}
|
|
||||||
fallback={
|
|
||||||
<Show when={input()}>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text fg={props.theme.muted} wrapMode="word">
|
|
||||||
{input()}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<textarea
|
|
||||||
width="100%"
|
|
||||||
minHeight={1}
|
|
||||||
maxHeight={4}
|
|
||||||
wrapMode="word"
|
|
||||||
placeholder="Type your own answer"
|
|
||||||
placeholderColor={props.theme.muted}
|
|
||||||
textColor={props.theme.text}
|
|
||||||
focusedTextColor={props.theme.text}
|
|
||||||
backgroundColor={props.theme.surface}
|
|
||||||
focusedBackgroundColor={props.theme.surface}
|
|
||||||
cursorColor={props.theme.text}
|
|
||||||
focused={!disabled()}
|
|
||||||
onSubmit={saveCustom}
|
|
||||||
onContentChange={() => {
|
|
||||||
if (!area || area.isDestroyed || disabled()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = area.plainText
|
|
||||||
setState((prev) => questionStoreCustom(prev, prev.tab, text))
|
|
||||||
}}
|
|
||||||
ref={(item) => {
|
|
||||||
area = item
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
flexDirection={narrow() ? "column" : "row"}
|
|
||||||
flexShrink={0}
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
paddingRight={3}
|
|
||||||
paddingBottom={1}
|
|
||||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
|
||||||
alignItems={narrow() ? "flex-start" : "center"}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={!disabled()}
|
|
||||||
fallback={
|
|
||||||
<text fg={props.theme.muted} wrapMode="word">
|
|
||||||
Waiting for question event...
|
|
||||||
</text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<box
|
|
||||||
flexDirection={narrow() ? "column" : "row"}
|
|
||||||
gap={narrow() ? 1 : 2}
|
|
||||||
flexShrink={0}
|
|
||||||
width={narrow() ? "100%" : undefined}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={!state().editing}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
enter <span style={{ fg: props.theme.muted }}>save</span>
|
|
||||||
</text>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
|
||||||
</text>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={!single()}>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
{"⇆"} <span style={{ fg: props.theme.muted }}>tab</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={!confirm()}>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
{"↑↓"} <span style={{ fg: props.theme.muted }}>select</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
enter <span style={{ fg: props.theme.muted }}>{verb()}</span>
|
|
||||||
</text>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
esc <span style={{ fg: props.theme.muted }}>dismiss</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import { registerOpencodeSpinner } from "../component/register-spinner"
|
|||||||
import { Show, createMemo, indexArray } from "solid-js"
|
import { Show, createMemo, indexArray } from "solid-js"
|
||||||
import { SPINNER_FRAMES } from "../component/spinner-frames"
|
import { SPINNER_FRAMES } from "../component/spinner-frames"
|
||||||
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
||||||
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
import type { FooterSubagentDetail, FooterSubagentTab } from "./types"
|
||||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||||
|
|
||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
@@ -51,8 +51,6 @@ export function RunFooterSubagentBody(props: {
|
|||||||
index: () => number
|
index: () => number
|
||||||
total: () => number
|
total: () => number
|
||||||
detail: () => FooterSubagentDetail | undefined
|
detail: () => FooterSubagentDetail | undefined
|
||||||
width: () => number
|
|
||||||
diffStyle?: RunDiffStyle
|
|
||||||
onCycle: (dir: -1 | 1) => void
|
onCycle: (dir: -1 | 1) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
// Formatted interrupt shortcut from the registered keymap binding; the
|
// Formatted interrupt shortcut from the registered keymap binding; the
|
||||||
@@ -63,7 +61,6 @@ export function RunFooterSubagentBody(props: {
|
|||||||
const footer = createMemo(() => theme().footer)
|
const footer = createMemo(() => theme().footer)
|
||||||
const tab = createMemo(() => props.tab())
|
const tab = createMemo(() => props.tab())
|
||||||
const commits = createMemo(() => props.detail()?.commits ?? [])
|
const commits = createMemo(() => props.detail()?.commits ?? [])
|
||||||
const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
|
|
||||||
const scrollbar = createMemo(() => ({
|
const scrollbar = createMemo(() => ({
|
||||||
trackOptions: {
|
trackOptions: {
|
||||||
backgroundColor: footer().surface,
|
backgroundColor: footer().surface,
|
||||||
@@ -89,7 +86,7 @@ export function RunFooterSubagentBody(props: {
|
|||||||
const rows = indexArray(commits, (commit, index) => (
|
const rows = indexArray(commits, (commit, index) => (
|
||||||
<box flexDirection="column" gap={0} flexShrink={0}>
|
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||||
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
|
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
|
||||||
<RunEntryContent commit={commit()} theme={theme()} opts={opts()} width={props.width()} />
|
<RunEntryContent commit={commit()} theme={theme()} />
|
||||||
</box>
|
</box>
|
||||||
))
|
))
|
||||||
let scroll: ScrollBoxRenderable | undefined
|
let scroll: ScrollBoxRenderable | undefined
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// rebuilding the session model.
|
// rebuilding the session model.
|
||||||
//
|
//
|
||||||
// Footer: event() updates the SolidJS signal-backed FooterState, which
|
// Footer: event() updates the SolidJS signal-backed FooterState, which
|
||||||
// drives the reactive footer view (prompt, status, permission, question).
|
// drives the reactive footer view (prompt, status, permission, form).
|
||||||
// present() swaps the active footer view and resizes the footer region.
|
// present() swaps the active footer view and resizes the footer region.
|
||||||
//
|
//
|
||||||
// Lifecycle:
|
// Lifecycle:
|
||||||
@@ -24,11 +24,12 @@
|
|||||||
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
|
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
|
||||||
// two-press pattern where the first press shows a hint and the second press
|
// two-press pattern where the first press shows a hint and the second press
|
||||||
// within 5 seconds actually fires the action.
|
// within 5 seconds actually fires the action.
|
||||||
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
|
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
|
||||||
import { render } from "@opentui/solid"
|
import { render } from "@opentui/solid"
|
||||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
|
import { Locale } from "../util/locale"
|
||||||
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||||
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||||
@@ -45,12 +46,11 @@ import type {
|
|||||||
FooterState,
|
FooterState,
|
||||||
FooterSubagentState,
|
FooterSubagentState,
|
||||||
FooterView,
|
FooterView,
|
||||||
|
FormCancel,
|
||||||
|
FormReply,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
QuestionReject,
|
|
||||||
QuestionReply,
|
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunCommand,
|
RunCommand,
|
||||||
RunDiffStyle,
|
|
||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunProvider,
|
RunProvider,
|
||||||
@@ -67,11 +67,10 @@ type CycleResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RunFooterOptions = {
|
type RunFooterOptions = {
|
||||||
directory: string
|
directory: () => string
|
||||||
findFiles: (query: string) => Promise<string[]>
|
findFiles: (query: string) => Promise<string[]>
|
||||||
agents: RunAgent[]
|
agents: RunAgent[]
|
||||||
references: RunReference[]
|
references: RunReference[]
|
||||||
commands?: RunCommand[]
|
|
||||||
wrote?: boolean
|
wrote?: boolean
|
||||||
sessionID: () => string | undefined
|
sessionID: () => string | undefined
|
||||||
agentLabel: string
|
agentLabel: string
|
||||||
@@ -82,25 +81,22 @@ type RunFooterOptions = {
|
|||||||
history?: RunPrompt[]
|
history?: RunPrompt[]
|
||||||
theme: RunTheme
|
theme: RunTheme
|
||||||
tuiConfig: RunTuiConfig
|
tuiConfig: RunTuiConfig
|
||||||
diffStyle: RunDiffStyle
|
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
onFormReply: (input: FormReply) => void | Promise<void>
|
||||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||||
onCycleVariant?: () => CycleResult | void
|
onCycleVariant?: () => CycleResult | void
|
||||||
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
|
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
|
||||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||||
onInterrupt?: () => void
|
onInterrupt?: () => void
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||||
onExit?: () => void
|
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
onSubagentInterrupt?: (sessionID: string) => void
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
subscribeThemeSignal: (listener: () => void) => () => void
|
subscribeThemeSignal: (listener: () => void) => () => void
|
||||||
treeSitterClient?: TreeSitterClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERMISSION_ROWS = 12
|
const PERMISSION_ROWS = 12
|
||||||
const QUESTION_ROWS = 14
|
const FORM_ROWS = 14
|
||||||
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
|
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
const SKILL_ROWS = RUN_COMMAND_PANEL_ROWS
|
const SKILL_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
|
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
|
||||||
@@ -114,7 +110,7 @@ function createEmptySubagentState(): FooterSubagentState {
|
|||||||
tabs: [],
|
tabs: [],
|
||||||
details: {},
|
details: {},
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
forms: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,13 +137,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.type === "turn.wait") {
|
|
||||||
return {
|
|
||||||
phase: "running",
|
|
||||||
status: "waiting for assistant",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next.type === "turn.idle") {
|
if (next.type === "turn.idle") {
|
||||||
return {
|
return {
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
@@ -205,7 +194,6 @@ export class RunFooter implements FooterApi {
|
|||||||
private setHistory: Setter<RunPrompt[]>
|
private setHistory: Setter<RunPrompt[]>
|
||||||
private promptRoute: FooterPromptRoute = { type: "composer" }
|
private promptRoute: FooterPromptRoute = { type: "composer" }
|
||||||
private subagentMenuRows = SUBAGENT_ROWS
|
private subagentMenuRows = SUBAGENT_ROWS
|
||||||
private autocomplete = false
|
|
||||||
private interruptTimeout: NodeJS.Timeout | undefined
|
private interruptTimeout: NodeJS.Timeout | undefined
|
||||||
private exitTimeout: NodeJS.Timeout | undefined
|
private exitTimeout: NodeJS.Timeout | undefined
|
||||||
private noticeTimeout: NodeJS.Timeout | undefined
|
private noticeTimeout: NodeJS.Timeout | undefined
|
||||||
@@ -221,10 +209,7 @@ export class RunFooter implements FooterApi {
|
|||||||
|
|
||||||
private createScrollback(wrote: boolean): RunScrollbackStream {
|
private createScrollback(wrote: boolean): RunScrollbackStream {
|
||||||
return new RunScrollbackStream(this.renderer, this.theme(), {
|
return new RunScrollbackStream(this.renderer, this.theme(), {
|
||||||
diffStyle: this.options.diffStyle,
|
|
||||||
wrote,
|
wrote,
|
||||||
sessionID: this.options.sessionID,
|
|
||||||
treeSitterClient: this.options.treeSitterClient,
|
|
||||||
onThemeRelease: (theme) => {
|
onThemeRelease: (theme) => {
|
||||||
void this.renderer
|
void this.renderer
|
||||||
.idle()
|
.idle()
|
||||||
@@ -243,7 +228,6 @@ export class RunFooter implements FooterApi {
|
|||||||
status: "",
|
status: "",
|
||||||
queue: 0,
|
queue: 0,
|
||||||
model: options.modelLabel,
|
model: options.modelLabel,
|
||||||
duration: "",
|
|
||||||
usage: "",
|
usage: "",
|
||||||
first: options.first,
|
first: options.first,
|
||||||
interrupt: 0,
|
interrupt: 0,
|
||||||
@@ -260,7 +244,7 @@ export class RunFooter implements FooterApi {
|
|||||||
const [references, setReferences] = createSignal(options.references)
|
const [references, setReferences] = createSignal(options.references)
|
||||||
this.references = references
|
this.references = references
|
||||||
this.setReferences = setReferences
|
this.setReferences = setReferences
|
||||||
const [commands, setCommands] = createSignal<RunCommand[] | undefined>(options.commands)
|
const [commands, setCommands] = createSignal<RunCommand[] | undefined>()
|
||||||
this.commands = commands
|
this.commands = commands
|
||||||
this.setCommands = setCommands
|
this.setCommands = setCommands
|
||||||
const [providers, setProviders] = createSignal<RunProvider[] | undefined>()
|
const [providers, setProviders] = createSignal<RunProvider[] | undefined>()
|
||||||
@@ -285,7 +269,7 @@ export class RunFooter implements FooterApi {
|
|||||||
setSubagent("tabs", reconcile(next.tabs, { key: "sessionID" }))
|
setSubagent("tabs", reconcile(next.tabs, { key: "sessionID" }))
|
||||||
setSubagent("details", reconcile(next.details))
|
setSubagent("details", reconcile(next.details))
|
||||||
setSubagent("permissions", reconcile(next.permissions, { key: "id" }))
|
setSubagent("permissions", reconcile(next.permissions, { key: "id" }))
|
||||||
setSubagent("questions", reconcile(next.questions, { key: "id" }))
|
setSubagent("forms", reconcile(next.forms, { key: "id" }))
|
||||||
}
|
}
|
||||||
const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([])
|
const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([])
|
||||||
this.queuedPrompts = queuedPrompts
|
this.queuedPrompts = queuedPrompts
|
||||||
@@ -323,14 +307,12 @@ export class RunFooter implements FooterApi {
|
|||||||
variants: footer.variants,
|
variants: footer.variants,
|
||||||
currentVariant: footer.currentVariant,
|
currentVariant: footer.currentVariant,
|
||||||
theme: footer.theme,
|
theme: footer.theme,
|
||||||
diffStyle: options.diffStyle,
|
|
||||||
tuiConfig: options.tuiConfig,
|
tuiConfig: options.tuiConfig,
|
||||||
history: footer.history,
|
history: footer.history,
|
||||||
agent: options.agentLabel,
|
|
||||||
onSubmit: footer.handlePrompt,
|
onSubmit: footer.handlePrompt,
|
||||||
onPermissionReply: footer.handlePermissionReply,
|
onPermissionReply: footer.handlePermissionReply,
|
||||||
onQuestionReply: footer.handleQuestionReply,
|
onFormReply: footer.handleFormReply,
|
||||||
onQuestionReject: footer.handleQuestionReject,
|
onFormCancel: footer.handleFormCancel,
|
||||||
onCycle: footer.handleCycle,
|
onCycle: footer.handleCycle,
|
||||||
onInterrupt: footer.handleInterrupt,
|
onInterrupt: footer.handleInterrupt,
|
||||||
onBackground: options.onBackground,
|
onBackground: options.onBackground,
|
||||||
@@ -398,6 +380,11 @@ export class RunFooter implements FooterApi {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (next.type === "agent") {
|
||||||
|
this.options.agentLabel = Locale.titlecase(next.agent ?? "build")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (next.type === "model") {
|
if (next.type === "model") {
|
||||||
this.setCurrentModel(next.selection)
|
this.setCurrentModel(next.selection)
|
||||||
}
|
}
|
||||||
@@ -502,7 +489,6 @@ export class RunFooter implements FooterApi {
|
|||||||
status: typeof next.status === "string" ? next.status : prev.status,
|
status: typeof next.status === "string" ? next.status : prev.status,
|
||||||
queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
|
queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
|
||||||
model: typeof next.model === "string" ? next.model : prev.model,
|
model: typeof next.model === "string" ? next.model : prev.model,
|
||||||
duration: typeof next.duration === "string" ? next.duration : prev.duration,
|
|
||||||
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
||||||
first: typeof next.first === "boolean" ? next.first : prev.first,
|
first: typeof next.first === "boolean" ? next.first : prev.first,
|
||||||
interrupt:
|
interrupt:
|
||||||
@@ -552,19 +538,9 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const last = this.queue.at(-1)
|
const last = this.queue.at(-1)
|
||||||
if (
|
const merged = last ? coalesceProgressCommit(last, commit) : undefined
|
||||||
last &&
|
if (merged) this.queue[this.queue.length - 1] = merged
|
||||||
last.phase === "progress" &&
|
else this.queue.push(commit)
|
||||||
commit.phase === "progress" &&
|
|
||||||
last.kind === commit.kind &&
|
|
||||||
last.source === commit.source &&
|
|
||||||
last.partID === commit.partID &&
|
|
||||||
last.tool === commit.tool
|
|
||||||
) {
|
|
||||||
last.text += commit.text
|
|
||||||
} else {
|
|
||||||
this.queue.push(commit)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pending) {
|
if (this.pending) {
|
||||||
return
|
return
|
||||||
@@ -704,15 +680,15 @@ export class RunFooter implements FooterApi {
|
|||||||
this.patch({ interrupt: 0, exit: 0 })
|
this.patch({ interrupt: 0, exit: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resizes the footer to fit the current view. Permission and question views
|
// Resizes the footer to fit the current view. Permission and form views
|
||||||
// get fixed extra rows; the prompt view scales with textarea line count.
|
// get fixed extra rows; the prompt view scales with textarea line count.
|
||||||
private applyHeight(): void {
|
private applyHeight(): void {
|
||||||
const type = this.view().type
|
const type = this.view().type
|
||||||
const height =
|
const height =
|
||||||
type === "permission"
|
type === "permission"
|
||||||
? this.base + PERMISSION_ROWS
|
? this.base + PERMISSION_ROWS
|
||||||
: type === "question"
|
: type === "form"
|
||||||
? this.base + QUESTION_ROWS
|
? this.base + FORM_ROWS
|
||||||
: this.promptRoute.type === "command"
|
: this.promptRoute.type === "command"
|
||||||
? 1 + COMMAND_ROWS
|
? 1 + COMMAND_ROWS
|
||||||
: this.promptRoute.type === "skill"
|
: this.promptRoute.type === "skill"
|
||||||
@@ -750,9 +726,8 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncLayout = (next: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }): void => {
|
private syncLayout = (next: { route: FooterPromptRoute; subagentRows: number }): void => {
|
||||||
this.promptRoute = next.route
|
this.promptRoute = next.route
|
||||||
this.autocomplete = next.autocomplete
|
|
||||||
this.subagentMenuRows = next.subagentRows
|
this.subagentMenuRows = next.subagentRows
|
||||||
if (this.view().type === "prompt") {
|
if (this.view().type === "prompt") {
|
||||||
this.applyHeight()
|
this.applyHeight()
|
||||||
@@ -788,20 +763,14 @@ export class RunFooter implements FooterApi {
|
|||||||
await this.options.onPermissionReply(input)
|
await this.options.onPermissionReply(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleQuestionReply = async (input: QuestionReply): Promise<void> => {
|
private handleFormReply = async (input: FormReply): Promise<void> => {
|
||||||
if (this.isClosed) {
|
if (this.isClosed) return
|
||||||
return
|
await this.options.onFormReply(input)
|
||||||
}
|
|
||||||
|
|
||||||
await this.options.onQuestionReply(input)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleQuestionReject = async (input: QuestionReject): Promise<void> => {
|
private handleFormCancel = async (input: FormCancel): Promise<void> => {
|
||||||
if (this.isClosed) {
|
if (this.isClosed) return
|
||||||
return
|
await this.options.onFormCancel(input)
|
||||||
}
|
|
||||||
|
|
||||||
await this.options.onQuestionReject(input)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCycle = (): void => {
|
private handleCycle = (): void => {
|
||||||
@@ -1014,12 +983,11 @@ export class RunFooter implements FooterApi {
|
|||||||
this.clearExitTimer()
|
this.clearExitTimer()
|
||||||
this.patch({ exit: 0, status: "exiting" })
|
this.patch({ exit: 0, status: "exiting" })
|
||||||
this.close()
|
this.close()
|
||||||
this.options.onExit?.()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private handlePalette = (): void => {
|
private handlePalette = (): void => {
|
||||||
void resolveRunTheme(this.renderer).then((theme) => {
|
void resolveRunTheme(this.renderer, this.options.tuiConfig.theme).then((theme) => {
|
||||||
if (this.isGone) {
|
if (this.isGone) {
|
||||||
theme.block.syntax?.destroy()
|
theme.block.syntax?.destroy()
|
||||||
return
|
return
|
||||||
@@ -1139,3 +1107,19 @@ export class RunFooter implements FooterApi {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for queue identity regression tests. */
|
||||||
|
export function coalesceProgressCommit(previous: StreamCommit, current: StreamCommit): StreamCommit | undefined {
|
||||||
|
if (
|
||||||
|
previous.phase !== "progress" ||
|
||||||
|
current.phase !== "progress" ||
|
||||||
|
previous.kind !== current.kind ||
|
||||||
|
previous.source !== current.source ||
|
||||||
|
previous.messageID !== current.messageID ||
|
||||||
|
previous.partID !== current.partID ||
|
||||||
|
previous.tool !== current.tool ||
|
||||||
|
previous.toolState !== current.toolState
|
||||||
|
)
|
||||||
|
return
|
||||||
|
return { ...current, text: previous.text + current.text }
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
|
|||||||
import { RunFooterSubagentBody } from "./footer.subagent"
|
import { RunFooterSubagentBody } from "./footer.subagent"
|
||||||
import { RunPromptBody, createPromptState } from "./footer.prompt"
|
import { RunPromptBody, createPromptState } from "./footer.prompt"
|
||||||
import { RunPermissionBody } from "./footer.permission"
|
import { RunPermissionBody } from "./footer.permission"
|
||||||
import { RunQuestionBody } from "./footer.question"
|
import { RunFormBody } from "./footer.form"
|
||||||
|
import { createFormBodyState, type FormBodyState } from "./form.shared"
|
||||||
import { footerWidthPolicy } from "./footer.width"
|
import { footerWidthPolicy } from "./footer.width"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
|
|
||||||
@@ -35,12 +36,11 @@ import type {
|
|||||||
FooterState,
|
FooterState,
|
||||||
FooterSubagentState,
|
FooterSubagentState,
|
||||||
FooterView,
|
FooterView,
|
||||||
|
FormCancel,
|
||||||
|
FormReply,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
QuestionReject,
|
|
||||||
QuestionReply,
|
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunCommand,
|
RunCommand,
|
||||||
RunDiffStyle,
|
|
||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunProvider,
|
RunProvider,
|
||||||
@@ -67,7 +67,7 @@ const EMPTY_BORDER = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RunFooterViewProps = {
|
type RunFooterViewProps = {
|
||||||
directory: string
|
directory: () => string
|
||||||
findFiles: (query: string) => Promise<string[]>
|
findFiles: (query: string) => Promise<string[]>
|
||||||
agents: () => RunAgent[]
|
agents: () => RunAgent[]
|
||||||
references: () => RunReference[]
|
references: () => RunReference[]
|
||||||
@@ -81,14 +81,12 @@ type RunFooterViewProps = {
|
|||||||
subagent?: () => FooterSubagentState
|
subagent?: () => FooterSubagentState
|
||||||
queuedPrompts?: () => FooterQueuedPrompt[]
|
queuedPrompts?: () => FooterQueuedPrompt[]
|
||||||
theme: () => RunTheme
|
theme: () => RunTheme
|
||||||
diffStyle?: RunDiffStyle
|
|
||||||
tuiConfig: RunTuiConfig
|
tuiConfig: RunTuiConfig
|
||||||
history?: () => RunPrompt[]
|
history?: () => RunPrompt[]
|
||||||
agent: string
|
|
||||||
onSubmit: (input: RunPrompt) => boolean
|
onSubmit: (input: RunPrompt) => boolean
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
onFormReply: (input: FormReply) => void | Promise<void>
|
||||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||||
onCycle: () => void
|
onCycle: () => void
|
||||||
onInterrupt: () => boolean
|
onInterrupt: () => boolean
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
@@ -100,15 +98,13 @@ type RunFooterViewProps = {
|
|||||||
onModelSelect: (model: NonNullable<RunInput["model"]>) => void
|
onModelSelect: (model: NonNullable<RunInput["model"]>) => void
|
||||||
onVariantSelect: (variant: string | undefined) => void
|
onVariantSelect: (variant: string | undefined) => void
|
||||||
onRows: (rows: number) => void
|
onRows: (rows: number) => void
|
||||||
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
|
onLayout: (input: { route: FooterPromptRoute; subagentRows: number }) => void
|
||||||
onStatus: (text: string) => void
|
onStatus: (text: string) => void
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
onSubagentInterrupt?: (sessionID: string) => void
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
onQueuedRemove: (messageID: string) => Promise<boolean>
|
onQueuedRemove: (messageID: string) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
|
|
||||||
|
|
||||||
export function RunFooterView(props: RunFooterViewProps) {
|
export function RunFooterView(props: RunFooterViewProps) {
|
||||||
const term = useTerminalDimensions()
|
const term = useTerminalDimensions()
|
||||||
const width = createMemo(() => term().width)
|
const width = createMemo(() => term().width)
|
||||||
@@ -120,7 +116,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
tabs: [],
|
tabs: [],
|
||||||
details: {},
|
details: {},
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
forms: [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -139,7 +135,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
const panel = createMemo(
|
const panel = createMemo(
|
||||||
() =>
|
() =>
|
||||||
active().type === "permission" ||
|
active().type === "permission" ||
|
||||||
active().type === "question" ||
|
active().type === "form" ||
|
||||||
selectingQueued() ||
|
selectingQueued() ||
|
||||||
selectingSubagent() ||
|
selectingSubagent() ||
|
||||||
commanding() ||
|
commanding() ||
|
||||||
@@ -165,7 +161,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background))
|
const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background))
|
||||||
const model = createMemo(() => {
|
const model = createMemo(() => {
|
||||||
const current = props.currentModel()
|
const current = props.currentModel()
|
||||||
return current ? modelInfo(props.providers(), current) : { model: props.state().model, provider: undefined }
|
return current ? modelInfo(props.providers(), current).model : props.state().model
|
||||||
})
|
})
|
||||||
const detail = createMemo(() => {
|
const detail = createMemo(() => {
|
||||||
const current = route()
|
const current = route()
|
||||||
@@ -215,9 +211,24 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
const view = active()
|
const view = active()
|
||||||
return view.type === "permission" ? view : undefined
|
return view.type === "permission" ? view : undefined
|
||||||
})
|
})
|
||||||
const question = createMemo<Extract<FooterView, { type: "question" }> | undefined>(() => {
|
const form = createMemo<Extract<FooterView, { type: "form" }> | undefined>(() => {
|
||||||
const view = active()
|
const view = active()
|
||||||
return view.type === "question" ? view : undefined
|
return view.type === "form" ? view : undefined
|
||||||
|
})
|
||||||
|
const formStates = new Map<string, FormBodyState>()
|
||||||
|
const settledForms = new Set<string>()
|
||||||
|
let formsAbsent = true
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const view = active()
|
||||||
|
if (view.type === "form") {
|
||||||
|
formsAbsent = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (view.type === "permission") return
|
||||||
|
formsAbsent = true
|
||||||
|
formStates.clear()
|
||||||
|
settledForms.clear()
|
||||||
})
|
})
|
||||||
const promptView = createMemo(() => {
|
const promptView = createMemo(() => {
|
||||||
if (active().type !== "prompt") {
|
if (active().type !== "prompt") {
|
||||||
@@ -371,10 +382,8 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
model: model().model,
|
model: model(),
|
||||||
variant: props.currentVariant(),
|
variant: props.currentVariant(),
|
||||||
provider: undefined,
|
|
||||||
// Prefer without provider, but keep it on the shared width policy if we add it back.
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const statusColor = createMemo(() => {
|
const statusColor = createMemo(() => {
|
||||||
@@ -571,7 +580,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.onLayout({
|
props.onLayout({
|
||||||
route: route(),
|
route: route(),
|
||||||
autocomplete: menu(),
|
|
||||||
subagentRows: subagentMenuRows(),
|
subagentRows: subagentMenuRows(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -736,19 +744,36 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
<Match when={active().type === "permission"}>
|
<Match when={active().type === "permission"}>
|
||||||
<RunPermissionBody
|
<RunPermissionBody
|
||||||
request={permission()!.request}
|
request={permission()!.request}
|
||||||
|
directory={props.directory}
|
||||||
theme={theme()}
|
theme={theme()}
|
||||||
block={block()}
|
block={block()}
|
||||||
diffStyle={props.diffStyle}
|
|
||||||
onReply={props.onPermissionReply}
|
onReply={props.onPermissionReply}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={active().type === "question"}>
|
<Match when={active().type === "form"}>
|
||||||
<RunQuestionBody
|
<For each={form() ? [form()!] : []}>
|
||||||
request={question()!.request}
|
{(value) => (
|
||||||
theme={theme()}
|
<RunFormBody
|
||||||
onReply={props.onQuestionReply}
|
request={value.request}
|
||||||
onReject={props.onQuestionReject}
|
theme={theme()}
|
||||||
/>
|
state={formStates.get(value.request.id) ?? createFormBodyState(value.request)}
|
||||||
|
onState={(state) => {
|
||||||
|
if (!formsAbsent && !settledForms.has(state.formID))
|
||||||
|
formStates.set(state.formID, state)
|
||||||
|
}}
|
||||||
|
onReply={async (input) => {
|
||||||
|
await props.onFormReply(input)
|
||||||
|
settledForms.add(input.formID)
|
||||||
|
formStates.delete(input.formID)
|
||||||
|
}}
|
||||||
|
onCancel={async (input) => {
|
||||||
|
await props.onFormCancel(input)
|
||||||
|
settledForms.add(input.formID)
|
||||||
|
formStates.delete(input.formID)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</box>
|
</box>
|
||||||
@@ -824,9 +849,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||||
<text fg={theme().text} wrapMode="none">
|
<text fg={theme().text} wrapMode="none">
|
||||||
{info().model}
|
{info().model}
|
||||||
<Show when={info().provider}>
|
|
||||||
{(provider) => <span style={{ fg: theme().muted }}> {provider()}</span>}
|
|
||||||
</Show>
|
|
||||||
<Show when={info().variant}>
|
<Show when={info().variant}>
|
||||||
{(variant) => (
|
{(variant) => (
|
||||||
<>
|
<>
|
||||||
@@ -889,8 +911,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
index={selectedIndex}
|
index={selectedIndex}
|
||||||
total={() => tabs().length}
|
total={() => tabs().length}
|
||||||
detail={detail}
|
detail={detail}
|
||||||
width={width}
|
|
||||||
diffStyle={props.diffStyle}
|
|
||||||
onCycle={cycleTab}
|
onCycle={cycleTab}
|
||||||
onClose={closeTab}
|
onClose={closeTab}
|
||||||
interrupt={() => subagentInterruptShortcut() || undefined}
|
interrupt={() => subagentInterruptShortcut() || undefined}
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import type { FormAnswer, FormField, FormInfo, FormValue } from "@opencode-ai/client/promise"
|
||||||
|
import type { FormReply, MiniFormRequest } from "./types"
|
||||||
|
|
||||||
|
type AnswerField = Exclude<FormField, { type: "external" }>
|
||||||
|
|
||||||
|
export type FormBodyState = {
|
||||||
|
formID: string
|
||||||
|
field: number
|
||||||
|
answers: Record<string, FormValue | undefined>
|
||||||
|
custom: Record<string, string>
|
||||||
|
selected: number
|
||||||
|
editing: boolean
|
||||||
|
externalReady: Record<string, boolean>
|
||||||
|
submitting: boolean
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormRow = {
|
||||||
|
value: string | boolean
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormBodyState(form: FormInfo): FormBodyState {
|
||||||
|
const answers = Object.fromEntries(
|
||||||
|
form.fields.flatMap((field) =>
|
||||||
|
field.type !== "external" && field.default !== undefined ? [[field.key, field.default]] : [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const custom = Object.fromEntries(
|
||||||
|
form.fields.flatMap((field) => {
|
||||||
|
if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return []
|
||||||
|
if (field.options.some((option) => option.value === field.default)) return []
|
||||||
|
return [[field.key, field.default]]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
formID: form.id,
|
||||||
|
field: 0,
|
||||||
|
answers,
|
||||||
|
custom,
|
||||||
|
selected: formSelected(form.fields[0], answers[form.fields[0]?.key ?? ""]),
|
||||||
|
editing: formTextual(form.fields[0]),
|
||||||
|
externalReady: {},
|
||||||
|
submitting: false,
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSync(state: FormBodyState, form: FormInfo): FormBodyState {
|
||||||
|
return state.formID === form.id ? state : createFormBodyState(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formUnsupported(form: FormInfo): string | undefined {
|
||||||
|
if (!Array.isArray(form.fields) || form.fields.length === 0) return "This form has no supported fields."
|
||||||
|
for (const field of form.fields as ReadonlyArray<FormField | Record<string, unknown>>) {
|
||||||
|
if (!field || typeof field !== "object" || typeof field.type !== "string") return "This form uses an unknown field."
|
||||||
|
if (!("key" in field) || typeof field.key !== "string") return "This form uses an invalid field."
|
||||||
|
if ("when" in field && Array.isArray(field.when) && field.when.length > 0)
|
||||||
|
return "Conditional forms are not supported in Mini yet."
|
||||||
|
if (field.type === "string" && "pattern" in field && field.pattern !== undefined)
|
||||||
|
return "Pattern-constrained forms are not supported in Mini yet."
|
||||||
|
if (!["string", "number", "integer", "boolean", "multiselect", "external"].includes(field.type))
|
||||||
|
return `Field type ${field.type} is not supported in Mini yet.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formLabel(field: FormField) {
|
||||||
|
return field.title ?? (field.type === "external" ? field.url : field.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formCurrent(form: FormInfo, state: FormBodyState) {
|
||||||
|
return form.fields[state.field]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formConfirm(form: FormInfo, state: FormBodyState) {
|
||||||
|
return state.field >= form.fields.length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSingle(form: FormInfo) {
|
||||||
|
if (form.fields.length !== 1) return false
|
||||||
|
const field = form.fields[0]
|
||||||
|
return (
|
||||||
|
field?.type === "boolean" ||
|
||||||
|
field?.type === "number" ||
|
||||||
|
field?.type === "integer" ||
|
||||||
|
field?.type === "external" ||
|
||||||
|
field?.type === "string"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formTextual(field: FormField | undefined) {
|
||||||
|
if (!field) return false
|
||||||
|
return field.type === "number" || field.type === "integer" || (field.type === "string" && !field.options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formPlaceholder(field: FormField | undefined) {
|
||||||
|
if (field?.type === "string") return field.placeholder ?? "Type your answer"
|
||||||
|
return "Enter a number"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formCustom(field: FormField | undefined) {
|
||||||
|
if (!field) return false
|
||||||
|
if (field.type === "string" && field.options) return field.custom === true
|
||||||
|
return field.type === "multiselect" && field.custom === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formRows(field: FormField | undefined): FormRow[] {
|
||||||
|
if (!field) return []
|
||||||
|
if (field.type === "boolean")
|
||||||
|
return [
|
||||||
|
{ value: true, label: "Yes" },
|
||||||
|
{ value: false, label: "No" },
|
||||||
|
]
|
||||||
|
const options = field.type === "multiselect" ? field.options : field.type === "string" ? field.options : undefined
|
||||||
|
if (!options) return []
|
||||||
|
return options.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSelected(field: FormField | undefined, value: FormValue | undefined) {
|
||||||
|
if (!field || value === undefined || Array.isArray(value)) return 0
|
||||||
|
const index = formRows(field).findIndex((row) => row.value === value)
|
||||||
|
if (index !== -1) return index
|
||||||
|
if (typeof value === "string" && formCustom(field)) return formRows(field).length
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formMove(state: FormBodyState, form: FormInfo, direction: -1 | 1): FormBodyState {
|
||||||
|
const field = formCurrent(form, state)
|
||||||
|
const total = formRows(field).length + (formCustom(field) ? 1 : 0)
|
||||||
|
if (total === 0) return state
|
||||||
|
return { ...state, selected: (state.selected + direction + total) % total, error: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSetSelected(state: FormBodyState, selected: number): FormBodyState {
|
||||||
|
return { ...state, selected, error: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState {
|
||||||
|
return { ...state, editing, error: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSetSubmitting(state: FormBodyState, submitting: boolean, error = ""): FormBodyState {
|
||||||
|
return { ...state, submitting, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSetError(state: FormBodyState, error: string): FormBodyState {
|
||||||
|
return { ...state, error, submitting: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSetExternalReady(state: FormBodyState, key: string): FormBodyState {
|
||||||
|
return { ...state, externalReady: { ...state.externalReady, [key]: true }, error: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formInput(state: FormBodyState, field: FormField | undefined) {
|
||||||
|
if (!field || field.type === "external") return ""
|
||||||
|
return state.custom[field.key] ?? formDisplay(field, state.answers[field.key])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSetDraft(state: FormBodyState, field: FormField | undefined, value: string): FormBodyState {
|
||||||
|
if (!field || field.type === "external") return state
|
||||||
|
return { ...state, custom: { ...state.custom, [field.key]: value } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formValidateValue(field: AnswerField, value: FormValue | undefined): string | undefined {
|
||||||
|
if (value === undefined) return field.required ? "Answer required" : undefined
|
||||||
|
if (field.required && (value === "" || (Array.isArray(value) && value.length === 0)))
|
||||||
|
return field.type === "multiselect" ? "Select at least one option" : "Answer required"
|
||||||
|
if (field.type === "string") {
|
||||||
|
if (typeof value !== "string") return "Expected text"
|
||||||
|
if (field.minLength !== undefined && value.length < field.minLength)
|
||||||
|
return `Must be at least ${field.minLength} characters`
|
||||||
|
if (field.maxLength !== undefined && value.length > field.maxLength)
|
||||||
|
return `Must be at most ${field.maxLength} characters`
|
||||||
|
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
|
||||||
|
if (field.format === "uri" && !validURL(value)) return "Expected a URL"
|
||||||
|
if (field.format === "date" && !validDate(value)) return "Expected a date (YYYY-MM-DD)"
|
||||||
|
if (field.format === "date-time" && Number.isNaN(new Date(value).getTime())) return "Expected a date and time"
|
||||||
|
if (field.options && !field.custom && !field.options.some((option) => option.value === value))
|
||||||
|
return "Select an available option"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (field.type === "number" || field.type === "integer") {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number"
|
||||||
|
if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer"
|
||||||
|
if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}`
|
||||||
|
if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
|
||||||
|
if (!Array.isArray(value)) return "Expected selections"
|
||||||
|
if (field.required && value.length === 0) return "Select at least one option"
|
||||||
|
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
|
||||||
|
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
|
||||||
|
if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item)))
|
||||||
|
return "Select only available options"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formValidate(form: FormInfo, state: FormBodyState): string | undefined {
|
||||||
|
const unsupported = formUnsupported(form)
|
||||||
|
if (unsupported) return unsupported
|
||||||
|
for (const field of form.fields) {
|
||||||
|
if (field.type === "external") {
|
||||||
|
if (state.answers[field.key] !== true) return `Acknowledge ${formLabel(field)}`
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const invalid = formValidateValue(field, state.answers[field.key])
|
||||||
|
if (invalid) return `${formLabel(field)}: ${invalid}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formAnswer(form: FormInfo, state: FormBodyState): FormAnswer | undefined {
|
||||||
|
if (formValidate(form, state)) return
|
||||||
|
return Object.fromEntries(
|
||||||
|
form.fields.flatMap((field) => {
|
||||||
|
const value = state.answers[field.key]
|
||||||
|
return value === undefined ? [] : [[field.key, value] as const]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formReply(form: MiniFormRequest, state: FormBodyState): FormReply | undefined {
|
||||||
|
const answer = formAnswer(form, state)
|
||||||
|
if (!answer) return
|
||||||
|
return { sessionID: form.sessionID, formID: form.id, answer, location: form.location }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formSetField(state: FormBodyState, form: FormInfo, index: number): FormBodyState {
|
||||||
|
const bounded = Math.max(0, Math.min(form.fields.length, index))
|
||||||
|
const field = form.fields[bounded]
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
field: bounded,
|
||||||
|
selected: formSelected(field, field ? state.answers[field.key] : undefined),
|
||||||
|
editing: formTextual(field),
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formPick(state: FormBodyState, form: FormInfo): FormBodyState {
|
||||||
|
const field = formCurrent(form, state)
|
||||||
|
if (!field || field.type === "external" || formTextual(field)) return state
|
||||||
|
const rows = formRows(field)
|
||||||
|
if (state.selected === rows.length && formCustom(field)) return formSetEditing(state, true)
|
||||||
|
const row = rows[state.selected]
|
||||||
|
if (!row) return state
|
||||||
|
if (field.type === "multiselect") {
|
||||||
|
const answer = state.answers[field.key]
|
||||||
|
const values = Array.isArray(answer) ? [...answer] : []
|
||||||
|
const value = String(row.value)
|
||||||
|
const index = values.indexOf(value)
|
||||||
|
if (index === -1) values.push(value)
|
||||||
|
if (index !== -1) values.splice(index, 1)
|
||||||
|
return { ...state, answers: { ...state.answers, [field.key]: values }, error: "" }
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
...state,
|
||||||
|
answers: { ...state.answers, [field.key]: row.value },
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
return formSetField(next, form, formSingle(form) ? state.field : state.field + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formCommitInput(state: FormBodyState, form: FormInfo, text: string): FormBodyState {
|
||||||
|
const field = formCurrent(form, state)
|
||||||
|
if (!field || field.type === "external" || field.type === "boolean") return state
|
||||||
|
const input = text.trim()
|
||||||
|
const value = !input ? undefined : field.type === "number" || field.type === "integer" ? Number(input) : input
|
||||||
|
if (field.type === "multiselect") {
|
||||||
|
const answer = state.answers[field.key]
|
||||||
|
const values = Array.isArray(answer) ? [...answer] : []
|
||||||
|
const previous = state.custom[field.key]
|
||||||
|
if (previous) {
|
||||||
|
const index = values.indexOf(previous)
|
||||||
|
if (index !== -1) values.splice(index, 1)
|
||||||
|
}
|
||||||
|
if (input && !values.includes(input)) values.push(input)
|
||||||
|
const invalid = formValidateValue(field, values)
|
||||||
|
if (invalid) return formSetError(state, invalid)
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
answers: { ...state.answers, [field.key]: values },
|
||||||
|
custom: { ...state.custom, [field.key]: input },
|
||||||
|
editing: false,
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const invalid = formValidateValue(field, value)
|
||||||
|
if (invalid) return formSetError(state, invalid)
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
answers: { ...state.answers, [field.key]: value },
|
||||||
|
custom: { ...state.custom, [field.key]: typeof value === "string" ? value : input },
|
||||||
|
editing: false,
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formAcknowledge(state: FormBodyState, form: FormInfo): FormBodyState {
|
||||||
|
const field = formCurrent(form, state)
|
||||||
|
if (field?.type !== "external" || !state.externalReady[field.key]) return state
|
||||||
|
const next = {
|
||||||
|
...state,
|
||||||
|
answers: { ...state.answers, [field.key]: true },
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
return formSetField(next, form, formSingle(form) ? state.field : state.field + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formDisplay(field: AnswerField, value: FormValue | undefined) {
|
||||||
|
if (value === undefined) return ""
|
||||||
|
const label = (item: string | number | boolean) =>
|
||||||
|
formRows(field).find((row) => row.value === item)?.label ?? String(item)
|
||||||
|
return Array.isArray(value) ? value.map(label).join(", ") : label(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formErrorMessage(error: unknown) {
|
||||||
|
if (typeof error === "string" && error.trim()) return error
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const message = Reflect.get(error, "message")
|
||||||
|
if (typeof message === "string" && message.trim()) return message
|
||||||
|
const tag = Reflect.get(error, "_tag")
|
||||||
|
if (typeof tag === "string" && tag.trim()) return tag
|
||||||
|
}
|
||||||
|
return "Form request failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
function validURL(value: string) {
|
||||||
|
try {
|
||||||
|
new URL(value)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDate(value: string) {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||||
|
const date = new Date(`${value}T00:00:00.000Z`)
|
||||||
|
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||||
|
}
|
||||||
@@ -13,8 +13,7 @@
|
|||||||
//
|
//
|
||||||
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
||||||
// the request, delegating to tool.ts for tool-specific formatting.
|
// the request, delegating to tool.ts for tool-specific formatting.
|
||||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
import type { MiniPermissionRequest, PermissionReply } from "./types"
|
||||||
import type { PermissionReply } from "./types"
|
|
||||||
import { toolPath, toolPermissionInfo } from "./tool"
|
import { toolPath, toolPermissionInfo } from "./tool"
|
||||||
|
|
||||||
type Dict = Record<string, unknown>
|
type Dict = Record<string, unknown>
|
||||||
@@ -24,6 +23,7 @@ export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cance
|
|||||||
|
|
||||||
export type PermissionBodyState = {
|
export type PermissionBodyState = {
|
||||||
requestID: string
|
requestID: string
|
||||||
|
sessionID: string
|
||||||
stage: PermissionStage
|
stage: PermissionStage
|
||||||
selected: PermissionOption
|
selected: PermissionOption
|
||||||
message: string
|
message: string
|
||||||
@@ -35,6 +35,7 @@ export type PermissionInfo = {
|
|||||||
title: string
|
title: string
|
||||||
lines: string[]
|
lines: string[]
|
||||||
diff?: string
|
diff?: string
|
||||||
|
patch?: string
|
||||||
file?: string
|
file?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,21 +56,26 @@ function text(v: unknown): string {
|
|||||||
return typeof v === "string" ? v : ""
|
return typeof v === "string" ? v : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function data(request: PermissionV2Request): Dict {
|
function data(request: MiniPermissionRequest): { input: Dict; metadata: Dict } {
|
||||||
const meta = dict(request.metadata)
|
const state = request.tool?.state
|
||||||
return {
|
const metadata = {
|
||||||
...meta,
|
...(state && state.status !== "streaming" ? dict(state.structured) : {}),
|
||||||
...dict(meta.input),
|
...dict(request.metadata),
|
||||||
}
|
}
|
||||||
|
if (!state || state.status === "streaming") return { input: {}, metadata }
|
||||||
|
return { input: dict(state.input), metadata }
|
||||||
}
|
}
|
||||||
|
|
||||||
function patterns(request: PermissionV2Request): string[] {
|
function patterns(request: MiniPermissionRequest): string[] {
|
||||||
return request.resources.filter((item): item is string => typeof item === "string")
|
return request.resources.filter((item): item is string => typeof item === "string")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPermissionBodyState(requestID: string): PermissionBodyState {
|
export function createPermissionBodyState(
|
||||||
|
request: Pick<MiniPermissionRequest, "id" | "sessionID">,
|
||||||
|
): PermissionBodyState {
|
||||||
return {
|
return {
|
||||||
requestID,
|
requestID: request.id,
|
||||||
|
sessionID: request.sessionID,
|
||||||
stage: "permission",
|
stage: "permission",
|
||||||
selected: "once",
|
selected: "once",
|
||||||
message: "",
|
message: "",
|
||||||
@@ -89,10 +95,10 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function permissionInfo(request: PermissionV2Request): PermissionInfo {
|
export function permissionInfo(request: MiniPermissionRequest, directory?: string): PermissionInfo {
|
||||||
const pats = patterns(request)
|
const pats = patterns(request)
|
||||||
const input = data(request)
|
const source = data(request)
|
||||||
const info = toolPermissionInfo(request.action, input, dict(request.metadata), pats)
|
const info = toolPermissionInfo(request.action, source.input, source.metadata, pats, directory)
|
||||||
if (info) {
|
if (info) {
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
@@ -103,7 +109,7 @@ export function permissionInfo(request: PermissionV2Request): PermissionInfo {
|
|||||||
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
||||||
return {
|
return {
|
||||||
icon: "←",
|
icon: "←",
|
||||||
title: `Access external directory ${toolPath(dir, { home: true })}`,
|
title: `Access external directory ${toolPath(dir, { home: true, directory })}`,
|
||||||
lines: pats.map((item) => `- ${item}`),
|
lines: pats.map((item) => `- ${item}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,16 +129,13 @@ export function permissionInfo(request: PermissionV2Request): PermissionInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function permissionAlwaysLines(request: PermissionV2Request): string[] {
|
export function permissionAlwaysLines(request: MiniPermissionRequest): string[] {
|
||||||
const save = request.save ?? []
|
const save = request.save ?? []
|
||||||
if (save.length === 1 && save[0] === "*") {
|
if (save.length === 1 && save[0] === "*") {
|
||||||
return [`This will allow ${request.action} until OpenCode is restarted.`]
|
return [`This will allow ${request.action} until OpenCode is restarted.`]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return ["This will allow the following patterns until OpenCode is restarted.", ...save.map((item) => `- ${item}`)]
|
||||||
"This will allow the following patterns until OpenCode is restarted.",
|
|
||||||
...save.map((item) => `- ${item}`),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function permissionLabel(option: PermissionOption): string {
|
export function permissionLabel(option: PermissionOption): string {
|
||||||
@@ -143,8 +146,14 @@ export function permissionLabel(option: PermissionOption): string {
|
|||||||
return "Cancel"
|
return "Cancel"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function permissionReply(requestID: string, reply: PermissionReply["reply"], message?: string): PermissionReply {
|
export function permissionReply(
|
||||||
|
sessionID: string,
|
||||||
|
requestID: string,
|
||||||
|
reply: PermissionReply["reply"],
|
||||||
|
message?: string,
|
||||||
|
): PermissionReply {
|
||||||
return {
|
return {
|
||||||
|
sessionID,
|
||||||
requestID,
|
requestID,
|
||||||
reply,
|
reply,
|
||||||
...(message && message.trim() ? { message: message.trim() } : {}),
|
...(message && message.trim() ? { message: message.trim() } : {}),
|
||||||
@@ -203,7 +212,7 @@ export function permissionRun(state: PermissionBodyState, requestID: string, opt
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
reply: permissionReply(requestID, "once"),
|
reply: permissionReply(state.sessionID, requestID, "once"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +232,7 @@ export function permissionRun(state: PermissionBodyState, requestID: string, opt
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
reply: permissionReply(requestID, "always"),
|
reply: permissionReply(state.sessionID, requestID, "always"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +241,7 @@ export function permissionReject(state: PermissionBodyState, requestID: string):
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return permissionReply(requestID, "reject", state.message)
|
return permissionReply(state.sessionID, requestID, "reject", state.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function permissionCancel(state: PermissionBodyState): PermissionBodyState {
|
export function permissionCancel(state: PermissionBodyState): PermissionBodyState {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { RunPromptPart } from "./types"
|
import type { RunPromptPart } from "./types"
|
||||||
|
import { slashHead } from "./prompt.shared"
|
||||||
|
|
||||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||||
|
|
||||||
@@ -57,29 +58,6 @@ export function realignEditorPromptParts(content: string, parts: RunPromptPart[]
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
function slashHead(text: string) {
|
|
||||||
if (!text.startsWith("/")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 1; i < text.length; i++) {
|
|
||||||
switch (text[i]) {
|
|
||||||
case " ":
|
|
||||||
case "\t":
|
|
||||||
case "\n":
|
|
||||||
return {
|
|
||||||
name: text.slice(1, i),
|
|
||||||
arguments: text.slice(i + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: text.slice(1),
|
|
||||||
arguments: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptPartText(part: Mention) {
|
function promptPartText(part: Mention) {
|
||||||
if (part.type === "agent") {
|
if (part.type === "agent") {
|
||||||
return part.source?.value
|
return part.source?.value
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ export function isNewCommand(input: string): boolean {
|
|||||||
return input.trim().toLowerCase() === "/new"
|
return input.trim().toLowerCase() === "/new"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function slashHead(text: string) {
|
||||||
|
if (!text.startsWith("/")) return
|
||||||
|
const end = text.slice(1).search(/[ \t\n]/)
|
||||||
|
if (end === -1) return { name: text.slice(1), arguments: "", end: text.length }
|
||||||
|
const split = end + 1
|
||||||
|
return { name: text.slice(1, split), arguments: text.slice(split + 1), end: split }
|
||||||
|
}
|
||||||
|
|
||||||
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||||
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
||||||
const next: RunPrompt[] = []
|
const next: RunPrompt[] = []
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
// Pure state machine for the question UI.
|
|
||||||
//
|
|
||||||
// Supports both single-question and multi-question flows. Single questions
|
|
||||||
// submit immediately on selection. Multi-question flows use tabs and a
|
|
||||||
// final confirmation step.
|
|
||||||
//
|
|
||||||
// State transitions:
|
|
||||||
// questionSelect → picks an option (single: submits, multi: toggles/advances)
|
|
||||||
// questionSave → saves custom text input
|
|
||||||
// questionMove → arrow key navigation through options
|
|
||||||
// questionSetTab → tab navigation between questions
|
|
||||||
// questionSubmit → builds the final QuestionReply with all answers
|
|
||||||
//
|
|
||||||
// Custom answers: if a question has custom=true, an extra "Type your own
|
|
||||||
// answer" option appears. Selecting it enters editing mode with a text field.
|
|
||||||
import type { QuestionV2Info, QuestionV2Request } from "@opencode-ai/client/promise"
|
|
||||||
import type { QuestionReject, QuestionReply } from "./types"
|
|
||||||
|
|
||||||
export type QuestionBodyState = {
|
|
||||||
requestID: string
|
|
||||||
tab: number
|
|
||||||
answers: string[][]
|
|
||||||
custom: string[]
|
|
||||||
selected: number
|
|
||||||
editing: boolean
|
|
||||||
submitting: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionStep = {
|
|
||||||
state: QuestionBodyState
|
|
||||||
reply?: QuestionReply
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createQuestionBodyState(requestID: string): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
requestID,
|
|
||||||
tab: 0,
|
|
||||||
answers: [],
|
|
||||||
custom: [],
|
|
||||||
selected: 0,
|
|
||||||
editing: false,
|
|
||||||
submitting: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSync(state: QuestionBodyState, requestID: string): QuestionBodyState {
|
|
||||||
if (state.requestID === requestID) {
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
return createQuestionBodyState(requestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSingle(request: QuestionV2Request): boolean {
|
|
||||||
return request.questions.length === 1 && request.questions[0]?.multiple !== true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionTabs(request: QuestionV2Request): number {
|
|
||||||
return questionSingle(request) ? 1 : request.questions.length + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionConfirm(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
|
||||||
return !questionSingle(request) && state.tab === request.questions.length
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionInfo(request: QuestionV2Request, state: QuestionBodyState): QuestionV2Info | undefined {
|
|
||||||
return request.questions[state.tab]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionCustom(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
|
||||||
return questionInfo(request, state)?.custom !== false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionInput(state: QuestionBodyState): string {
|
|
||||||
return state.custom[state.tab] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionPicked(state: QuestionBodyState): boolean {
|
|
||||||
const value = questionInput(state)
|
|
||||||
if (!value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.answers[state.tab]?.includes(value) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionOther(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (!info || info.custom === false) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.selected === info.options.length
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionTotal(request: QuestionV2Request, state: QuestionBodyState): number {
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (!info) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return info.options.length + (questionCustom(request, state) ? 1 : 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionAnswers(state: QuestionBodyState, count: number): string[][] {
|
|
||||||
return Array.from({ length: count }, (_, idx) => state.answers[idx] ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSetTab(state: QuestionBodyState, tab: number): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
tab,
|
|
||||||
selected: 0,
|
|
||||||
editing: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSetSelected(state: QuestionBodyState, selected: number): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
selected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSetEditing(state: QuestionBodyState, editing: boolean): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
editing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSetSubmitting(state: QuestionBodyState, submitting: boolean): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
submitting,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeAnswers(state: QuestionBodyState, tab: number, list: string[]): QuestionBodyState {
|
|
||||||
const answers = [...state.answers]
|
|
||||||
answers[tab] = list
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
answers,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionStoreCustom(state: QuestionBodyState, tab: number, text: string): QuestionBodyState {
|
|
||||||
const custom = [...state.custom]
|
|
||||||
custom[tab] = text
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
custom,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function questionPick(
|
|
||||||
state: QuestionBodyState,
|
|
||||||
request: QuestionV2Request,
|
|
||||||
answer: string,
|
|
||||||
custom = false,
|
|
||||||
): QuestionStep {
|
|
||||||
const answers = [...state.answers]
|
|
||||||
answers[state.tab] = [answer]
|
|
||||||
let next: QuestionBodyState = {
|
|
||||||
...state,
|
|
||||||
answers,
|
|
||||||
editing: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (custom) {
|
|
||||||
const list = [...state.custom]
|
|
||||||
list[state.tab] = answer
|
|
||||||
next = {
|
|
||||||
...next,
|
|
||||||
custom: list,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionSingle(request)) {
|
|
||||||
return {
|
|
||||||
state: next,
|
|
||||||
reply: {
|
|
||||||
requestID: request.id,
|
|
||||||
answers: [[answer]],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: questionSetTab(next, state.tab + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyState {
|
|
||||||
const list = [...(state.answers[state.tab] ?? [])]
|
|
||||||
const idx = list.indexOf(answer)
|
|
||||||
if (idx === -1) {
|
|
||||||
list.push(answer)
|
|
||||||
} else {
|
|
||||||
list.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return storeAnswers(state, state.tab, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionMove(state: QuestionBodyState, request: QuestionV2Request, dir: -1 | 1): QuestionBodyState {
|
|
||||||
const total = questionTotal(request, state)
|
|
||||||
if (total === 0) {
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
selected: (state.selected + dir + total) % total,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSelect(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (!info) {
|
|
||||||
return { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionOther(request, state)) {
|
|
||||||
if (!info.multiple) {
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(state, true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = questionInput(state)
|
|
||||||
if (value && questionPicked(state)) {
|
|
||||||
return {
|
|
||||||
state: questionToggle(state, value),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(state, true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const option = info.options[state.selected]
|
|
||||||
if (!option) {
|
|
||||||
return { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.multiple) {
|
|
||||||
return {
|
|
||||||
state: questionToggle(state, option.label),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return questionPick(state, request, option.label)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSave(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (!info) {
|
|
||||||
return { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = questionInput(state).trim()
|
|
||||||
const prev = state.custom[state.tab]
|
|
||||||
if (!value) {
|
|
||||||
if (!prev) {
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(state, false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = questionStoreCustom(state, state.tab, "")
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(
|
|
||||||
storeAnswers(
|
|
||||||
next,
|
|
||||||
state.tab,
|
|
||||||
(state.answers[state.tab] ?? []).filter((item) => item !== prev),
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.multiple) {
|
|
||||||
const answers = [...(state.answers[state.tab] ?? [])]
|
|
||||||
if (prev) {
|
|
||||||
const idx = answers.indexOf(prev)
|
|
||||||
if (idx !== -1) {
|
|
||||||
answers.splice(idx, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!answers.includes(value)) {
|
|
||||||
answers.push(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = questionStoreCustom(state, state.tab, value)
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(storeAnswers(next, state.tab, answers), false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return questionPick(state, request, value, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply {
|
|
||||||
return {
|
|
||||||
requestID: request.id,
|
|
||||||
answers: questionAnswers(state, request.questions.length),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionReject(request: QuestionV2Request): QuestionReject {
|
|
||||||
return {
|
|
||||||
requestID: request.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string {
|
|
||||||
if (state.submitting) {
|
|
||||||
return "Waiting for question event..."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionConfirm(request, state)) {
|
|
||||||
return "enter submit esc dismiss"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.editing) {
|
|
||||||
return "enter save esc cancel"
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (questionSingle(request)) {
|
|
||||||
return `↑↓ select enter ${info?.multiple ? "toggle" : "submit"} esc dismiss`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `⇆ tab ↑↓ select enter ${info?.multiple ? "toggle" : "confirm"} esc dismiss`
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
// Boot-time resolution for direct interactive mode.
|
// Boot-time resolution for direct interactive mode.
|
||||||
//
|
//
|
||||||
// These functions run concurrently at startup to gather everything the runtime
|
// These functions run concurrently at startup to gather everything the runtime
|
||||||
// needs before the first frame: TUI keymap config, diff display style,
|
// needs before the first frame: TUI keymap config, model catalog, and session history for the prompt
|
||||||
// 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
|
// history ring. All are async because they read config or hit the SDK, but
|
||||||
// none block each other.
|
// none block each other.
|
||||||
import { resolve } from "../config/v1"
|
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||||
|
import { resolve } from "../config"
|
||||||
import { loadRunProviders } from "./catalog.shared"
|
import { loadRunProviders } from "./catalog.shared"
|
||||||
import { resolveCurrentSession, sessionHistory } from "./session.shared"
|
import { resolveCurrentSession, sessionHistory } from "./session.shared"
|
||||||
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
import type { RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
||||||
import { pickVariant } from "./variant.shared"
|
import { pickVariant } from "./variant.shared"
|
||||||
|
|
||||||
export type ModelInfo = {
|
export type ModelInfo = {
|
||||||
providers: RunProvider[]
|
providers: RunProvider[]
|
||||||
variants: string[]
|
|
||||||
limits: Record<string, number>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionInfo = {
|
export type SessionInfo = {
|
||||||
@@ -27,8 +25,6 @@ export type SessionInfo = {
|
|||||||
function emptyModelInfo(): ModelInfo {
|
function emptyModelInfo(): ModelInfo {
|
||||||
return {
|
return {
|
||||||
providers: [],
|
providers: [],
|
||||||
variants: [],
|
|
||||||
limits: {},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,47 +37,24 @@ function emptySessionInfo(): SessionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function defaultRunTuiConfig(platform: NodeJS.Platform): RunTuiConfig {
|
function defaultRunTuiConfig(platform: NodeJS.Platform): RunTuiConfig {
|
||||||
return {
|
return resolve({}, { terminalSuspend: platform !== "win32" })
|
||||||
...resolve({}, { terminalSuspend: platform !== "win32" }),
|
|
||||||
diff_style: "auto",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModelInfo(
|
async function loadModelInfo(sdk: RunInput["sdk"], location: LocationRef, signal?: AbortSignal): Promise<ModelInfo> {
|
||||||
sdk: RunInput["sdk"],
|
return { providers: await loadRunProviders(sdk, location, signal) }
|
||||||
directory: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
): Promise<ModelInfo> {
|
|
||||||
const providers = await loadRunProviders(sdk, directory)
|
|
||||||
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 { providers, variants: [], limits }
|
|
||||||
const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]
|
|
||||||
return {
|
|
||||||
providers,
|
|
||||||
variants: Object.keys(info?.variants ?? {}),
|
|
||||||
limits,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches available variants and context limits for every provider/model pair.
|
// Fetches available providers and variants.
|
||||||
export async function resolveModelInfo(
|
export async function resolveModelInfo(
|
||||||
sdk: RunInput["sdk"],
|
sdk: RunInput["sdk"],
|
||||||
directory: string,
|
location: LocationRef,
|
||||||
model: RunInput["model"],
|
signal?: AbortSignal,
|
||||||
): Promise<ModelInfo> {
|
): Promise<ModelInfo> {
|
||||||
return loadModelInfo(sdk, directory, model).catch(() => emptyModelInfo())
|
return loadModelInfo(sdk, location, signal).catch(() => emptyModelInfo())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveModelInfoStrict(sdk: RunInput["sdk"], directory: string, model: RunInput["model"]) {
|
export function resolveModelInfoStrict(sdk: RunInput["sdk"], location: LocationRef, signal?: AbortSignal) {
|
||||||
return loadModelInfo(sdk, directory, model)
|
return loadModelInfo(sdk, location, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches session messages to determine if this is the first turn and build prompt history.
|
// Fetches session messages to determine if this is the first turn and build prompt history.
|
||||||
@@ -89,8 +62,9 @@ export async function resolveSessionInfo(
|
|||||||
sdk: RunInput["sdk"],
|
sdk: RunInput["sdk"],
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
model: RunInput["model"],
|
model: RunInput["model"],
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<SessionInfo> {
|
): Promise<SessionInfo> {
|
||||||
return resolveCurrentSession(sdk, sessionID)
|
return resolveCurrentSession(sdk, sessionID, signal)
|
||||||
.then((session) => ({
|
.then((session) => ({
|
||||||
first: session.first,
|
first: session.first,
|
||||||
history: sessionHistory(session),
|
history: sessionHistory(session),
|
||||||
@@ -109,10 +83,3 @@ export async function resolveRunTuiConfig(
|
|||||||
.then((value) => value ?? defaultRunTuiConfig(platform))
|
.then((value) => value ?? defaultRunTuiConfig(platform))
|
||||||
.catch(() => defaultRunTuiConfig(platform))
|
.catch(() => defaultRunTuiConfig(platform))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDiffStyle(
|
|
||||||
config?: RunTuiConfig | Promise<RunTuiConfig>,
|
|
||||||
platform: NodeJS.Platform = "linux",
|
|
||||||
): Promise<RunDiffStyle> {
|
|
||||||
return resolveRunTuiConfig(config, platform).then((value) => value.diff_style ?? "auto")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import { entrySplash, exitSplash, splashMeta } from "./splash"
|
|||||||
import { resolveRunTheme } from "./theme"
|
import { resolveRunTheme } from "./theme"
|
||||||
import type {
|
import type {
|
||||||
FooterApi,
|
FooterApi,
|
||||||
|
FormCancel,
|
||||||
|
FormReply,
|
||||||
MiniHost,
|
MiniHost,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
QuestionReject,
|
|
||||||
QuestionReply,
|
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
@@ -49,7 +49,7 @@ type FooterLabels = {
|
|||||||
|
|
||||||
export type LifecycleInput = {
|
export type LifecycleInput = {
|
||||||
host: MiniHost
|
host: MiniHost
|
||||||
directory: string
|
getDirectory: () => string
|
||||||
findFiles: (query: string) => Promise<string[]>
|
findFiles: (query: string) => Promise<string[]>
|
||||||
agents: RunAgent[]
|
agents: RunAgent[]
|
||||||
references: RunReference[]
|
references: RunReference[]
|
||||||
@@ -63,8 +63,8 @@ export type LifecycleInput = {
|
|||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
|
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
onFormReply: (input: FormReply) => void | Promise<void>
|
||||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||||
onCycleVariant?: () => CycleResult | void
|
onCycleVariant?: () => CycleResult | void
|
||||||
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
|
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
|
||||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||||
@@ -175,7 +175,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
consoleMode: "disabled",
|
consoleMode: "disabled",
|
||||||
clearOnShutdown: false,
|
clearOnShutdown: false,
|
||||||
})
|
})
|
||||||
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
const tuiConfig = await input.tuiConfig
|
||||||
|
const theme = await resolveRunTheme(renderer, tuiConfig.theme)
|
||||||
renderer.setBackgroundColor(theme.background)
|
renderer.setBackgroundColor(theme.background)
|
||||||
const state: SplashState = {
|
const state: SplashState = {
|
||||||
entry: false,
|
entry: false,
|
||||||
@@ -199,7 +200,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
...meta,
|
...meta,
|
||||||
theme: theme.splash,
|
theme: theme.splash,
|
||||||
showSession: splash.showSession,
|
showSession: splash.showSession,
|
||||||
detail: directoryLabel(input.directory, input.host.paths.home),
|
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await renderer.idle().catch(() => {})
|
await renderer.idle().catch(() => {})
|
||||||
@@ -210,7 +211,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
let detachSigintListener: (() => void) | undefined
|
let detachSigintListener: (() => void) | undefined
|
||||||
|
|
||||||
const footer = new RunFooter(renderer, {
|
const footer = new RunFooter(renderer, {
|
||||||
directory: input.directory,
|
directory: input.getDirectory,
|
||||||
findFiles: input.findFiles,
|
findFiles: input.findFiles,
|
||||||
agents: input.agents,
|
agents: input.agents,
|
||||||
references: input.references,
|
references: input.references,
|
||||||
@@ -223,10 +224,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
theme,
|
theme,
|
||||||
wrote,
|
wrote,
|
||||||
tuiConfig,
|
tuiConfig,
|
||||||
diffStyle: tuiConfig.diff_style ?? "auto",
|
|
||||||
onPermissionReply: input.onPermissionReply,
|
onPermissionReply: input.onPermissionReply,
|
||||||
onQuestionReply: input.onQuestionReply,
|
onFormReply: input.onFormReply,
|
||||||
onQuestionReject: input.onQuestionReject,
|
onFormCancel: input.onFormCancel,
|
||||||
onCycleVariant: input.onCycleVariant,
|
onCycleVariant: input.onCycleVariant,
|
||||||
onModelSelect: input.onModelSelect,
|
onModelSelect: input.onModelSelect,
|
||||||
onVariantSelect: input.onVariantSelect,
|
onVariantSelect: input.onVariantSelect,
|
||||||
@@ -243,7 +243,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
try {
|
try {
|
||||||
return await input.host.editor.open({
|
return await input.host.editor.open({
|
||||||
value,
|
value,
|
||||||
cwd: input.directory,
|
cwd: input.getDirectory(),
|
||||||
renderer,
|
renderer,
|
||||||
stdin: input.host.terminal.stdin,
|
stdin: input.host.terminal.stdin,
|
||||||
})
|
})
|
||||||
@@ -369,10 +369,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
}),
|
}),
|
||||||
theme: footer.currentTheme().splash,
|
theme: footer.currentTheme().splash,
|
||||||
showSession: splash.showSession,
|
showSession: splash.showSession,
|
||||||
detail: directoryLabel(input.directory, input.host.paths.home),
|
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
renderer.requestRender()
|
renderer.requestRender()
|
||||||
|
await renderer.idle().catch(() => {})
|
||||||
},
|
},
|
||||||
close,
|
close,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
// and tracks per-turn wall-clock duration for the footer status line.
|
// and tracks per-turn wall-clock duration for the footer status line.
|
||||||
//
|
//
|
||||||
// Resolves when the footer closes and all in-flight work finishes.
|
// Resolves when the footer closes and all in-flight work finishes.
|
||||||
import { ascending } from "@opencode-ai/schema/identifier"
|
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import { isExitCommand, isNewCommand } from "./prompt.shared"
|
import { isExitCommand, isNewCommand } from "./prompt.shared"
|
||||||
@@ -287,7 +286,6 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
) {
|
) {
|
||||||
const queued: FooterQueuedPrompt = {
|
const queued: FooterQueuedPrompt = {
|
||||||
messageID: SessionMessage.ID.create(),
|
messageID: SessionMessage.ID.create(),
|
||||||
partID: "prt_" + ascending(),
|
|
||||||
prompt,
|
prompt,
|
||||||
}
|
}
|
||||||
state.queued = [...state.queued, queued]
|
state.queued = [...state.queued, queued]
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
type PendingTask<T> = {
|
|
||||||
current?: Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reusePendingTask<T>(slot: PendingTask<T>, run: () => Promise<T>) {
|
|
||||||
if (slot.current) {
|
|
||||||
return slot.current
|
|
||||||
}
|
|
||||||
|
|
||||||
const task = run().finally(() => {
|
|
||||||
if (slot.current === task) {
|
|
||||||
slot.current = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
slot.current = task
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
+362
-316
@@ -1,61 +1,44 @@
|
|||||||
// Top-level orchestrator for `opencode mini`.
|
// Top-level orchestrator for `opencode mini`.
|
||||||
//
|
//
|
||||||
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
|
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
|
||||||
// and prompt queue together into a single session loop. Two entry points:
|
// and prompt queue together into a single session loop. The frontend paints
|
||||||
//
|
// before resolving its Session, then:
|
||||||
// runInteractiveMode -- used when an SDK client already exists (attach mode)
|
|
||||||
// runInteractiveDeferredMode -- paints before resolving its session
|
|
||||||
//
|
|
||||||
// Both delegate to runInteractiveRuntime, which:
|
|
||||||
// 1. resolves TUI config, model info, and session history,
|
// 1. resolves TUI config, model info, and session history,
|
||||||
// 2. creates the split-footer lifecycle (renderer + RunFooter),
|
// 2. creates the split-footer lifecycle (renderer + RunFooter),
|
||||||
// 3. starts the stream transport (SDK event subscription), lazily for fresh
|
// 3. starts the stream transport (SDK event subscription), lazily for fresh
|
||||||
// local sessions,
|
// local sessions,
|
||||||
// 4. runs the prompt queue until the footer closes.
|
// 4. runs the prompt queue until the footer closes.
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
|
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||||
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
|
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
|
||||||
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 { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
|
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
|
||||||
import type {
|
import type { LocalReplayRow, MiniHost, RunInput, RunPrompt, RunProvider, RunTuiConfig, StreamCommit } from "./types"
|
||||||
LocalReplayAnchor,
|
|
||||||
LocalReplayRow,
|
|
||||||
MiniHost,
|
|
||||||
RunInput,
|
|
||||||
RunPrompt,
|
|
||||||
RunProvider,
|
|
||||||
RunTuiConfig,
|
|
||||||
StreamCommit,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
/** @internal Exported for testing */
|
type BootContext = Pick<RunInput, "sdk" | "agent" | "model" | "variant"> & {
|
||||||
export { pickVariant, resolveVariant } from "./variant.shared"
|
location: LocationRef
|
||||||
|
}
|
||||||
/** @internal Exported for testing */
|
|
||||||
export { runPromptQueue } from "./runtime.queue"
|
|
||||||
|
|
||||||
type BootContext = Pick<
|
|
||||||
RunInput,
|
|
||||||
"sdk" | "directory" | "sessionID" | "sessionTitle" | "resume" | "agent" | "model" | "variant"
|
|
||||||
>
|
|
||||||
|
|
||||||
type CreateSessionInput = {
|
type CreateSessionInput = {
|
||||||
|
location: LocationRef
|
||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
model: RunInput["model"]
|
model: RunInput["model"]
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput) => Promise<{ id: string; title?: string }>
|
type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput, signal?: AbortSignal) => Promise<ResolvedSession>
|
||||||
|
type Reconnect = (signal: AbortSignal) => Promise<RunInput["sdk"]>
|
||||||
|
|
||||||
type RunRuntimeInput = {
|
type RunRuntimeInput = {
|
||||||
host: MiniHost
|
host: MiniHost
|
||||||
boot: () => Promise<BootContext>
|
boot: () => Promise<BootContext>
|
||||||
afterPaint?: (ctx: BootContext) => Promise<void> | void
|
resolveSession: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
|
||||||
resolveSession?: (ctx: BootContext) => Promise<ResolvedSession>
|
createSession?: CreateSession
|
||||||
createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession>
|
reconnect?: Reconnect
|
||||||
files: RunInput["files"]
|
files: RunInput["files"]
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
thinking: boolean
|
thinking?: boolean
|
||||||
replay?: boolean
|
replay?: boolean
|
||||||
replayLimit?: number
|
replayLimit?: number
|
||||||
demo?: RunInput["demo"]
|
demo?: RunInput["demo"]
|
||||||
@@ -65,16 +48,16 @@ type RunRuntimeInput = {
|
|||||||
export type RunDeferredInput = {
|
export type RunDeferredInput = {
|
||||||
host: MiniHost
|
host: MiniHost
|
||||||
sdk: RunInput["sdk"]
|
sdk: RunInput["sdk"]
|
||||||
|
reconnect?: Reconnect
|
||||||
directory: string
|
directory: string
|
||||||
resolveAgent: () => Promise<string | undefined>
|
target: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
|
||||||
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string; resume?: boolean } | undefined>
|
|
||||||
createSession?: CreateSession
|
createSession?: CreateSession
|
||||||
agent: RunInput["agent"]
|
agent: RunInput["agent"]
|
||||||
model: RunInput["model"]
|
model: RunInput["model"]
|
||||||
variant: RunInput["variant"]
|
variant: RunInput["variant"]
|
||||||
files: RunInput["files"]
|
files: RunInput["files"]
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
thinking: boolean
|
thinking?: boolean
|
||||||
replay?: boolean
|
replay?: boolean
|
||||||
replayLimit?: number
|
replayLimit?: number
|
||||||
demo?: RunInput["demo"]
|
demo?: RunInput["demo"]
|
||||||
@@ -99,44 +82,30 @@ type StreamState = {
|
|||||||
type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]>
|
type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]>
|
||||||
|
|
||||||
type ResolvedSession = {
|
type ResolvedSession = {
|
||||||
|
sdk?: RunInput["sdk"]
|
||||||
sessionID: string
|
sessionID: string
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
|
location: RunInput["location"]
|
||||||
|
model: RunInput["model"]
|
||||||
|
variant: string | undefined
|
||||||
agent?: string | undefined
|
agent?: string | undefined
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionResolver(fn?: CreateSession) {
|
|
||||||
if (!fn) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return async (ctx: BootContext, input: CreateSessionInput): Promise<ResolvedSession> => {
|
|
||||||
const created = await fn(ctx.sdk, input)
|
|
||||||
if (!created.id) {
|
|
||||||
throw new Error("Failed to create session")
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionID: created.id,
|
|
||||||
sessionTitle: created.title,
|
|
||||||
agent: input.agent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type RuntimeState = {
|
type RuntimeState = {
|
||||||
|
sdk: RunInput["sdk"]
|
||||||
shown: boolean
|
shown: boolean
|
||||||
aborting: boolean
|
aborting: boolean
|
||||||
model: RunInput["model"]
|
model: RunInput["model"]
|
||||||
providers: RunProvider[]
|
providers: RunProvider[]
|
||||||
variants: string[]
|
variants: string[]
|
||||||
limits: Record<string, number>
|
|
||||||
activeVariant: string | undefined
|
activeVariant: string | undefined
|
||||||
sessionID: string
|
sessionID: string
|
||||||
history: RunPrompt[]
|
history: RunPrompt[]
|
||||||
localRows: LocalReplayRow[]
|
localRows: LocalReplayRow[]
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
|
location: LocationRef
|
||||||
switching?: Promise<void>
|
switching?: Promise<void>
|
||||||
demo?: RunDemo
|
demo?: RunDemo
|
||||||
selectSubagent?: (sessionID: string | undefined) => void
|
selectSubagent?: (sessionID: string | undefined) => void
|
||||||
@@ -144,12 +113,10 @@ type RuntimeState = {
|
|||||||
stream?: Promise<StreamState>
|
stream?: Promise<StreamState>
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasSession(input: RunRuntimeInput, state: RuntimeState) {
|
type ClientAttempt = {
|
||||||
return !input.resolveSession || !!state.sessionID
|
sdk: RunInput["sdk"]
|
||||||
}
|
generation: number
|
||||||
|
signal: AbortSignal
|
||||||
function eagerStream(input: RunRuntimeInput, ctx: BootContext) {
|
|
||||||
return ctx.resume === true || !input.resolveSession || !!input.demo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
|
function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
|
||||||
@@ -160,22 +127,42 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
|
|||||||
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
|
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formRequestOptions(location: LocationRef | undefined) {
|
||||||
|
if (!location) return
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
"x-opencode-directory": encodeURIComponent(location.directory),
|
||||||
|
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formAlreadySettled(error: unknown) {
|
||||||
|
return !!error && typeof error === "object" && Reflect.get(error, "_tag") === "FormAlreadySettledError"
|
||||||
|
}
|
||||||
|
|
||||||
const RESIZE_DELAY = 250
|
const RESIZE_DELAY = 250
|
||||||
const LOCAL_REPLAY_ROW_LIMIT = 100
|
const LOCAL_REPLAY_ROW_LIMIT = 100
|
||||||
|
|
||||||
async function resolveExitTitle(
|
function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefined> {
|
||||||
ctx: BootContext,
|
if (signal.aborted) return Promise.resolve(undefined)
|
||||||
input: RunRuntimeInput,
|
return new Promise((resolve) => {
|
||||||
state: RuntimeState,
|
const abort = () => {
|
||||||
): Promise<string | undefined> {
|
signal.removeEventListener("abort", abort)
|
||||||
if (!state.shown || !hasSession(input, state)) {
|
resolve(undefined)
|
||||||
return undefined
|
}
|
||||||
}
|
signal.addEventListener("abort", abort, { once: true })
|
||||||
|
void task.then(
|
||||||
return ctx.sdk.session
|
(value) => {
|
||||||
.get({ sessionID: state.sessionID })
|
signal.removeEventListener("abort", abort)
|
||||||
.then((session) => session.title)
|
resolve(value)
|
||||||
.catch(() => undefined)
|
},
|
||||||
|
() => {
|
||||||
|
signal.removeEventListener("abort", abort)
|
||||||
|
resolve(undefined)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core runtime loop. Boot resolves the SDK context, then we set up the
|
// Core runtime loop. Boot resolves the SDK context, then we set up the
|
||||||
@@ -189,74 +176,43 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
const log = input.host.diagnostics.trace
|
const log = input.host.diagnostics.trace
|
||||||
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform)
|
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform)
|
||||||
const ctx = await input.boot()
|
const ctx = await input.boot()
|
||||||
const sessionTask =
|
const runtimeController = new AbortController()
|
||||||
ctx.resume === true
|
const session = {
|
||||||
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
first: true,
|
||||||
: Promise.resolve({
|
history: [] as RunPrompt[],
|
||||||
first: true,
|
model: undefined as RunInput["model"],
|
||||||
history: [],
|
variant: undefined as string | undefined,
|
||||||
model: undefined,
|
}
|
||||||
variant: undefined,
|
const savedVariant = await input.host.preferences.resolveVariant(ctx.model)
|
||||||
})
|
|
||||||
const savedTask = input.host.preferences.resolveVariant(ctx.model)
|
|
||||||
const [session, savedVariant] = await Promise.all([sessionTask, savedTask])
|
|
||||||
const state: RuntimeState = {
|
const state: RuntimeState = {
|
||||||
|
sdk: ctx.sdk,
|
||||||
shown: !session.first,
|
shown: !session.first,
|
||||||
aborting: false,
|
aborting: false,
|
||||||
model: ctx.model ?? session.model,
|
model: ctx.model ?? session.model,
|
||||||
providers: [],
|
providers: [],
|
||||||
variants: [],
|
variants: [],
|
||||||
limits: {},
|
|
||||||
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
|
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
|
||||||
sessionID: ctx.sessionID,
|
sessionID: "",
|
||||||
history: [...session.history],
|
history: [...session.history],
|
||||||
localRows: [],
|
localRows: [],
|
||||||
sessionTitle: ctx.sessionTitle,
|
|
||||||
agent: ctx.agent,
|
agent: ctx.agent,
|
||||||
|
location: ctx.location,
|
||||||
}
|
}
|
||||||
const loadModel = async () => {
|
const settleForm = async (sessionID: string, formID: string) => {
|
||||||
if (state.model) {
|
if (!state.stream) return
|
||||||
return {
|
const stream = await state.stream
|
||||||
model: state.model,
|
stream.handle.settleForm?.(sessionID, formID)
|
||||||
savedVariant,
|
|
||||||
boot: true,
|
|
||||||
info: await resolveModelInfo(ctx.sdk, ctx.directory, state.model),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = await waitForDefaultModel({
|
|
||||||
sdk: ctx.sdk,
|
|
||||||
directory: ctx.directory,
|
|
||||||
active: () => !footer.isClosed,
|
|
||||||
})
|
|
||||||
if (footer.isClosed) return
|
|
||||||
const [fallbackSavedVariant, info] = await Promise.all([
|
|
||||||
input.host.preferences.resolveVariant(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)({
|
||||||
host: input.host,
|
host: input.host,
|
||||||
directory: ctx.directory,
|
getDirectory: () => state.location.directory,
|
||||||
findFiles: (query) =>
|
findFiles: (query) =>
|
||||||
ctx.sdk.file
|
state.sdk.file
|
||||||
.find({ query, type: "file", location: { directory: ctx.directory } })
|
.find({
|
||||||
|
query,
|
||||||
|
type: "file",
|
||||||
|
location: { directory: state.location.directory, workspace: state.location.workspaceID },
|
||||||
|
})
|
||||||
.then((result) => result.data.map((file) => file.path))
|
.then((result) => result.data.map((file) => file.path))
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
agents: [],
|
agents: [],
|
||||||
@@ -276,25 +232,25 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
}
|
}
|
||||||
|
|
||||||
log?.write("send.permission.reply", next)
|
log?.write("send.permission.reply", next)
|
||||||
await ctx.sdk.permission.reply({ sessionID: state.sessionID, ...next })
|
await state.sdk.permission.reply(next)
|
||||||
},
|
},
|
||||||
onQuestionReply: async (next) => {
|
onFormReply: async (next) => {
|
||||||
if (state.demo?.questionReply(next)) {
|
if (state.demo?.formReply(next)) return
|
||||||
return
|
try {
|
||||||
|
await state.sdk.form.reply(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
|
||||||
|
} catch (error) {
|
||||||
|
if (!formAlreadySettled(error)) throw error
|
||||||
}
|
}
|
||||||
|
await settleForm(next.sessionID, next.formID)
|
||||||
await ctx.sdk.question.reply({
|
|
||||||
sessionID: state.sessionID,
|
|
||||||
requestID: next.requestID,
|
|
||||||
answers: next.answers ?? [],
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onQuestionReject: async (next) => {
|
onFormCancel: async (next) => {
|
||||||
if (state.demo?.questionReject(next)) {
|
if (state.demo?.formCancel(next)) return
|
||||||
return
|
try {
|
||||||
|
await state.sdk.form.cancel(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
|
||||||
|
} catch (error) {
|
||||||
|
if (!formAlreadySettled(error)) throw error
|
||||||
}
|
}
|
||||||
|
await settleForm(next.sessionID, next.formID)
|
||||||
await ctx.sdk.question.reject({ sessionID: state.sessionID, ...next })
|
|
||||||
},
|
},
|
||||||
onCycleVariant: () => {
|
onCycleVariant: () => {
|
||||||
if (!state.model || state.variants.length === 0) {
|
if (!state.model || state.variants.length === 0) {
|
||||||
@@ -325,7 +281,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state.activeVariant = resolveVariant(ctx.variant, undefined, saved, state.variants)
|
state.activeVariant = resolveVariant(undefined, undefined, saved, state.variants)
|
||||||
})
|
})
|
||||||
state.switching = switching
|
state.switching = switching
|
||||||
await switching
|
await switching
|
||||||
@@ -368,7 +324,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onInterrupt: () => {
|
onInterrupt: () => {
|
||||||
if (!hasSession(input, state) || state.aborting) {
|
if (!state.sessionID || state.aborting) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +332,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
void (
|
void (
|
||||||
state.stream
|
state.stream
|
||||||
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
||||||
: ctx.sdk.session.interrupt({ sessionID: state.sessionID })
|
: state.sdk.session.interrupt({ sessionID: state.sessionID })
|
||||||
)
|
)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -385,16 +341,16 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
onBackground: () => {
|
onBackground: () => {
|
||||||
if (!hasSession(input, state)) {
|
if (!state.sessionID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log?.write("send.background", { sessionID: state.sessionID })
|
log?.write("send.background", { sessionID: state.sessionID })
|
||||||
void ctx.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
|
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
|
||||||
},
|
},
|
||||||
onSubagentInterrupt: (sessionID) => {
|
onSubagentInterrupt: (sessionID) => {
|
||||||
log?.write("send.subagent.interrupt", { sessionID })
|
log?.write("send.subagent.interrupt", { sessionID })
|
||||||
void ctx.sdk.session.interrupt({ sessionID }).catch(() => {})
|
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
|
||||||
},
|
},
|
||||||
onSubagentSelect: (sessionID) => {
|
onSubagentSelect: (sessionID) => {
|
||||||
state.selectSubagent?.(sessionID)
|
state.selectSubagent?.(sessionID)
|
||||||
@@ -403,10 +359,43 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const tuiConfig = await tuiConfigTask
|
||||||
|
const thinking = input.thinking ?? tuiConfig.session?.thinking !== "hide"
|
||||||
const footer = shell.footer
|
const footer = shell.footer
|
||||||
const firstPaint = footer.idle().catch(() => {})
|
const firstPaint = footer.idle().catch(() => {})
|
||||||
|
const offRuntimeClose = footer.onClose(() => runtimeController.abort())
|
||||||
|
let clientGeneration = 0
|
||||||
|
let clientController = new AbortController()
|
||||||
|
let modelAttempt: AbortController | undefined
|
||||||
|
let modelLoad: Promise<void> | undefined
|
||||||
|
let modelLoadQueued = false
|
||||||
|
let modelLoadStarted = false
|
||||||
|
|
||||||
|
const updateClient = (sdk: RunInput["sdk"]) => {
|
||||||
|
if (state.sdk === sdk) return
|
||||||
|
state.sdk = sdk
|
||||||
|
clientGeneration++
|
||||||
|
clientController.abort()
|
||||||
|
clientController = new AbortController()
|
||||||
|
modelAttempt?.abort()
|
||||||
|
if (modelLoadStarted && !state.model) void requestModelLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientAttempt = (signal?: AbortSignal): ClientAttempt => ({
|
||||||
|
sdk: state.sdk,
|
||||||
|
generation: clientGeneration,
|
||||||
|
signal: AbortSignal.any(
|
||||||
|
signal
|
||||||
|
? [runtimeController.signal, clientController.signal, signal]
|
||||||
|
: [runtimeController.signal, clientController.signal],
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentClient = (attempt: ClientAttempt) =>
|
||||||
|
!footer.isClosed && !attempt.signal.aborted && attempt.generation === clientGeneration && attempt.sdk === state.sdk
|
||||||
|
|
||||||
const ensureSession = () => {
|
const ensureSession = () => {
|
||||||
if (!input.resolveSession || state.sessionID) {
|
if (state.sessionID) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,39 +403,130 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
return state.session
|
return state.session
|
||||||
}
|
}
|
||||||
|
|
||||||
state.session = input.resolveSession(ctx).then(async (next) => {
|
const task = input
|
||||||
state.sessionID = next.sessionID
|
.resolveSession(state.sdk, runtimeController.signal)
|
||||||
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
|
.then(async (next) => {
|
||||||
state.agent = next.agent
|
if (next.sdk) updateClient(next.sdk)
|
||||||
if (!next.resume) return
|
if (footer.isClosed || runtimeController.signal.aborted) return
|
||||||
const resumed = await resolveSessionInfo(ctx.sdk, next.sessionID, ctx.model)
|
state.sessionID = next.sessionID
|
||||||
session.first = resumed.first
|
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
|
||||||
session.history = resumed.history
|
state.agent = next.agent
|
||||||
session.model = resumed.model
|
state.location = next.location
|
||||||
session.variant = resumed.variant
|
state.model = next.model
|
||||||
state.shown = !resumed.first
|
state.activeVariant = next.variant
|
||||||
state.history = [...resumed.history]
|
footer.event({ type: "agent", agent: state.agent })
|
||||||
state.model = ctx.model ?? resumed.model
|
if (!next.resume) return
|
||||||
const resumedSavedVariant = state.model ? await input.host.preferences.resolveVariant(state.model) : undefined
|
const resumed = await resolveSessionInfo(state.sdk, next.sessionID, next.model, runtimeController.signal)
|
||||||
state.activeVariant = resolveVariant(ctx.variant, resumed.variant, resumedSavedVariant, [])
|
if (footer.isClosed || runtimeController.signal.aborted) return
|
||||||
session.variant = state.activeVariant
|
session.first = resumed.first
|
||||||
footer.event({ type: "history", history: resumed.history })
|
session.history = resumed.history
|
||||||
footer.event({ type: "first", first: resumed.first })
|
session.model = resumed.model
|
||||||
|
session.variant = resumed.variant
|
||||||
|
state.shown = !resumed.first
|
||||||
|
state.history = [...resumed.history]
|
||||||
|
state.model = next.model ?? resumed.model
|
||||||
|
const resumedSavedVariant = state.model ? await input.host.preferences.resolveVariant(state.model) : undefined
|
||||||
|
state.activeVariant = resolveVariant(next.variant, resumed.variant, resumedSavedVariant, [])
|
||||||
|
session.variant = state.activeVariant
|
||||||
|
footer.event({ type: "history", history: resumed.history })
|
||||||
|
footer.event({ type: "first", first: resumed.first })
|
||||||
|
if (footer.isClosed || runtimeController.signal.aborted) return
|
||||||
|
await shell.resetForReplay({
|
||||||
|
sessionTitle: state.sessionTitle,
|
||||||
|
sessionID: state.sessionID,
|
||||||
|
history: state.history,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (footer.isClosed || runtimeController.signal.aborted) return
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
state.session = task
|
||||||
|
void task.catch(() => {
|
||||||
|
if (state.session === task) state.session = undefined
|
||||||
})
|
})
|
||||||
return state.session
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentModelLoad = (generation: number, sdk: RunInput["sdk"]) =>
|
||||||
|
!footer.isClosed && !runtimeController.signal.aborted && generation === clientGeneration && sdk === state.sdk
|
||||||
|
|
||||||
|
const loadCurrentModel = async () => {
|
||||||
|
const generation = clientGeneration
|
||||||
|
const sdk = state.sdk
|
||||||
|
const selected = state.model
|
||||||
|
const controller = new AbortController()
|
||||||
|
const signal = AbortSignal.any([runtimeController.signal, controller.signal])
|
||||||
|
modelAttempt = controller
|
||||||
|
try {
|
||||||
|
if (selected) {
|
||||||
|
const info = await abortable(resolveModelInfo(sdk, state.location, signal), signal)
|
||||||
|
if (
|
||||||
|
!info ||
|
||||||
|
!currentModelLoad(generation, sdk) ||
|
||||||
|
state.model?.providerID !== selected.providerID ||
|
||||||
|
state.model.modelID !== selected.modelID
|
||||||
|
)
|
||||||
|
return
|
||||||
|
applyModelInfo(info, session.variant, { sdk, generation, signal }, true, savedVariant)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await waitForDefaultModel({
|
||||||
|
sdk,
|
||||||
|
location: state.location,
|
||||||
|
active: () => currentModelLoad(generation, sdk),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
if (!currentModelLoad(generation, sdk)) return
|
||||||
|
const [fallbackSavedVariant, info] = await Promise.all([
|
||||||
|
input.host.preferences.resolveVariant(model),
|
||||||
|
abortable(resolveModelInfo(sdk, state.location, signal), signal),
|
||||||
|
])
|
||||||
|
if (!info || !currentModelLoad(generation, sdk)) return
|
||||||
|
if (model && !state.model) state.model = model
|
||||||
|
const boot = !!model && state.model?.providerID === model.providerID && state.model.modelID === model.modelID
|
||||||
|
applyModelInfo(
|
||||||
|
info,
|
||||||
|
boot ? session.variant : state.activeVariant,
|
||||||
|
{ sdk, generation, signal },
|
||||||
|
boot,
|
||||||
|
fallbackSavedVariant,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (modelAttempt === controller) modelAttempt = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestModelLoad(): Promise<void> {
|
||||||
|
modelLoadQueued = true
|
||||||
|
if (modelLoad || footer.isClosed) return modelLoad ?? Promise.resolve()
|
||||||
|
const task = (async () => {
|
||||||
|
while (modelLoadQueued && !footer.isClosed) {
|
||||||
|
modelLoadQueued = false
|
||||||
|
await loadCurrentModel()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
modelLoad = task
|
||||||
|
const cleanup = () => {
|
||||||
|
if (modelLoad === task) modelLoad = undefined
|
||||||
|
if (modelLoadQueued && !footer.isClosed) void requestModelLoad()
|
||||||
|
}
|
||||||
|
void task.then(cleanup, cleanup)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
const modelTask = firstPaint.then(async () => {
|
const modelTask = firstPaint.then(async () => {
|
||||||
if (footer.isClosed) return
|
if (footer.isClosed) return
|
||||||
await ensureSession()
|
await ensureSession()
|
||||||
if (footer.isClosed) return
|
if (footer.isClosed) return
|
||||||
return loadModel()
|
modelLoadStarted = true
|
||||||
|
return requestModelLoad()
|
||||||
})
|
})
|
||||||
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
|
const rememberLocal = (commit: StreamCommit) => {
|
||||||
const last = state.localRows.at(-1)
|
const last = state.localRows.at(-1)
|
||||||
if (
|
if (
|
||||||
last &&
|
last &&
|
||||||
!after &&
|
|
||||||
!last.after &&
|
|
||||||
(commit.kind === "assistant" || commit.kind === "reasoning") &&
|
(commit.kind === "assistant" || commit.kind === "reasoning") &&
|
||||||
last.commit.kind === commit.kind &&
|
last.commit.kind === commit.kind &&
|
||||||
last.commit.source === commit.source &&
|
last.commit.source === commit.source &&
|
||||||
@@ -457,17 +537,18 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
state.localRows = [...state.localRows.slice(0, -1), { commit }]
|
state.localRows = [...state.localRows.slice(0, -1), { commit }]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
|
state.localRows = [...state.localRows, { commit }].slice(-LOCAL_REPLAY_ROW_LIMIT)
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyCatalog = (catalog: {
|
const applyCatalog = (
|
||||||
agents: Awaited<ReturnType<typeof loadRunAgents>>
|
catalog: {
|
||||||
references: Awaited<ReturnType<typeof loadRunReferences>>
|
agents: Awaited<ReturnType<typeof loadRunAgents>>
|
||||||
commands: Awaited<ReturnType<typeof loadRunCommands>>
|
references: Awaited<ReturnType<typeof loadRunReferences>>
|
||||||
}) => {
|
commands: Awaited<ReturnType<typeof loadRunCommands>>
|
||||||
if (footer.isClosed) {
|
},
|
||||||
return
|
attempt: ClientAttempt,
|
||||||
}
|
) => {
|
||||||
|
if (!currentClient(attempt)) return
|
||||||
footer.event({
|
footer.event({
|
||||||
type: "catalog",
|
type: "catalog",
|
||||||
agents: catalog.agents,
|
agents: catalog.agents,
|
||||||
@@ -476,34 +557,37 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchCatalog = async () => {
|
const fetchCatalog = async (attempt: ClientAttempt) => {
|
||||||
const [agents, references, commands] = await Promise.all([
|
const [agents, references, commands] = await Promise.all([
|
||||||
loadRunAgents(ctx.sdk, ctx.directory),
|
loadRunAgents(attempt.sdk, state.location, attempt.signal),
|
||||||
loadRunReferences(ctx.sdk, ctx.directory),
|
loadRunReferences(attempt.sdk, state.location, attempt.signal),
|
||||||
loadRunCommands(ctx.sdk, ctx.directory),
|
loadRunCommands(attempt.sdk, state.location, attempt.signal),
|
||||||
])
|
])
|
||||||
return { agents, references, commands }
|
return { agents, references, commands }
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadCatalog = async () => {
|
const loadCatalog = async (attempt: ClientAttempt) => {
|
||||||
applyCatalog(
|
const catalog = await abortable(
|
||||||
await Promise.all([
|
Promise.all([
|
||||||
loadRunAgents(ctx.sdk, ctx.directory).catch(() => []),
|
loadRunAgents(attempt.sdk, state.location, attempt.signal).catch(() => []),
|
||||||
loadRunReferences(ctx.sdk, ctx.directory).catch(() => []),
|
loadRunReferences(attempt.sdk, state.location, attempt.signal).catch(() => []),
|
||||||
loadRunCommands(ctx.sdk, ctx.directory).catch(() => []),
|
loadRunCommands(attempt.sdk, state.location, attempt.signal).catch(() => []),
|
||||||
]).then(([agents, references, commands]) => ({ agents, references, commands })),
|
]).then(([agents, references, commands]) => ({ agents, references, commands })),
|
||||||
|
attempt.signal,
|
||||||
)
|
)
|
||||||
|
if (catalog) applyCatalog(catalog, attempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyModelInfo = (
|
function applyModelInfo(
|
||||||
info: Awaited<ReturnType<typeof resolveModelInfo>>,
|
info: Awaited<ReturnType<typeof resolveModelInfo>>,
|
||||||
current: string | undefined,
|
current: string | undefined,
|
||||||
|
attempt: ClientAttempt,
|
||||||
boot = false,
|
boot = false,
|
||||||
saved = savedVariant,
|
saved = savedVariant,
|
||||||
) => {
|
) {
|
||||||
|
if (!currentClient(attempt)) return
|
||||||
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.activeVariant = boot
|
state.activeVariant = boot
|
||||||
? resolveVariant(ctx.variant, current, saved, state.variants)
|
? resolveVariant(ctx.variant, current, saved, state.variants)
|
||||||
: current && !state.variants.includes(current)
|
: current && !state.variants.includes(current)
|
||||||
@@ -520,30 +604,62 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let catalogRefresh: Promise<void> | undefined
|
let catalogRefresh:
|
||||||
let catalogRefreshQueued = false
|
| {
|
||||||
const requestCatalogRefresh = () => {
|
attempt: ClientAttempt
|
||||||
catalogRefreshQueued = true
|
source: AbortSignal | undefined
|
||||||
if (catalogRefresh || footer.isClosed) return
|
queued: boolean
|
||||||
catalogRefresh = (async () => {
|
task: Promise<void>
|
||||||
await Promise.all([modelTask, initialCatalog])
|
|
||||||
while (catalogRefreshQueued && !footer.isClosed) {
|
|
||||||
catalogRefreshQueued = false
|
|
||||||
const [catalog, info] = await Promise.allSettled([
|
|
||||||
fetchCatalog(),
|
|
||||||
resolveModelInfoStrict(ctx.sdk, ctx.directory, state.model),
|
|
||||||
])
|
|
||||||
if (catalog.status === "fulfilled") applyCatalog(catalog.value)
|
|
||||||
if (info.status === "fulfilled") applyModelInfo(info.value, state.activeVariant)
|
|
||||||
}
|
}
|
||||||
})().finally(() => {
|
| undefined
|
||||||
|
const requestCatalogRefresh = (signal?: AbortSignal): Promise<void> => {
|
||||||
|
const attempt = clientAttempt(signal)
|
||||||
|
if (!currentClient(attempt)) return Promise.resolve()
|
||||||
|
const running = catalogRefresh
|
||||||
|
if (
|
||||||
|
running &&
|
||||||
|
!running.attempt.signal.aborted &&
|
||||||
|
running.attempt.generation === attempt.generation &&
|
||||||
|
running.attempt.sdk === attempt.sdk
|
||||||
|
) {
|
||||||
|
running.queued = true
|
||||||
|
return running.task
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = {
|
||||||
|
attempt,
|
||||||
|
source: signal,
|
||||||
|
queued: true,
|
||||||
|
task: Promise.resolve(),
|
||||||
|
}
|
||||||
|
const task = (async () => {
|
||||||
|
await Promise.all([abortable(modelTask, attempt.signal), abortable(initialCatalog, attempt.signal)])
|
||||||
|
while (refresh.queued && currentClient(attempt)) {
|
||||||
|
refresh.queued = false
|
||||||
|
const [catalog, info] = await Promise.all([
|
||||||
|
abortable(fetchCatalog(attempt), attempt.signal),
|
||||||
|
abortable(resolveModelInfoStrict(attempt.sdk, state.location, attempt.signal), attempt.signal),
|
||||||
|
])
|
||||||
|
if (!currentClient(attempt)) return
|
||||||
|
if (catalog) applyCatalog(catalog, attempt)
|
||||||
|
if (info) applyModelInfo(info, state.activeVariant, attempt)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
refresh.task = task
|
||||||
|
catalogRefresh = refresh
|
||||||
|
const cleanup = () => {
|
||||||
|
if (catalogRefresh !== refresh) return
|
||||||
catalogRefresh = undefined
|
catalogRefresh = undefined
|
||||||
if (catalogRefreshQueued) requestCatalogRefresh()
|
if (refresh.queued && currentClient(attempt)) void requestCatalogRefresh(refresh.source)
|
||||||
})
|
}
|
||||||
void catalogRefresh.catch(() => {})
|
void task.then(cleanup, cleanup)
|
||||||
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialCatalog = firstPaint.then(() => (footer.isClosed ? undefined : loadCatalog())).catch(() => {})
|
const initialCatalog = firstPaint
|
||||||
|
.then(() => (footer.isClosed ? undefined : ensureSession()))
|
||||||
|
.then(() => (footer.isClosed ? undefined : loadCatalog(clientAttempt())))
|
||||||
|
.catch(() => {})
|
||||||
void initialCatalog
|
void initialCatalog
|
||||||
|
|
||||||
if (input.host.startup.showTiming) {
|
if (input.host.startup.showTiming) {
|
||||||
@@ -563,7 +679,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
return createRunDemo({
|
return createRunDemo({
|
||||||
footer,
|
footer,
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
thinking: input.thinking,
|
thinking,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,21 +691,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.afterPaint) {
|
|
||||||
void firstPaint.then(() => (footer.isClosed ? undefined : input.afterPaint?.(ctx))).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
let streamTask = deps.streamTransport
|
||||||
const loadStreamTransport = () => {
|
const loadStreamTransport = () => {
|
||||||
if (streamTask) return streamTask
|
if (streamTask) return streamTask
|
||||||
@@ -615,15 +716,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handle = await mod.createSessionTransport({
|
const handle = await mod.createSessionTransport({
|
||||||
sdk: ctx.sdk,
|
sdk: state.sdk,
|
||||||
|
reconnect: input.reconnect,
|
||||||
|
onClient: updateClient,
|
||||||
readTextFile: input.host.files.readText,
|
readTextFile: input.host.files.readText,
|
||||||
directory: ctx.directory,
|
location: state.location,
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
thinking: input.thinking,
|
thinking,
|
||||||
replay: input.replay,
|
replay: input.replay,
|
||||||
replayLimit: input.replayLimit,
|
replayLimit: input.replayLimit,
|
||||||
limits: () => state.limits,
|
|
||||||
providers: () => state.providers,
|
|
||||||
footer,
|
footer,
|
||||||
onCommit: rememberLocal,
|
onCommit: rememberLocal,
|
||||||
trace: log,
|
trace: log,
|
||||||
@@ -714,11 +815,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
? async () => {
|
? async () => {
|
||||||
try {
|
try {
|
||||||
await state.switching?.catch(() => {})
|
await state.switching?.catch(() => {})
|
||||||
const created = await createSession(ctx, {
|
const created = await createSession(
|
||||||
agent: state.agent,
|
state.sdk,
|
||||||
model: state.model,
|
{
|
||||||
variant: state.activeVariant,
|
location: state.location,
|
||||||
})
|
agent: state.agent,
|
||||||
|
model: state.model,
|
||||||
|
variant: state.activeVariant,
|
||||||
|
},
|
||||||
|
runtimeController.signal,
|
||||||
|
)
|
||||||
|
if (!created.sessionID) throw new Error("Failed to create session")
|
||||||
await footer.idle().catch(() => {})
|
await footer.idle().catch(() => {})
|
||||||
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
||||||
state.stream = undefined
|
state.stream = undefined
|
||||||
@@ -728,6 +835,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
state.sessionID = created.sessionID
|
state.sessionID = created.sessionID
|
||||||
state.sessionTitle = created.sessionTitle
|
state.sessionTitle = created.sessionTitle
|
||||||
state.agent = created.agent ?? state.agent
|
state.agent = created.agent ?? state.agent
|
||||||
|
state.location = created.location
|
||||||
|
state.model = created.model
|
||||||
|
state.activeVariant = created.variant
|
||||||
|
footer.event({ type: "agent", agent: state.agent })
|
||||||
state.history = []
|
state.history = []
|
||||||
state.localRows = []
|
state.localRows = []
|
||||||
includeFiles = true
|
includeFiles = true
|
||||||
@@ -741,7 +852,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
tabs: [],
|
tabs: [],
|
||||||
details: {},
|
details: {},
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
forms: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
footer.event({ type: "stream.view", view: { type: "prompt" } })
|
footer.event({ type: "stream.view", view: { type: "prompt" } })
|
||||||
@@ -749,7 +860,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
type: "stream.patch",
|
type: "stream.patch",
|
||||||
patch: {
|
patch: {
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
duration: "",
|
|
||||||
usage: "",
|
usage: "",
|
||||||
first: true,
|
first: true,
|
||||||
},
|
},
|
||||||
@@ -788,7 +898,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
|
|
||||||
await state.switching?.catch(() => {})
|
await state.switching?.catch(() => {})
|
||||||
|
|
||||||
let outputAnchor: LocalReplayAnchor | undefined
|
|
||||||
try {
|
try {
|
||||||
const next = await ensureStream()
|
const next = await ensureStream()
|
||||||
await next.handle.runPromptTurn({
|
await next.handle.runPromptTurn({
|
||||||
@@ -798,9 +907,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
prompt,
|
prompt,
|
||||||
files: input.files,
|
files: input.files,
|
||||||
includeFiles,
|
includeFiles,
|
||||||
onVisibleOutput: (anchor) => {
|
|
||||||
outputAnchor = anchor
|
|
||||||
},
|
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
if (prompt.messageID) {
|
if (prompt.messageID) {
|
||||||
@@ -826,7 +932,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
source: "system",
|
source: "system",
|
||||||
messageID: prompt.messageID,
|
messageID: prompt.messageID,
|
||||||
} as const
|
} as const
|
||||||
rememberLocal(commit, outputAnchor)
|
rememberLocal(commit)
|
||||||
footer.append(commit)
|
footer.append(commit)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -834,20 +940,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const eager = eagerStream(input, ctx)
|
if (input.demo) {
|
||||||
if (eager) {
|
|
||||||
await firstPaint
|
await firstPaint
|
||||||
if (footer.isClosed) return
|
if (footer.isClosed) return
|
||||||
if (input.replay && state.shown) {
|
|
||||||
// Replay commits immutable scrollback rows, so wait for provider names
|
|
||||||
// before bootstrapping existing session history.
|
|
||||||
await modelTask
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureStream()
|
await ensureStream()
|
||||||
}
|
} else {
|
||||||
|
|
||||||
if (!eager && input.resolveSession) {
|
|
||||||
void firstPaint
|
void firstPaint
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (footer.isClosed) {
|
if (footer.isClosed) {
|
||||||
@@ -869,11 +966,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
const title = await resolveExitTitle(ctx, input, state)
|
runtimeController.abort()
|
||||||
|
offRuntimeClose()
|
||||||
|
|
||||||
await shell.close({
|
await shell.close({
|
||||||
showExit: state.shown && hasSession(input, state),
|
showExit: state.shown && !!state.sessionID,
|
||||||
sessionTitle: title,
|
sessionTitle: state.sessionTitle,
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
history: state.history,
|
history: state.history,
|
||||||
})
|
})
|
||||||
@@ -884,7 +982,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
// generated client with a transport that is still acquiring a daemon.
|
// generated client with a transport that is still acquiring a daemon.
|
||||||
export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise<void> {
|
export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise<void> {
|
||||||
const sdk = input.sdk
|
const sdk = input.sdk
|
||||||
let session: Promise<ResolvedSession> | undefined
|
|
||||||
|
|
||||||
return runInteractiveRuntime(
|
return runInteractiveRuntime(
|
||||||
{
|
{
|
||||||
@@ -896,33 +993,13 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
|
|||||||
replayLimit: input.replayLimit,
|
replayLimit: input.replayLimit,
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
tuiConfig: input.tuiConfig,
|
tuiConfig: input.tuiConfig,
|
||||||
resolveSession: () => {
|
reconnect: input.reconnect,
|
||||||
if (session) {
|
resolveSession: input.target,
|
||||||
return session
|
createSession: input.createSession,
|
||||||
}
|
|
||||||
|
|
||||||
session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
|
|
||||||
if (!next?.id) {
|
|
||||||
throw new Error("Session not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionID: next.id,
|
|
||||||
sessionTitle: next.title,
|
|
||||||
agent,
|
|
||||||
resume: next.resume,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return session
|
|
||||||
},
|
|
||||||
createSession: createSessionResolver(input.createSession),
|
|
||||||
boot: async () => {
|
boot: async () => {
|
||||||
return {
|
return {
|
||||||
sdk,
|
sdk,
|
||||||
directory: input.directory,
|
location: { directory: input.directory },
|
||||||
sessionID: "",
|
|
||||||
sessionTitle: undefined,
|
|
||||||
resume: false,
|
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
variant: input.variant,
|
variant: input.variant,
|
||||||
@@ -932,34 +1009,3 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
|
|||||||
deps,
|
deps,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach mode. Uses the caller-provided SDK client directly.
|
|
||||||
export async function runInteractiveMode(
|
|
||||||
input: RunInput & { host: MiniHost; createSession?: CreateSession; tuiConfig?: RunTuiConfig | Promise<RunTuiConfig> },
|
|
||||||
deps?: RunRuntimeDeps,
|
|
||||||
): Promise<void> {
|
|
||||||
return runInteractiveRuntime(
|
|
||||||
{
|
|
||||||
host: input.host,
|
|
||||||
files: input.files,
|
|
||||||
initialInput: input.initialInput,
|
|
||||||
thinking: input.thinking,
|
|
||||||
replay: input.replay,
|
|
||||||
replayLimit: input.replayLimit,
|
|
||||||
demo: input.demo,
|
|
||||||
tuiConfig: input.tuiConfig,
|
|
||||||
boot: async () => ({
|
|
||||||
sdk: input.sdk,
|
|
||||||
directory: input.directory,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
sessionTitle: input.sessionTitle,
|
|
||||||
resume: input.resume,
|
|
||||||
agent: input.agent,
|
|
||||||
model: input.model,
|
|
||||||
variant: input.variant,
|
|
||||||
}),
|
|
||||||
createSession: createSessionResolver(input.createSession),
|
|
||||||
},
|
|
||||||
deps,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function entrySyntax(theme: RunTheme): SyntaxStyle {
|
|||||||
return syntax(theme.block.syntax)
|
return syntax(theme.block.syntax)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function entryFailed(commit: StreamCommit): boolean {
|
function entryFailed(commit: StreamCommit): boolean {
|
||||||
return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
|
return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
|||||||
import { turnSummaryCommit } from "./turn-summary"
|
import { turnSummaryCommit } from "./turn-summary"
|
||||||
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
|
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
|
||||||
import { type RunTheme } from "./theme"
|
import { type RunTheme } from "./theme"
|
||||||
import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
|
import type { RunEntryBody, StreamCommit } from "./types"
|
||||||
|
|
||||||
type ActiveBody = Exclude<RunEntryBody, { type: "none" | "structured" }>
|
type ActiveBody = Exclude<RunEntryBody, { type: "none" | "structured" }>
|
||||||
|
|
||||||
@@ -86,8 +86,6 @@ export class RunScrollbackStream {
|
|||||||
private tail: StreamCommit | undefined
|
private tail: StreamCommit | undefined
|
||||||
private rendered: StreamCommit | undefined
|
private rendered: StreamCommit | undefined
|
||||||
private active: ActiveEntry | undefined
|
private active: ActiveEntry | undefined
|
||||||
private diffStyle: RunDiffStyle | undefined
|
|
||||||
private sessionID?: () => string | undefined
|
|
||||||
private treeSitterClient: TreeSitterClient | undefined
|
private treeSitterClient: TreeSitterClient | undefined
|
||||||
private wrote: boolean
|
private wrote: boolean
|
||||||
private pendingThemes: RunTheme[] = []
|
private pendingThemes: RunTheme[] = []
|
||||||
@@ -97,14 +95,10 @@ export class RunScrollbackStream {
|
|||||||
private theme: RunTheme,
|
private theme: RunTheme,
|
||||||
options: {
|
options: {
|
||||||
wrote?: boolean
|
wrote?: boolean
|
||||||
diffStyle?: RunDiffStyle
|
|
||||||
sessionID?: () => string | undefined
|
|
||||||
treeSitterClient?: TreeSitterClient
|
treeSitterClient?: TreeSitterClient
|
||||||
onThemeRelease?: (theme: RunTheme) => void
|
onThemeRelease?: (theme: RunTheme) => void
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
this.diffStyle = options.diffStyle
|
|
||||||
this.sessionID = options.sessionID
|
|
||||||
this.treeSitterClient = options.treeSitterClient
|
this.treeSitterClient = options.treeSitterClient
|
||||||
this.wrote = options.wrote ?? false
|
this.wrote = options.wrote ?? false
|
||||||
this.onThemeRelease = options.onThemeRelease
|
this.onThemeRelease = options.onThemeRelease
|
||||||
@@ -395,9 +389,6 @@ export class RunScrollbackStream {
|
|||||||
commit,
|
commit,
|
||||||
body: staticBody(commit, body, spaced),
|
body: staticBody(commit, body, spaced),
|
||||||
theme: this.theme,
|
theme: this.theme,
|
||||||
opts: {
|
|
||||||
diffStyle: this.diffStyle,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
this.markRendered(commit)
|
this.markRendered(commit)
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ export function entryGroupKey(commit: StreamCommit): string | undefined {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const part = `${commit.messageID ?? ""}\u0000${commit.partID}`
|
||||||
if (toolStructuredFinal(commit)) {
|
if (toolStructuredFinal(commit)) {
|
||||||
return `tool:${commit.partID}:final`
|
return `tool:${part}:final`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${commit.kind}:${commit.partID}`
|
return `${commit.kind}:${part}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean {
|
export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean {
|
||||||
@@ -47,14 +48,6 @@ export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody
|
|||||||
return "inline"
|
return "inline"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commit.kind === "reasoning") {
|
|
||||||
return "block"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commit.kind === "error") {
|
|
||||||
return "block"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "block"
|
return "block"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +72,6 @@ export function RunEntryContent(props: {
|
|||||||
body?: RunEntryBody
|
body?: RunEntryBody
|
||||||
theme?: RunTheme
|
theme?: RunTheme
|
||||||
opts?: ScrollbackOptions
|
opts?: ScrollbackOptions
|
||||||
width?: number
|
|
||||||
}) {
|
}) {
|
||||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||||
const body = createMemo(() => props.body ?? entryBody(props.commit))
|
const body = createMemo(() => props.body ?? entryBody(props.commit))
|
||||||
@@ -263,13 +255,12 @@ export function entryWriter(input: {
|
|||||||
opts?: ScrollbackOptions
|
opts?: ScrollbackOptions
|
||||||
}): ScrollbackWriter {
|
}): ScrollbackWriter {
|
||||||
return createScrollbackWriter(
|
return createScrollbackWriter(
|
||||||
(ctx) => (
|
() => (
|
||||||
<RunEntryContent
|
<RunEntryContent
|
||||||
commit={input.commit}
|
commit={input.commit}
|
||||||
body={input.body}
|
body={input.body}
|
||||||
theme={input.theme}
|
theme={input.theme}
|
||||||
opts={{ ...input.opts, suppressBackgrounds: true }}
|
opts={{ ...input.opts, suppressBackgrounds: true }}
|
||||||
width={ctx.width}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
entryFlags(input.commit),
|
entryFlags(input.commit),
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
|
import type { FooterView, MiniFormRequest, MiniPermissionRequest } from "./types"
|
||||||
import type { FooterView } from "./types"
|
|
||||||
|
|
||||||
export function pickBlockerView(input: {
|
export function pickBlockerView(input: { permission?: MiniPermissionRequest; form?: MiniFormRequest }): FooterView {
|
||||||
permission?: PermissionV2Request
|
|
||||||
question?: QuestionV2Request
|
|
||||||
}): FooterView {
|
|
||||||
if (input.permission) return { type: "permission", request: input.permission }
|
if (input.permission) return { type: "permission", request: input.permission }
|
||||||
if (input.question) return { type: "question", request: input.question }
|
if (input.form) return { type: "form", request: input.form }
|
||||||
return { type: "prompt" }
|
return { type: "prompt" }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function blockerStatus(view: FooterView) {
|
export function blockerStatus(view: FooterView) {
|
||||||
if (view.type === "permission") return "awaiting permission"
|
if (view.type === "permission") return "awaiting permission"
|
||||||
if (view.type === "question") return "awaiting answer"
|
if (view.type === "form") return "awaiting form"
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,11 +62,12 @@ export function createSession(messages: SessionMessages): RunSession {
|
|||||||
export async function resolveCurrentSession(
|
export async function resolveCurrentSession(
|
||||||
sdk: RunInput["sdk"],
|
sdk: RunInput["sdk"],
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
limit = LIMIT,
|
limit = LIMIT,
|
||||||
): Promise<RunSession> {
|
): Promise<RunSession> {
|
||||||
const [response, session] = await Promise.all([
|
const [response, session] = await Promise.all([
|
||||||
sdk.message.list({ sessionID, limit, order: "desc" }),
|
sdk.message.list({ sessionID, limit, order: "desc" }, ...requestOptions(signal)),
|
||||||
sdk.session.get({ sessionID }),
|
sdk.session.get({ sessionID }, ...requestOptions(signal)),
|
||||||
])
|
])
|
||||||
const current = createSession(response.data.toReversed())
|
const current = createSession(response.data.toReversed())
|
||||||
return {
|
return {
|
||||||
@@ -84,6 +85,10 @@ export async function resolveCurrentSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] {
|
||||||
|
return signal ? [{ signal }] : []
|
||||||
|
}
|
||||||
|
|
||||||
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
||||||
return session.turns
|
return session.turns
|
||||||
.map((turn) => turn.prompt)
|
.map((turn) => turn.prompt)
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import { Locale } from "../util/locale"
|
|||||||
import { go } from "../logo"
|
import { go } from "../logo"
|
||||||
import type { RunSplashTheme } from "./theme"
|
import type { RunSplashTheme } from "./theme"
|
||||||
|
|
||||||
export const SPLASH_TITLE_LIMIT = 50
|
const SPLASH_TITLE_LIMIT = 50
|
||||||
export const SPLASH_TITLE_FALLBACK = "Untitled session"
|
const SPLASH_TITLE_FALLBACK = "Untitled session"
|
||||||
|
|
||||||
type SplashInput = {
|
type SplashInput = {
|
||||||
title: string | undefined
|
title: string | undefined
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -71,18 +71,15 @@ function traceCommit(commit: StreamCommit) {
|
|||||||
part: commit.part
|
part: commit.part
|
||||||
? {
|
? {
|
||||||
id: commit.part.id,
|
id: commit.part.id,
|
||||||
sessionID: commit.part.sessionID,
|
tool: commit.part.name,
|
||||||
messageID: commit.part.messageID,
|
|
||||||
callID: commit.part.callID,
|
|
||||||
tool: commit.part.tool,
|
|
||||||
state: {
|
state: {
|
||||||
status: commit.part.state.status,
|
status: commit.part.state.status,
|
||||||
title: "title" in commit.part.state ? summarize(commit.part.state.title) : undefined,
|
|
||||||
error: "error" in commit.part.state ? summarize(commit.part.state.error) : undefined,
|
|
||||||
time: "time" in commit.part.state ? summarize(commit.part.state.time) : undefined,
|
|
||||||
input: summarize(commit.part.state.input),
|
input: summarize(commit.part.state.input),
|
||||||
metadata: "metadata" in commit.part.state ? summarize(commit.part.state.metadata) : undefined,
|
structured: "structured" in commit.part.state ? summarize(commit.part.state.structured) : undefined,
|
||||||
|
content: "content" in commit.part.state ? summarize(commit.part.state.content) : undefined,
|
||||||
|
error: "error" in commit.part.state ? summarize(commit.part.state.error) : undefined,
|
||||||
},
|
},
|
||||||
|
time: commit.part.time,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
@@ -106,37 +103,30 @@ export function traceSubagentState(state: FooterSubagentState) {
|
|||||||
action: item.action,
|
action: item.action,
|
||||||
resources: item.resources,
|
resources: item.resources,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
|
tool: item.tool
|
||||||
|
? {
|
||||||
|
id: item.tool.id,
|
||||||
|
name: item.tool.name,
|
||||||
|
status: item.tool.state.status,
|
||||||
|
input: summarize(item.tool.state.input),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
metadata: item.metadata
|
metadata: item.metadata
|
||||||
? {
|
? {
|
||||||
keys: Object.keys(item.metadata),
|
keys: Object.keys(item.metadata),
|
||||||
input: summarize(item.metadata.input),
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
})),
|
})),
|
||||||
questions: state.questions.map((item) => ({
|
forms: state.forms.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
sessionID: item.sessionID,
|
sessionID: item.sessionID,
|
||||||
questions: item.questions.map((question) => ({
|
title: item.title,
|
||||||
header: question.header,
|
fields: item.fields.map((field) => ({ key: field.key, type: field.type })),
|
||||||
question: question.question,
|
location: item.location,
|
||||||
options: question.options.length,
|
|
||||||
multiple: question.multiple,
|
|
||||||
})),
|
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function traceFooterOutput(footer?: FooterOutput) {
|
|
||||||
if (!footer?.subagent) {
|
|
||||||
return footer
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...footer,
|
|
||||||
subagent: traceSubagentState(footer.subagent),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forwards transport output to the footer: commits go to scrollback, patches update the status bar.
|
// Forwards transport output to the footer: commits go to scrollback, patches update the status bar.
|
||||||
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
|
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
|
||||||
for (const commit of out.commits) {
|
for (const commit of out.commits) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// palette if detection fails.
|
// palette if detection fails.
|
||||||
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
|
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
|
||||||
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||||
import type { EntryKind } from "./types"
|
import type { EntryKind, RunTuiConfig } from "./types"
|
||||||
|
|
||||||
type Tone = {
|
type Tone = {
|
||||||
body: ColorInput
|
body: ColorInput
|
||||||
@@ -20,7 +20,6 @@ export type RunSplashTheme = {
|
|||||||
left: ColorInput
|
left: ColorInput
|
||||||
right: ColorInput
|
right: ColorInput
|
||||||
leftShadow: ColorInput
|
leftShadow: ColorInput
|
||||||
rightShadow: ColorInput
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunFooterTheme = {
|
export type RunFooterTheme = {
|
||||||
@@ -28,7 +27,6 @@ export type RunFooterTheme = {
|
|||||||
selected: ColorInput
|
selected: ColorInput
|
||||||
selectedText: ColorInput
|
selectedText: ColorInput
|
||||||
warning: ColorInput
|
warning: ColorInput
|
||||||
success: ColorInput
|
|
||||||
error: ColorInput
|
error: ColorInput
|
||||||
muted: ColorInput
|
muted: ColorInput
|
||||||
text: ColorInput
|
text: ColorInput
|
||||||
@@ -42,12 +40,9 @@ export type RunFooterTheme = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type RunBlockTheme = {
|
export type RunBlockTheme = {
|
||||||
highlight: ColorInput
|
|
||||||
warning: ColorInput
|
|
||||||
text: ColorInput
|
text: ColorInput
|
||||||
muted: ColorInput
|
muted: ColorInput
|
||||||
syntax?: SyntaxStyle
|
syntax?: SyntaxStyle
|
||||||
diffAdded: ColorInput
|
|
||||||
diffRemoved: ColorInput
|
diffRemoved: ColorInput
|
||||||
diffAddedBg: ColorInput
|
diffAddedBg: ColorInput
|
||||||
diffRemovedBg: ColorInput
|
diffRemovedBg: ColorInput
|
||||||
@@ -460,7 +455,6 @@ function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
|
|||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
|
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
|
||||||
rightShadow: splashShadow(indexed, theme.background, right, 0.14),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +484,6 @@ function map(
|
|||||||
selected: footerTheme.backgroundElement,
|
selected: footerTheme.backgroundElement,
|
||||||
selectedText: footerTheme.selectedListItemText,
|
selectedText: footerTheme.selectedListItemText,
|
||||||
warning: footerTheme.warning,
|
warning: footerTheme.warning,
|
||||||
success: footerTheme.success,
|
|
||||||
error: footerTheme.error,
|
error: footerTheme.error,
|
||||||
muted: footerTheme.textMuted,
|
muted: footerTheme.textMuted,
|
||||||
text: footerTheme.text,
|
text: footerTheme.text,
|
||||||
@@ -525,12 +518,9 @@ function map(
|
|||||||
},
|
},
|
||||||
splash,
|
splash,
|
||||||
block: {
|
block: {
|
||||||
highlight: scrollbackTheme.primary,
|
|
||||||
warning: scrollbackTheme.warning,
|
|
||||||
text: scrollbackTheme.text,
|
text: scrollbackTheme.text,
|
||||||
muted: scrollbackTheme.textMuted,
|
muted: scrollbackTheme.textMuted,
|
||||||
syntax,
|
syntax,
|
||||||
diffAdded: scrollbackTheme.diffAdded,
|
|
||||||
diffRemoved: scrollbackTheme.diffRemoved,
|
diffRemoved: scrollbackTheme.diffRemoved,
|
||||||
diffAddedBg: transparent,
|
diffAddedBg: transparent,
|
||||||
diffRemovedBg: transparent,
|
diffRemovedBg: transparent,
|
||||||
@@ -572,7 +562,6 @@ export const RUN_THEME_FALLBACK: RunTheme = {
|
|||||||
selected: seed.text,
|
selected: seed.text,
|
||||||
selectedText: seed.panel,
|
selectedText: seed.panel,
|
||||||
warning: seed.warning,
|
warning: seed.warning,
|
||||||
success: seed.success,
|
|
||||||
error: seed.error,
|
error: seed.error,
|
||||||
muted: seed.muted,
|
muted: seed.muted,
|
||||||
text: seed.text,
|
text: seed.text,
|
||||||
@@ -596,14 +585,10 @@ export const RUN_THEME_FALLBACK: RunTheme = {
|
|||||||
left: fallbackSplashLeft,
|
left: fallbackSplashLeft,
|
||||||
right: fallbackSplashRight,
|
right: fallbackSplashRight,
|
||||||
leftShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashLeft, 0.14),
|
leftShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashLeft, 0.14),
|
||||||
rightShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashRight, 0.14),
|
|
||||||
},
|
},
|
||||||
block: {
|
block: {
|
||||||
highlight: seed.highlight,
|
|
||||||
warning: seed.warning,
|
|
||||||
text: seed.text,
|
text: seed.text,
|
||||||
muted: seed.muted,
|
muted: seed.muted,
|
||||||
diffAdded: seed.success,
|
|
||||||
diffRemoved: seed.error,
|
diffRemoved: seed.error,
|
||||||
diffAddedBg: alpha(seed.success, 0.18),
|
diffAddedBg: alpha(seed.success, 0.18),
|
||||||
diffRemovedBg: alpha(seed.error, 0.18),
|
diffRemovedBg: alpha(seed.error, 0.18),
|
||||||
@@ -616,7 +601,7 @@ export const RUN_THEME_FALLBACK: RunTheme = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
|
export async function resolveRunTheme(renderer: CliRenderer, config?: RunTuiConfig["theme"]): Promise<RunTheme> {
|
||||||
try {
|
try {
|
||||||
const colors = await renderer.getPalette({
|
const colors = await renderer.getPalette({
|
||||||
size: 256,
|
size: 256,
|
||||||
@@ -628,19 +613,21 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
|||||||
|
|
||||||
// Palette-only terminal reloads can leave renderer.themeMode stale, but
|
// Palette-only terminal reloads can leave renderer.themeMode stale, but
|
||||||
// ANSI slot zero is not the terminal background when OSC 11 is absent.
|
// ANSI slot zero is not the terminal background when OSC 11 is absent.
|
||||||
const pick = colors.defaultBackground
|
const pick =
|
||||||
? mode(RGBA.fromHex(colors.defaultBackground))
|
config?.mode === "dark" || config?.mode === "light"
|
||||||
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
|
? config.mode
|
||||||
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
|
: colors.defaultBackground
|
||||||
const indexed = indexedPalette(colors, 256)
|
? mode(RGBA.fromHex(colors.defaultBackground))
|
||||||
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
|
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
|
||||||
const { generateSyntax } = await import("../theme")
|
const { generateSyntax } = await import("../theme")
|
||||||
|
const indexed = indexedPalette(colors, 256)
|
||||||
|
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
|
||||||
|
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
|
||||||
const syntaxTheme: SharedSyntaxTheme = {
|
const syntaxTheme: SharedSyntaxTheme = {
|
||||||
...scrollbackTheme,
|
...scrollbackTheme,
|
||||||
_hasSelectedListItemText: true,
|
_hasSelectedListItemText: true,
|
||||||
}
|
}
|
||||||
const syntax = generateSyntax(syntaxTheme)
|
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), generateSyntax(syntaxTheme))
|
||||||
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
|
|
||||||
} catch {
|
} catch {
|
||||||
return RUN_THEME_FALLBACK
|
return RUN_THEME_FALLBACK
|
||||||
}
|
}
|
||||||
|
|||||||
+271
-135
@@ -1,6 +1,6 @@
|
|||||||
// Per-tool display rules shared across `opencode run` output paths.
|
// Per-tool display rules shared across `opencode run` output paths.
|
||||||
//
|
//
|
||||||
// Each known tool (shell, edit, write, task, etc.) has a ToolRule that controls
|
// Each known tool (shell, edit, write, subagent, etc.) has a ToolRule that controls
|
||||||
// five display hooks:
|
// five display hooks:
|
||||||
//
|
//
|
||||||
// view → visibility policy for progress/final scrollback entries and
|
// view → visibility policy for progress/final scrollback entries and
|
||||||
@@ -15,9 +15,10 @@
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
|
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||||
import { LANGUAGE_EXTENSIONS } from "../util/filetype"
|
import { LANGUAGE_EXTENSIONS } from "../util/filetype"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import type { MiniToolPart, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||||
|
|
||||||
export type { MiniToolPart } from "./types"
|
export type { MiniToolPart } from "./types"
|
||||||
|
|
||||||
@@ -32,10 +33,9 @@ export type ToolPhase = "start" | "progress" | "final"
|
|||||||
export type ToolDict = Record<string, unknown>
|
export type ToolDict = Record<string, unknown>
|
||||||
|
|
||||||
type PatchFile = {
|
type PatchFile = {
|
||||||
type?: string
|
status?: string
|
||||||
relativePath?: string
|
file?: string
|
||||||
filePath?: string
|
from?: string
|
||||||
movePath?: string
|
|
||||||
patch?: string
|
patch?: string
|
||||||
deletions?: number
|
deletions?: number
|
||||||
}
|
}
|
||||||
@@ -43,11 +43,9 @@ type PatchFile = {
|
|||||||
type ToolInput = ToolDict & {
|
type ToolInput = ToolDict & {
|
||||||
path?: string
|
path?: string
|
||||||
pattern?: string
|
pattern?: string
|
||||||
filePath?: string
|
|
||||||
filepath?: string
|
|
||||||
url?: string
|
url?: string
|
||||||
query?: string
|
query?: string
|
||||||
subagent_type?: string
|
agent?: string
|
||||||
description?: string
|
description?: string
|
||||||
name?: string
|
name?: string
|
||||||
operation?: string
|
operation?: string
|
||||||
@@ -71,6 +69,7 @@ type ToolMetadata = ToolDict & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ToolFrame = {
|
export type ToolFrame = {
|
||||||
|
directory?: string
|
||||||
raw: string
|
raw: string
|
||||||
name: string
|
name: string
|
||||||
input: ToolDict
|
input: ToolDict
|
||||||
@@ -78,6 +77,11 @@ export type ToolFrame = {
|
|||||||
state: ToolDict
|
state: ToolDict
|
||||||
status: string
|
status: string
|
||||||
error: string
|
error: string
|
||||||
|
output: string
|
||||||
|
time: {
|
||||||
|
start?: number
|
||||||
|
end?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolInline = {
|
export type ToolInline = {
|
||||||
@@ -93,6 +97,7 @@ export type ToolPermissionInfo = {
|
|||||||
title: string
|
title: string
|
||||||
lines: string[]
|
lines: string[]
|
||||||
diff?: string
|
diff?: string
|
||||||
|
patch?: string
|
||||||
file?: string
|
file?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,12 +108,14 @@ export type ToolProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ToolPermissionProps = {
|
type ToolPermissionProps = {
|
||||||
|
directory?: string
|
||||||
input: ToolInput
|
input: ToolInput
|
||||||
metadata: ToolMetadata
|
metadata: ToolMetadata
|
||||||
patterns: string[]
|
patterns: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolPermissionCtx = {
|
type ToolPermissionCtx = {
|
||||||
|
directory?: string
|
||||||
input: ToolDict
|
input: ToolDict
|
||||||
meta: ToolDict
|
meta: ToolDict
|
||||||
patterns: string[]
|
patterns: string[]
|
||||||
@@ -121,7 +128,7 @@ type ToolName =
|
|||||||
| "edit"
|
| "edit"
|
||||||
| "patch"
|
| "patch"
|
||||||
| "batch"
|
| "batch"
|
||||||
| "task"
|
| "subagent"
|
||||||
| "question"
|
| "question"
|
||||||
| "read"
|
| "read"
|
||||||
| "glob"
|
| "glob"
|
||||||
@@ -163,6 +170,7 @@ function props(frame: ToolFrame): ToolProps {
|
|||||||
|
|
||||||
function permission(ctx: ToolPermissionCtx): ToolPermissionProps {
|
function permission(ctx: ToolPermissionCtx): ToolPermissionProps {
|
||||||
return {
|
return {
|
||||||
|
directory: ctx.directory,
|
||||||
input: ctx.input,
|
input: ctx.input,
|
||||||
metadata: ctx.meta,
|
metadata: ctx.meta,
|
||||||
patterns: ctx.patterns,
|
patterns: ctx.patterns,
|
||||||
@@ -181,10 +189,87 @@ function text(v: unknown): string {
|
|||||||
|
|
||||||
export function toolOutputText(name: string, content: ReadonlyArray<{ type: string; text?: string }>) {
|
export function toolOutputText(name: string, content: ReadonlyArray<{ type: string; text?: string }>) {
|
||||||
// V2 shell content appends model-only status after the user-visible command output.
|
// V2 shell content appends model-only status after the user-visible command output.
|
||||||
if (name === "shell") return content.find((item) => item.type === "text")?.text ?? ""
|
if (canonicalToolName(name) === "shell") return content.find((item) => item.type === "text")?.text ?? ""
|
||||||
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
|
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canonicalToolName(name: string) {
|
||||||
|
if (name === "bash") return "shell"
|
||||||
|
if (name === "task") return "subagent"
|
||||||
|
if (name === "apply_patch") return "patch"
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInput(name: string, value: unknown) {
|
||||||
|
const input = dict(value)
|
||||||
|
const path = typeof input.path === "string" ? input.path : text(input.filePath) || text(input.filepath)
|
||||||
|
const agent = typeof input.agent === "string" ? input.agent : text(input.subagent_type)
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
...(["read", "write", "edit", "lsp"].includes(name) && path ? { path } : {}),
|
||||||
|
...(name === "subagent" && agent ? { agent } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFile(value: unknown): PatchFile | undefined {
|
||||||
|
const file = dict(value)
|
||||||
|
const name = text(file.file) || text(file.relativePath) || text(file.filePath)
|
||||||
|
if (!name) return
|
||||||
|
const legacy = text(file.type)
|
||||||
|
const status =
|
||||||
|
text(file.status) ||
|
||||||
|
(legacy === "add"
|
||||||
|
? "added"
|
||||||
|
: legacy === "delete"
|
||||||
|
? "deleted"
|
||||||
|
: legacy === "update"
|
||||||
|
? "modified"
|
||||||
|
: legacy === "move"
|
||||||
|
? "moved"
|
||||||
|
: legacy)
|
||||||
|
const patch = typeof file.patch === "string" ? file.patch : text(file.diff) || undefined
|
||||||
|
const deletions = num(file.deletions)
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
file: name,
|
||||||
|
...(status === "moved" && text(file.filePath) ? { from: text(file.filePath) } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(patch === undefined ? {} : { patch }),
|
||||||
|
...(deletions === undefined ? {} : { deletions }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStructured(name: string, value: unknown) {
|
||||||
|
const structured = dict(value)
|
||||||
|
const files = list(structured.files).flatMap((item) => {
|
||||||
|
const file = normalizeFile(item)
|
||||||
|
return file ? [file] : []
|
||||||
|
})
|
||||||
|
const sessionID = text(structured.sessionID) || text(structured.sessionId)
|
||||||
|
return {
|
||||||
|
...structured,
|
||||||
|
...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}),
|
||||||
|
...(name === "subagent" && sessionID ? { sessionID } : {}),
|
||||||
|
...(name === "shell" && num(structured.exit) === undefined && num(structured.exitCode) !== undefined
|
||||||
|
? { exit: num(structured.exitCode) }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTool(tool: SessionMessageAssistantTool): SessionMessageAssistantTool {
|
||||||
|
const name = canonicalToolName(tool.name)
|
||||||
|
if (tool.state.status === "streaming") return { ...tool, name }
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
name,
|
||||||
|
state: {
|
||||||
|
...tool.state,
|
||||||
|
input: normalizeInput(name, tool.state.input),
|
||||||
|
structured: normalizeStructured(name, tool.state.structured),
|
||||||
|
},
|
||||||
|
} as SessionMessageAssistantTool
|
||||||
|
}
|
||||||
|
|
||||||
function num(v: unknown): number | undefined {
|
function num(v: unknown): number | undefined {
|
||||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -217,10 +302,9 @@ function info(data: ToolDict, skip: string[] = []): string {
|
|||||||
return `[${list.map(([key, val]) => `${key}=${String(val)}`).join(", ")}]`
|
return `[${list.map(([key, val]) => `${key}=${String(val)}`).join(", ")}]`
|
||||||
}
|
}
|
||||||
|
|
||||||
function span(state: ToolDict): string {
|
function span(frame: ToolFrame): string {
|
||||||
const time = dict(state.time)
|
const start = frame.time.start
|
||||||
const start = num(time.start)
|
const end = frame.time.end
|
||||||
const end = num(time.end)
|
|
||||||
if (start === undefined || end === undefined || end <= start) {
|
if (start === undefined || end === undefined || end <= start) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -268,7 +352,7 @@ function fallbackFinal(ctx: ToolFrame): string {
|
|||||||
return ctx.raw.trim()
|
return ctx.raw.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
const time = span(ctx.state)
|
const time = span(ctx)
|
||||||
if (!time) {
|
if (!time) {
|
||||||
return `${ctx.name} completed`
|
return `${ctx.name} completed`
|
||||||
}
|
}
|
||||||
@@ -276,12 +360,12 @@ function fallbackFinal(ctx: ToolFrame): string {
|
|||||||
return `${ctx.name} completed · ${time}`
|
return `${ctx.name} completed · ${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolPath(input?: string, opts: { home?: boolean } = {}): string {
|
export function toolPath(input?: string, opts: { home?: boolean; directory?: string } = {}): string {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = process.cwd()
|
const cwd = opts.directory ?? process.cwd()
|
||||||
const home = os.homedir()
|
const home = os.homedir()
|
||||||
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
||||||
const rel = path.relative(cwd, abs)
|
const rel = path.relative(cwd, abs)
|
||||||
@@ -301,8 +385,12 @@ export function toolPath(input?: string, opts: { home?: boolean } = {}): string
|
|||||||
return abs.replaceAll("\\", "/")
|
return abs.replaceAll("\\", "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayPath(p: ToolProps, input?: string, opts: { home?: boolean } = {}) {
|
||||||
|
return toolPath(input, { ...opts, directory: p.frame.directory })
|
||||||
|
}
|
||||||
|
|
||||||
function fallbackInline(ctx: ToolFrame): ToolInline {
|
function fallbackInline(ctx: ToolFrame): ToolInline {
|
||||||
const title = text(ctx.state.title) || (Object.keys(ctx.input).length > 0 ? JSON.stringify(ctx.input) : "Unknown")
|
const title = Object.keys(ctx.input).length > 0 ? JSON.stringify(ctx.input) : "Unknown"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icon: "⚙",
|
icon: "⚙",
|
||||||
@@ -317,7 +405,7 @@ function count(n: number, label: string): string {
|
|||||||
function runGlob(p: ToolProps): ToolInline {
|
function runGlob(p: ToolProps): ToolInline {
|
||||||
const root = p.input.path ?? ""
|
const root = p.input.path ?? ""
|
||||||
const title = `Glob "${p.input.pattern ?? ""}"`
|
const title = `Glob "${p.input.pattern ?? ""}"`
|
||||||
const suffix = root ? `in ${toolPath(root)}` : ""
|
const suffix = root ? `in ${displayPath(p, root)}` : ""
|
||||||
const matches = p.metadata.count
|
const matches = p.metadata.count
|
||||||
const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
|
const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
|
||||||
return {
|
return {
|
||||||
@@ -330,7 +418,7 @@ function runGlob(p: ToolProps): ToolInline {
|
|||||||
function runGrep(p: ToolProps): ToolInline {
|
function runGrep(p: ToolProps): ToolInline {
|
||||||
const root = p.input.path ?? ""
|
const root = p.input.path ?? ""
|
||||||
const title = `Grep "${p.input.pattern ?? ""}"`
|
const title = `Grep "${p.input.pattern ?? ""}"`
|
||||||
const suffix = root ? `in ${toolPath(root)}` : ""
|
const suffix = root ? `in ${displayPath(p, root)}` : ""
|
||||||
const matches = p.metadata.matches
|
const matches = p.metadata.matches
|
||||||
const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
|
const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
|
||||||
return {
|
return {
|
||||||
@@ -344,13 +432,13 @@ function runList(p: ToolProps): ToolInline {
|
|||||||
const dir = text(dict(p.input).path)
|
const dir = text(dict(p.input).path)
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: dir ? `List ${toolPath(dir)}` : "List",
|
title: dir ? `List ${displayPath(p, dir)}` : "List",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runRead(p: ToolProps): ToolInline {
|
function runRead(p: ToolProps): ToolInline {
|
||||||
const file = toolPath(p.input.filePath)
|
const file = displayPath(p, p.input.path)
|
||||||
const description = info(p.frame.input, ["filePath"]) || undefined
|
const description = info(p.frame.input, ["path"]) || undefined
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `Read ${file}`,
|
title: `Read ${file}`,
|
||||||
@@ -361,9 +449,9 @@ function runRead(p: ToolProps): ToolInline {
|
|||||||
function runWrite(p: ToolProps): ToolInline {
|
function runWrite(p: ToolProps): ToolInline {
|
||||||
return {
|
return {
|
||||||
icon: "←",
|
icon: "←",
|
||||||
title: `Write ${toolPath(p.input.filePath)}`,
|
title: `Write ${displayPath(p, p.input.path)}`,
|
||||||
mode: "block",
|
mode: "block",
|
||||||
body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
|
body: p.frame.status === "completed" ? p.frame.output : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,11 +464,12 @@ function runWebfetch(p: ToolProps): ToolInline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runEdit(p: ToolProps): ToolInline {
|
function runEdit(p: ToolProps): ToolInline {
|
||||||
|
const file = list<PatchFile>(p.metadata.files)[0]
|
||||||
return {
|
return {
|
||||||
icon: "←",
|
icon: "←",
|
||||||
title: `Edit ${toolPath(p.input.filePath)}`,
|
title: `Edit ${displayPath(p, p.input.path)}`,
|
||||||
mode: "block",
|
mode: "block",
|
||||||
body: p.metadata.diff,
|
body: file?.patch ?? p.metadata.diff,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,12 +482,12 @@ function runWebSearch(p: ToolProps): ToolInline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runTask(p: ToolProps): ToolInline {
|
function runTask(p: ToolProps): ToolInline {
|
||||||
const kind = Locale.titlecase(p.input.subagent_type || "unknown")
|
const kind = Locale.titlecase(p.input.agent || "unknown")
|
||||||
const desc = p.input.description
|
const desc = p.input.description
|
||||||
const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓"
|
const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓"
|
||||||
return {
|
return {
|
||||||
icon,
|
icon,
|
||||||
title: desc || `${kind} Task`,
|
title: desc || `${kind} Subagent`,
|
||||||
description: desc ? `${kind} Agent` : undefined,
|
description: desc ? `${kind} Agent` : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,9 +525,9 @@ function runQuestion(p: ToolProps): ToolInline {
|
|||||||
function runInvalid(p: ToolProps): ToolInline {
|
function runInvalid(p: ToolProps): ToolInline {
|
||||||
return {
|
return {
|
||||||
icon: "✗",
|
icon: "✗",
|
||||||
title: text(p.frame.state.title) || "Invalid Tool",
|
title: "Invalid Tool",
|
||||||
mode: "block",
|
mode: "block",
|
||||||
body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
|
body: p.frame.status === "completed" ? p.frame.output : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,23 +535,23 @@ function runBatch(p: ToolProps): ToolInline {
|
|||||||
const calls = list(dict(p.input).tool_calls).length
|
const calls = list(dict(p.input).tool_calls).length
|
||||||
return {
|
return {
|
||||||
icon: "#",
|
icon: "#",
|
||||||
title: text(p.frame.state.title) || (calls > 0 ? `Batch ${calls} tool${calls === 1 ? "" : "s"}` : "Batch"),
|
title: calls > 0 ? `Batch ${calls} tool${calls === 1 ? "" : "s"}` : "Batch",
|
||||||
mode: "block",
|
mode: "block",
|
||||||
body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
|
body: p.frame.status === "completed" ? p.frame.output : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function lspTitle(
|
function lspTitle(
|
||||||
input: {
|
input: {
|
||||||
operation?: string
|
operation?: string
|
||||||
filePath?: string
|
path?: string
|
||||||
line?: number
|
line?: number
|
||||||
character?: number
|
character?: number
|
||||||
},
|
},
|
||||||
opts: { home?: boolean } = {},
|
opts: { home?: boolean; directory?: string } = {},
|
||||||
): string {
|
): string {
|
||||||
const op = input.operation || "request"
|
const op = input.operation || "request"
|
||||||
const file = input.filePath ? toolPath(input.filePath, opts) : ""
|
const file = input.path ? toolPath(input.path, opts) : ""
|
||||||
const line = typeof input.line === "number" ? input.line : undefined
|
const line = typeof input.line === "number" ? input.line : undefined
|
||||||
const char = typeof input.character === "number" ? input.character : undefined
|
const char = typeof input.character === "number" ? input.character : undefined
|
||||||
const pos = line !== undefined && char !== undefined ? `:${line}:${char}` : ""
|
const pos = line !== undefined && char !== undefined ? `:${line}:${char}` : ""
|
||||||
@@ -476,37 +565,35 @@ function lspTitle(
|
|||||||
function runLsp(p: ToolProps): ToolInline {
|
function runLsp(p: ToolProps): ToolInline {
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: text(p.frame.state.title) || lspTitle(p.input),
|
title: lspTitle(p.input, { directory: p.frame.directory }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPlanExit(p: ToolProps): ToolInline {
|
function runPlanExit(p: ToolProps): ToolInline {
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: text(p.frame.state.title) || "Switching to build agent",
|
title: "Switching to build agent",
|
||||||
mode: "block",
|
mode: "block",
|
||||||
body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
|
body: p.frame.status === "completed" ? p.frame.output : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchTitle(file: PatchFile): string {
|
function patchTitle(file: PatchFile, directory?: string): string {
|
||||||
const rel = file.relativePath
|
if (file.status === "added") {
|
||||||
const from = file.filePath
|
return `# Created ${toolPath(file.file, { directory })}`
|
||||||
if (file.type === "add") {
|
|
||||||
return `# Created ${rel || toolPath(from)}`
|
|
||||||
}
|
}
|
||||||
if (file.type === "delete") {
|
if (file.status === "deleted") {
|
||||||
return `# Deleted ${rel || toolPath(from)}`
|
return `# Deleted ${toolPath(file.file, { directory })}`
|
||||||
}
|
}
|
||||||
if (file.type === "move") {
|
if (file.status === "moved") {
|
||||||
return `# Moved ${toolPath(from)} -> ${rel || toolPath(file.movePath)}`
|
return `# Moved ${toolPath(file.from, { directory })} -> ${toolPath(file.file, { directory })}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `# Patched ${rel || toolPath(from)}`
|
return `# Patched ${toolPath(file.file, { directory })}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapWrite(p: ToolProps): ToolSnapshot | undefined {
|
function snapWrite(p: ToolProps): ToolSnapshot | undefined {
|
||||||
const file = p.input.filePath || ""
|
const file = p.input.path || ""
|
||||||
const content = p.input.content || ""
|
const content = p.input.content || ""
|
||||||
if (!file && !content) {
|
if (!file && !content) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -514,15 +601,16 @@ function snapWrite(p: ToolProps): ToolSnapshot | undefined {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
kind: "code",
|
kind: "code",
|
||||||
title: `# Wrote ${toolPath(file)}`,
|
title: `# Wrote ${displayPath(p, file)}`,
|
||||||
content,
|
content,
|
||||||
file,
|
file,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapEdit(p: ToolProps): ToolSnapshot | undefined {
|
function snapEdit(p: ToolProps): ToolSnapshot | undefined {
|
||||||
const file = p.input.filePath || ""
|
const item = list<PatchFile>(p.metadata.files)[0]
|
||||||
const diff = p.metadata.diff || ""
|
const file = item?.file || p.input.path || ""
|
||||||
|
const diff = item?.patch || p.metadata.diff || ""
|
||||||
if (!file || !diff.trim()) {
|
if (!file || !diff.trim()) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -531,7 +619,7 @@ function snapEdit(p: ToolProps): ToolSnapshot | undefined {
|
|||||||
kind: "diff",
|
kind: "diff",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: `# Edited ${toolPath(file)}`,
|
title: `# Edited ${displayPath(p, file)}`,
|
||||||
diff,
|
diff,
|
||||||
file,
|
file,
|
||||||
},
|
},
|
||||||
@@ -555,10 +643,10 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefined {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = file.movePath || file.filePath || file.relativePath
|
const name = file.file
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: patchTitle(file),
|
title: patchTitle(file, p.frame.directory),
|
||||||
diff,
|
diff,
|
||||||
file: name,
|
file: name,
|
||||||
deletions: typeof file.deletions === "number" ? file.deletions : 0,
|
deletions: typeof file.deletions === "number" ? file.deletions : 0,
|
||||||
@@ -566,7 +654,7 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefined {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length !== files.length) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,14 +665,13 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapTask(p: ToolProps): ToolSnapshot {
|
function snapTask(p: ToolProps): ToolSnapshot {
|
||||||
const kind = Locale.titlecase(p.input.subagent_type || "general")
|
const kind = Locale.titlecase(p.input.agent || "general")
|
||||||
const desc = p.input.description
|
const desc = p.input.description
|
||||||
const title = text(p.frame.state.title)
|
const rows = [desc].filter((item): item is string => Boolean(item))
|
||||||
const rows = [desc || title].filter((item): item is string => Boolean(item))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: "task",
|
kind: "task",
|
||||||
title: `# ${kind} Task`,
|
title: `# ${kind} Subagent`,
|
||||||
rows,
|
rows,
|
||||||
tail: "",
|
tail: "",
|
||||||
}
|
}
|
||||||
@@ -610,7 +697,7 @@ function snapQuestion(p: ToolProps): ToolSnapshot {
|
|||||||
function scrollBashStart(p: ToolProps): string {
|
function scrollBashStart(p: ToolProps): string {
|
||||||
const cmd = p.input.command ?? ""
|
const cmd = p.input.command ?? ""
|
||||||
const wd = p.input.workdir ?? ""
|
const wd = p.input.workdir ?? ""
|
||||||
const formatted = wd && wd !== "." ? toolPath(wd) : ""
|
const formatted = wd && wd !== "." ? displayPath(p, wd) : ""
|
||||||
const dir = formatted === "." ? "" : formatted
|
const dir = formatted === "." ? "" : formatted
|
||||||
if (cmd && !dir) {
|
if (cmd && !dir) {
|
||||||
return `$ ${cmd}`
|
return `$ ${cmd}`
|
||||||
@@ -636,7 +723,7 @@ function scrollBashProgress(p: ToolProps): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wdRaw = (p.input.workdir ?? "").trim()
|
const wdRaw = (p.input.workdir ?? "").trim()
|
||||||
const wd = wdRaw ? toolPath(wdRaw) : ""
|
const wd = wdRaw ? displayPath(p, wdRaw) : ""
|
||||||
const lines = out.split("\n")
|
const lines = out.split("\n")
|
||||||
const first = (lines[0] || "").trim()
|
const first = (lines[0] || "").trim()
|
||||||
const second = (lines[1] || "").trim()
|
const second = (lines[1] || "").trim()
|
||||||
@@ -662,7 +749,7 @@ function scrollShellFinal(p: ToolProps): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const code = p.metadata.exit ?? num(p.frame.meta.exitCode) ?? num(p.frame.meta.exit_code)
|
const code = p.metadata.exit ?? num(p.frame.meta.exitCode) ?? num(p.frame.meta.exit_code)
|
||||||
const time = span(p.frame.state)
|
const time = span(p.frame)
|
||||||
if (code === undefined) {
|
if (code === undefined) {
|
||||||
if (!time) {
|
if (!time) {
|
||||||
return "shell completed"
|
return "shell completed"
|
||||||
@@ -675,8 +762,8 @@ function scrollShellFinal(p: ToolProps): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrollReadStart(p: ToolProps): string {
|
function scrollReadStart(p: ToolProps): string {
|
||||||
const file = toolPath(p.input.filePath)
|
const file = displayPath(p, p.input.path)
|
||||||
const extra = info(p.frame.input, ["filePath"])
|
const extra = info(p.frame.input, ["path"])
|
||||||
const tail = extra ? ` ${extra}` : ""
|
const tail = extra ? ` ${extra}` : ""
|
||||||
return `→ Read ${file}${tail}`.trim()
|
return `→ Read ${file}${tail}`.trim()
|
||||||
}
|
}
|
||||||
@@ -693,24 +780,19 @@ function scrollPatchStart(_: ToolProps): string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchLine(file: PatchFile): string {
|
function patchLine(file: PatchFile, directory?: string): string {
|
||||||
const type = file.type
|
if (file.status === "added") {
|
||||||
const rel = file.relativePath
|
return `+ Created ${toolPath(file.file, { directory })}`
|
||||||
const from = file.filePath
|
|
||||||
|
|
||||||
if (type === "add") {
|
|
||||||
return `+ Created ${rel || toolPath(from)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "delete") {
|
if (file.status === "deleted") {
|
||||||
return `- Deleted ${rel || toolPath(from)}`
|
return `- Deleted ${toolPath(file.file, { directory })}`
|
||||||
|
}
|
||||||
|
if (file.status === "moved") {
|
||||||
|
return `→ Moved ${toolPath(file.from, { directory })} → ${toolPath(file.file, { directory })}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "move") {
|
return `~ Patched ${toolPath(file.file, { directory })}`
|
||||||
return `→ Moved ${toolPath(from)} → ${rel || toolPath(file.movePath)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `~ Patched ${rel || toolPath(from)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollPatchFinal(p: ToolProps): string {
|
function scrollPatchFinal(p: ToolProps): string {
|
||||||
@@ -720,7 +802,7 @@ function scrollPatchFinal(p: ToolProps): string {
|
|||||||
|
|
||||||
const files = list<PatchFile>(p.frame.meta.files)
|
const files = list<PatchFile>(p.frame.meta.files)
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
const time = span(p.frame.state)
|
const time = span(p.frame)
|
||||||
if (!time) {
|
if (!time) {
|
||||||
return "patch"
|
return "patch"
|
||||||
}
|
}
|
||||||
@@ -728,9 +810,9 @@ function scrollPatchFinal(p: ToolProps): string {
|
|||||||
return `patch · ${time}`
|
return `patch · ${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const show_updates = !files.some((file) => file?.type && file.type !== "update")
|
const showModified = !files.some((file) => file?.status && file.status !== "modified")
|
||||||
const shown = files.filter((file) => show_updates || file.type !== "update")
|
const shown = files.filter((file) => showModified || file.status !== "modified")
|
||||||
const rows = shown.slice(0, 6).map(patchLine)
|
const rows = shown.slice(0, 6).map((file) => patchLine(file, p.frame.directory))
|
||||||
if (shown.length > 6) {
|
if (shown.length > 6) {
|
||||||
rows.push(`... and ${shown.length - 6} more`)
|
rows.push(`... and ${shown.length - 6} more`)
|
||||||
}
|
}
|
||||||
@@ -739,7 +821,7 @@ function scrollPatchFinal(p: ToolProps): string {
|
|||||||
return rows.join("\n")
|
return rows.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return patchLine(files[0]!)
|
return patchLine(files[0]!, p.frame.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollTaskStart(_: ToolProps): string {
|
function scrollTaskStart(_: ToolProps): string {
|
||||||
@@ -769,13 +851,13 @@ function scrollTaskFinal(p: ToolProps): string {
|
|||||||
return fail(p.frame)
|
return fail(p.frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
const kind = Locale.titlecase(p.input.subagent_type || "general")
|
const kind = Locale.titlecase(p.input.agent || "general")
|
||||||
const row = p.input.description || text(p.frame.state.title)
|
const row = p.input.description
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return `# ${kind} Task`
|
return `# ${kind} Subagent`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `# ${kind} Task\n${row}`
|
return `# ${kind} Subagent\n${row}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollQuestionStart(_: ToolProps): string {
|
function scrollQuestionStart(_: ToolProps): string {
|
||||||
@@ -785,7 +867,7 @@ function scrollQuestionStart(_: ToolProps): string {
|
|||||||
function scrollQuestionFinal(p: ToolProps): string {
|
function scrollQuestionFinal(p: ToolProps): string {
|
||||||
const q = p.input.questions ?? []
|
const q = p.input.questions ?? []
|
||||||
const a = p.metadata.answers ?? []
|
const a = p.metadata.answers ?? []
|
||||||
const time = span(p.frame.state)
|
const time = span(p.frame)
|
||||||
if (q.length === 0) {
|
if (q.length === 0) {
|
||||||
if (!time) {
|
if (!time) {
|
||||||
return "0 questions"
|
return "0 questions"
|
||||||
@@ -810,7 +892,7 @@ function scrollQuestionFinal(p: ToolProps): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrollLspStart(p: ToolProps): string {
|
function scrollLspStart(p: ToolProps): string {
|
||||||
return `→ ${lspTitle(p.input)}`
|
return `→ ${lspTitle(p.input, { directory: p.frame.directory })}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollSkillStart(p: ToolProps): string {
|
function scrollSkillStart(p: ToolProps): string {
|
||||||
@@ -825,7 +907,7 @@ function scrollGlobStart(p: ToolProps): string {
|
|||||||
return head
|
return head
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${head} in ${toolPath(dir)}`
|
return `${head} in ${displayPath(p, dir)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollGlobFinal(p: ToolProps): string {
|
function scrollGlobFinal(p: ToolProps): string {
|
||||||
@@ -840,7 +922,7 @@ function scrollGrepStart(p: ToolProps): string {
|
|||||||
return head
|
return head
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${head} in ${toolPath(dir)}`
|
return `${head} in ${displayPath(p, dir)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollListStart(p: ToolProps): string {
|
function scrollListStart(p: ToolProps): string {
|
||||||
@@ -849,7 +931,7 @@ function scrollListStart(p: ToolProps): string {
|
|||||||
return "→ List"
|
return "→ List"
|
||||||
}
|
}
|
||||||
|
|
||||||
return `→ List ${toolPath(dir)}`
|
return `→ List ${displayPath(p, dir)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollWebfetchStart(p: ToolProps): string {
|
function scrollWebfetchStart(p: ToolProps): string {
|
||||||
@@ -872,23 +954,24 @@ function scrollWebSearchStart(p: ToolProps): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function permEdit(p: ToolPermissionProps): ToolPermissionInfo {
|
function permEdit(p: ToolPermissionProps): ToolPermissionInfo {
|
||||||
const input = p.input as { filePath?: string; filepath?: string; diff?: string }
|
const file = p.input.path || p.patterns[0] || ""
|
||||||
const file = input.filePath || input.filepath || p.patterns[0] || ""
|
const diff = (list<PatchFile>(p.metadata.files)[0]?.patch ?? text(p.metadata.diff)) || undefined
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `Edit ${toolPath(file, { home: true })}`,
|
title: `Edit ${toolPath(file, { home: true, directory: p.directory })}`,
|
||||||
lines: [],
|
lines: [],
|
||||||
diff: p.metadata.diff ?? input.diff,
|
diff,
|
||||||
|
patch: diff ? undefined : text(p.input.patchText) || undefined,
|
||||||
file,
|
file,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function permRead(p: ToolPermissionProps): ToolPermissionInfo {
|
function permRead(p: ToolPermissionProps): ToolPermissionInfo {
|
||||||
const file = p.input.filePath || p.patterns[0] || ""
|
const file = p.input.path || p.patterns[0] || ""
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `Read ${toolPath(file, { home: true })}`,
|
title: `Read ${toolPath(file, { home: true, directory: p.directory })}`,
|
||||||
lines: file ? [`Path: ${toolPath(file, { home: true })}`] : [],
|
lines: file ? [`Path: ${toolPath(file, { home: true, directory: p.directory })}`] : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,8 +997,8 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo {
|
|||||||
const dir = text(dict(p.input).path) || p.patterns[0] || ""
|
const dir = text(dict(p.input).path) || p.patterns[0] || ""
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `List ${toolPath(dir, { home: true })}`,
|
title: `List ${toolPath(dir, { home: true, directory: p.directory })}`,
|
||||||
lines: dir ? [`Path: ${toolPath(dir, { home: true })}`] : [],
|
lines: dir ? [`Path: ${toolPath(dir, { home: true, directory: p.directory })}`] : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,11 +1012,11 @@ function permBash(p: ToolPermissionProps): ToolPermissionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function permTask(p: ToolPermissionProps): ToolPermissionInfo {
|
function permTask(p: ToolPermissionProps): ToolPermissionInfo {
|
||||||
const type = p.input.subagent_type || "general"
|
const type = p.input.agent || "general"
|
||||||
const desc = p.input.description
|
const desc = p.input.description
|
||||||
return {
|
return {
|
||||||
icon: "#",
|
icon: "#",
|
||||||
title: `${Locale.titlecase(type)} Task`,
|
title: `${Locale.titlecase(type)} Subagent`,
|
||||||
lines: desc ? [`◉ ${desc}`] : [],
|
lines: desc ? [`◉ ${desc}`] : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -958,16 +1041,16 @@ function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function permLsp(p: ToolPermissionProps): ToolPermissionInfo {
|
function permLsp(p: ToolPermissionProps): ToolPermissionInfo {
|
||||||
const file = p.input.filePath || ""
|
const file = p.input.path || ""
|
||||||
const line = typeof p.input.line === "number" ? p.input.line : undefined
|
const line = typeof p.input.line === "number" ? p.input.line : undefined
|
||||||
const char = typeof p.input.character === "number" ? p.input.character : undefined
|
const char = typeof p.input.character === "number" ? p.input.character : undefined
|
||||||
const pos = line !== undefined && char !== undefined ? `${line}:${char}` : undefined
|
const pos = line !== undefined && char !== undefined ? `${line}:${char}` : undefined
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: lspTitle(p.input, { home: true }),
|
title: lspTitle(p.input, { home: true, directory: p.directory }),
|
||||||
lines: [
|
lines: [
|
||||||
...(p.input.operation ? [`Operation: ${p.input.operation}`] : []),
|
...(p.input.operation ? [`Operation: ${p.input.operation}`] : []),
|
||||||
...(file ? [`Path: ${toolPath(file, { home: true })}`] : []),
|
...(file ? [`Path: ${toolPath(file, { home: true, directory: p.directory })}`] : []),
|
||||||
...(pos ? [`Position: ${pos}`] : []),
|
...(pos ? [`Position: ${pos}`] : []),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1045,7 +1128,7 @@ const TOOL_RULES = {
|
|||||||
start: () => "",
|
start: () => "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
task: {
|
subagent: {
|
||||||
view: {
|
view: {
|
||||||
output: false,
|
output: false,
|
||||||
final: true,
|
final: true,
|
||||||
@@ -1184,29 +1267,52 @@ function rule(name?: string): AnyToolRule | undefined {
|
|||||||
return TOOL_RULES[name]
|
return TOOL_RULES[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
function frame(part: MiniToolPart): ToolFrame {
|
function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame {
|
||||||
const state = dict(part.state)
|
const tool = normalizeTool(part)
|
||||||
|
if (tool.state.status === "streaming")
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
raw: tool.state.input,
|
||||||
|
name: tool.name,
|
||||||
|
input: {},
|
||||||
|
meta: {},
|
||||||
|
state: dict(tool.state),
|
||||||
|
status: tool.state.status,
|
||||||
|
error: "",
|
||||||
|
output: "",
|
||||||
|
time: { start: tool.time.created },
|
||||||
|
}
|
||||||
|
const output = toolOutputText(tool.name, tool.state.content)
|
||||||
return {
|
return {
|
||||||
raw: "",
|
directory,
|
||||||
name: part.tool,
|
raw: output,
|
||||||
input: dict(state.input),
|
name: tool.name,
|
||||||
meta: "metadata" in part.state ? dict(part.state.metadata) : {},
|
input: normalizeInput(tool.name, tool.state.input),
|
||||||
state,
|
meta: normalizeStructured(tool.name, tool.state.structured),
|
||||||
status: text(state.status),
|
state: dict(tool.state),
|
||||||
error: text(state.error),
|
status: tool.state.status,
|
||||||
|
error: tool.state.status === "error" ? tool.state.error.message : "",
|
||||||
|
output,
|
||||||
|
time: {
|
||||||
|
start: tool.time.ran ?? tool.time.created,
|
||||||
|
end: tool.time.completed,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
|
export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
|
||||||
const state = dict(commit.part?.state)
|
const current = commit.part ? frame(commit.part, commit.directory) : undefined
|
||||||
return {
|
return {
|
||||||
|
directory: commit.directory,
|
||||||
raw,
|
raw,
|
||||||
name: commit.tool || commit.part?.tool || "tool",
|
name: canonicalToolName(commit.tool || current?.name || "tool"),
|
||||||
input: dict(state.input),
|
input: current?.input ?? {},
|
||||||
meta: commit.part?.state && "metadata" in commit.part.state ? dict(commit.part.state.metadata) : {},
|
meta: current?.meta ?? {},
|
||||||
state,
|
state: current?.state ?? {},
|
||||||
status: commit.toolState ?? text(state.status),
|
status: commit.toolState ?? current?.status ?? "",
|
||||||
error: (commit.toolError ?? "").trim(),
|
error: (commit.toolError ?? current?.error ?? "").trim(),
|
||||||
|
output: current?.output ?? raw,
|
||||||
|
time: current?.time ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,13 +1321,13 @@ function runShell(p: ToolProps): ToolInline {
|
|||||||
icon: "$",
|
icon: "$",
|
||||||
title: p.input.command || "",
|
title: p.input.command || "",
|
||||||
mode: "block",
|
mode: "block",
|
||||||
body: p.frame.status === "completed" ? text(p.frame.state.output).trim() : undefined,
|
body: p.frame.status === "completed" ? p.frame.output.trim() : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolView(name?: string): ToolView {
|
export function toolView(name?: string): ToolView {
|
||||||
return (
|
return (
|
||||||
rule(name)?.view ?? {
|
rule(name ? canonicalToolName(name) : undefined)?.view ?? {
|
||||||
output: true,
|
output: true,
|
||||||
final: true,
|
final: true,
|
||||||
}
|
}
|
||||||
@@ -1234,12 +1340,12 @@ export function toolStructuredFinal(commit: StreamCommit): boolean {
|
|||||||
commit.kind === "tool" &&
|
commit.kind === "tool" &&
|
||||||
commit.phase === "final" &&
|
commit.phase === "final" &&
|
||||||
state === "completed" &&
|
state === "completed" &&
|
||||||
Boolean(toolView(commit.tool ?? commit.part?.tool).snap)
|
Boolean(toolView(commit.tool ?? commit.part?.name).snap)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolInlineInfo(part: MiniToolPart): ToolInline {
|
export function toolInlineInfo(part: SessionMessageAssistantTool, directory?: string): ToolInline {
|
||||||
const ctx = frame(part)
|
const ctx = frame(part, directory)
|
||||||
const draw = rule(ctx.name)?.run
|
const draw = rule(ctx.name)?.run
|
||||||
try {
|
try {
|
||||||
if (draw) {
|
if (draw) {
|
||||||
@@ -1284,14 +1390,23 @@ export function toolPermissionInfo(
|
|||||||
input: ToolDict,
|
input: ToolDict,
|
||||||
meta: ToolDict,
|
meta: ToolDict,
|
||||||
patterns: string[],
|
patterns: string[],
|
||||||
|
directory?: string,
|
||||||
): ToolPermissionInfo | undefined {
|
): ToolPermissionInfo | undefined {
|
||||||
const draw = rule(name)?.permission
|
const normalized = canonicalToolName(name)
|
||||||
|
const draw = rule(normalized)?.permission
|
||||||
if (!draw) {
|
if (!draw) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return draw(permission({ input, meta, patterns }))
|
return draw(
|
||||||
|
permission({
|
||||||
|
directory,
|
||||||
|
input: normalizeInput(normalized, input),
|
||||||
|
meta: normalizeStructured(normalized, meta),
|
||||||
|
patterns,
|
||||||
|
}),
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -1345,6 +1460,23 @@ function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undef
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STRUCTURED_FALLBACK_LENGTH = 4_096
|
||||||
|
|
||||||
|
function structuredFallback(value: ToolDict): RunEntryBody | undefined {
|
||||||
|
if (Object.keys(value).length === 0) return
|
||||||
|
const content = JSON.stringify(value, null, 2)
|
||||||
|
if (!content) return
|
||||||
|
const suffix = "\n... [truncated]"
|
||||||
|
return {
|
||||||
|
type: "code",
|
||||||
|
content:
|
||||||
|
content.length <= STRUCTURED_FALLBACK_LENGTH
|
||||||
|
? content
|
||||||
|
: content.slice(0, STRUCTURED_FALLBACK_LENGTH - suffix.length) + suffix,
|
||||||
|
filetype: "json",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shellOutput(command: string, raw: string): string | undefined {
|
function shellOutput(command: string, raw: string): string | undefined {
|
||||||
const body = stripAnsi(raw).replace(/^\n+/, "").replace(/\n+$/, "")
|
const body = stripAnsi(raw).replace(/^\n+/, "").replace(/\n+$/, "")
|
||||||
if (!body) {
|
if (!body) {
|
||||||
@@ -1379,13 +1511,13 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody |
|
|||||||
const ctx = toolFrame(commit, raw)
|
const ctx = toolFrame(commit, raw)
|
||||||
const view = toolView(ctx.name)
|
const view = toolView(ctx.name)
|
||||||
|
|
||||||
if (ctx.name === "task") {
|
if (ctx.name === "subagent") {
|
||||||
if (commit.phase === "start") {
|
if (commit.phase === "start") {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commit.phase === "final" && ctx.status === "completed") {
|
if (commit.phase === "final" && ctx.status === "completed") {
|
||||||
const result = taskResult(text(ctx.state.output))
|
const result = taskResult(ctx.output)
|
||||||
if (result) {
|
if (result) {
|
||||||
return markdownBody(result)
|
return markdownBody(result)
|
||||||
}
|
}
|
||||||
@@ -1412,6 +1544,10 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody |
|
|||||||
if (toolStructuredFinal(commit)) {
|
if (toolStructuredFinal(commit)) {
|
||||||
return structuredBody(commit, raw) ?? textBody(toolScroll("final", ctx))
|
return structuredBody(commit, raw) ?? textBody(toolScroll("final", ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!rule(ctx.name) && !ctx.output.trim()) {
|
||||||
|
return structuredFallback(ctx.meta) ?? textBody(toolScroll("final", ctx))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return textBody(toolScroll(commit.phase, ctx))
|
return textBody(toolScroll(commit.phase, ctx))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
|
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
|
||||||
// session transcript, and a mutable footer for prompt input, status, and
|
// session transcript, and a mutable footer for prompt input, status, and
|
||||||
// permission/question UI. Every module in run/* shares these types to stay
|
// permission/form UI. Every module in run/* shares these types to stay
|
||||||
// aligned on that two-lane model.
|
// aligned on that two-lane model.
|
||||||
//
|
//
|
||||||
// Data flow through the system:
|
// Data flow through the system:
|
||||||
@@ -12,12 +12,16 @@
|
|||||||
// → footer.ts queues commits and patches the footer view
|
// → footer.ts queues commits and patches the footer view
|
||||||
// → OpenTUI split-footer renderer writes to terminal
|
// → OpenTUI split-footer renderer writes to terminal
|
||||||
import type {
|
import type {
|
||||||
|
FormAnswer,
|
||||||
|
FormInfo,
|
||||||
OpenCodeClient,
|
OpenCodeClient,
|
||||||
|
LocationGetOutput,
|
||||||
|
LocationRef,
|
||||||
PermissionV2Request,
|
PermissionV2Request,
|
||||||
QuestionV2Request,
|
|
||||||
ReferenceListOutput,
|
ReferenceListOutput,
|
||||||
|
SessionMessageAssistantTool,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import type { TuiConfig } from "../config/v1"
|
import type { Config } from "../config"
|
||||||
import type { CliRenderer } from "@opentui/core"
|
import type { CliRenderer } from "@opentui/core"
|
||||||
|
|
||||||
export type RunFilePart = {
|
export type RunFilePart = {
|
||||||
@@ -47,63 +51,25 @@ export type RunCommand = {
|
|||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
source?: string
|
source?: string
|
||||||
template?: string
|
|
||||||
hints?: unknown[]
|
|
||||||
agent?: string
|
|
||||||
model?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
subtask?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunProviderModel = {
|
export type RunProviderModel = {
|
||||||
id: string
|
|
||||||
providerID: string
|
|
||||||
api?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
name?: string
|
name?: string
|
||||||
capabilities?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
cost?: {
|
cost?: {
|
||||||
input: number
|
input: number
|
||||||
output?: number
|
|
||||||
cache?: {
|
|
||||||
read: number
|
|
||||||
write: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
limit?: {
|
|
||||||
context: number
|
|
||||||
input?: number
|
|
||||||
output?: number
|
|
||||||
}
|
}
|
||||||
status?: string
|
status?: string
|
||||||
options?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
headers?: {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
release_date?: string
|
|
||||||
variants?: Record<string, unknown>
|
variants?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunProvider = {
|
export type RunProvider = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
source?: string
|
|
||||||
env?: string[]
|
|
||||||
options?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
models: Record<string, RunProviderModel>
|
models: Record<string, RunProviderModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunPrompt = {
|
export type RunPrompt = {
|
||||||
messageID?: string
|
messageID?: string
|
||||||
partID?: string
|
|
||||||
text: string
|
text: string
|
||||||
parts: RunPromptPart[]
|
parts: RunPromptPart[]
|
||||||
mode?: "shell"
|
mode?: "shell"
|
||||||
@@ -117,14 +83,12 @@ export type RunPrompt = {
|
|||||||
|
|
||||||
export type FooterQueuedPrompt = {
|
export type FooterQueuedPrompt = {
|
||||||
messageID: string
|
messageID: string
|
||||||
partID: string
|
|
||||||
prompt: RunPrompt
|
prompt: RunPrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunAgent = {
|
export type RunAgent = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
|
||||||
mode: "subagent" | "primary" | "all"
|
mode: "subagent" | "primary" | "all"
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
}
|
}
|
||||||
@@ -133,25 +97,17 @@ export type RunReference = ReferenceListOutput["data"][number]
|
|||||||
|
|
||||||
export type RunInput = {
|
export type RunInput = {
|
||||||
sdk: OpenCodeClient
|
sdk: OpenCodeClient
|
||||||
directory: string
|
location: LocationGetOutput
|
||||||
sessionID: string
|
|
||||||
sessionTitle?: string
|
|
||||||
resume?: boolean
|
|
||||||
replay?: boolean
|
|
||||||
replayLimit?: number
|
|
||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
model: PromptModel | undefined
|
model: PromptModel | undefined
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
files: RunFilePart[]
|
files: RunFilePart[]
|
||||||
initialInput?: string
|
|
||||||
thinking: boolean
|
|
||||||
demo?: boolean
|
demo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MiniHost = {
|
export type MiniHost = {
|
||||||
terminal: {
|
terminal: {
|
||||||
stdin: NodeJS.ReadStream
|
stdin: NodeJS.ReadStream
|
||||||
cleanup(): void
|
|
||||||
}
|
}
|
||||||
platform: NodeJS.Platform
|
platform: NodeJS.Platform
|
||||||
stdout: {
|
stdout: {
|
||||||
@@ -170,8 +126,6 @@ export type MiniHost = {
|
|||||||
}
|
}
|
||||||
paths: {
|
paths: {
|
||||||
home: string
|
home: string
|
||||||
state: string
|
|
||||||
log: string
|
|
||||||
}
|
}
|
||||||
signals: {
|
signals: {
|
||||||
sigint: {
|
sigint: {
|
||||||
@@ -186,9 +140,6 @@ export type MiniHost = {
|
|||||||
now(): number
|
now(): number
|
||||||
}
|
}
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
pid: number
|
|
||||||
cwd: string
|
|
||||||
argv: readonly string[]
|
|
||||||
trace?: {
|
trace?: {
|
||||||
write(type: string, data?: unknown): void
|
write(type: string, data?: unknown): void
|
||||||
}
|
}
|
||||||
@@ -212,7 +163,6 @@ export type FooterState = {
|
|||||||
status: string
|
status: string
|
||||||
queue: number
|
queue: number
|
||||||
model: string
|
model: string
|
||||||
duration: string
|
|
||||||
usage: string
|
usage: string
|
||||||
first: boolean
|
first: boolean
|
||||||
interrupt: number
|
interrupt: number
|
||||||
@@ -222,8 +172,6 @@ export type FooterState = {
|
|||||||
// A partial update to FooterState. The footer merges this onto the current state.
|
// A partial update to FooterState. The footer merges this onto the current state.
|
||||||
export type FooterPatch = Partial<FooterState>
|
export type FooterPatch = Partial<FooterState>
|
||||||
|
|
||||||
export type RunDiffStyle = "auto" | "stacked"
|
|
||||||
|
|
||||||
export type TurnSummary = {
|
export type TurnSummary = {
|
||||||
agent: string
|
agent: string
|
||||||
model: string
|
model: string
|
||||||
@@ -231,7 +179,6 @@ export type TurnSummary = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ScrollbackOptions = {
|
export type ScrollbackOptions = {
|
||||||
diffStyle?: RunDiffStyle
|
|
||||||
suppressBackgrounds?: boolean
|
suppressBackgrounds?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +242,8 @@ export type MiniToolState =
|
|||||||
time: { start: number; end: number }
|
time: { start: number; end: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retained only for the noninteractive run JSON/V1 compatibility boundary.
|
||||||
|
// Interactive Mini commits carry SessionMessageAssistantTool directly.
|
||||||
export type MiniToolPart = {
|
export type MiniToolPart = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -305,6 +254,14 @@ export type MiniToolPart = {
|
|||||||
state: MiniToolState
|
state: MiniToolState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MiniPermissionRequest = PermissionV2Request & {
|
||||||
|
tool?: SessionMessageAssistantTool
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MiniFormRequest = FormInfo & {
|
||||||
|
location?: LocationRef
|
||||||
|
}
|
||||||
|
|
||||||
export type EntryLayout = "inline" | "block"
|
export type EntryLayout = "inline" | "block"
|
||||||
|
|
||||||
export type RunEntryBody =
|
export type RunEntryBody =
|
||||||
@@ -320,8 +277,8 @@ export type RunEntryBody =
|
|||||||
// "prompt".
|
// "prompt".
|
||||||
export type FooterView =
|
export type FooterView =
|
||||||
| { type: "prompt" }
|
| { type: "prompt" }
|
||||||
| { type: "permission"; request: PermissionV2Request }
|
| { type: "permission"; request: MiniPermissionRequest }
|
||||||
| { type: "question"; request: QuestionV2Request }
|
| { type: "form"; request: MiniFormRequest }
|
||||||
|
|
||||||
export type FooterPromptRoute =
|
export type FooterPromptRoute =
|
||||||
| { type: "composer" }
|
| { type: "composer" }
|
||||||
@@ -335,27 +292,23 @@ export type FooterPromptRoute =
|
|||||||
|
|
||||||
export type FooterSubagentTab = {
|
export type FooterSubagentTab = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
partID: string
|
|
||||||
callID: string
|
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
status: "running" | "completed" | "cancelled" | "error"
|
status: "running" | "completed" | "cancelled" | "error"
|
||||||
background?: boolean
|
background?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
toolCalls?: number
|
|
||||||
lastUpdatedAt: number
|
lastUpdatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FooterSubagentDetail = {
|
export type FooterSubagentDetail = {
|
||||||
sessionID: string
|
|
||||||
commits: StreamCommit[]
|
commits: StreamCommit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FooterSubagentState = {
|
export type FooterSubagentState = {
|
||||||
tabs: FooterSubagentTab[]
|
tabs: FooterSubagentTab[]
|
||||||
details: Record<string, FooterSubagentDetail>
|
details: Record<string, FooterSubagentDetail>
|
||||||
permissions: PermissionV2Request[]
|
permissions: MiniPermissionRequest[]
|
||||||
questions: QuestionV2Request[]
|
forms: MiniFormRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// The transport emits this alongside scrollback commits so the footer can update in the same frame.
|
// The transport emits this alongside scrollback commits so the footer can update in the same frame.
|
||||||
@@ -373,6 +326,10 @@ export type FooterEvent =
|
|||||||
type: "history"
|
type: "history"
|
||||||
history: RunPrompt[]
|
history: RunPrompt[]
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "agent"
|
||||||
|
agent: string | undefined
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "catalog"
|
type: "catalog"
|
||||||
agents: RunAgent[]
|
agents: RunAgent[]
|
||||||
@@ -409,9 +366,6 @@ export type FooterEvent =
|
|||||||
type: "turn.send"
|
type: "turn.send"
|
||||||
queue: number
|
queue: number
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: "turn.wait"
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: "turn.idle"
|
type: "turn.idle"
|
||||||
queue: number
|
queue: number
|
||||||
@@ -433,16 +387,22 @@ export type FooterEvent =
|
|||||||
state: FooterSubagentState
|
state: FooterSubagentState
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PermissionReply = Omit<Parameters<OpenCodeClient["permission"]["reply"]>[0], "sessionID">
|
export type PermissionReply = Parameters<OpenCodeClient["permission"]["reply"]>[0]
|
||||||
|
|
||||||
export type QuestionReply = {
|
export type FormReply = {
|
||||||
requestID: string
|
sessionID: string
|
||||||
answers: string[][]
|
formID: string
|
||||||
|
answer: FormAnswer
|
||||||
|
location?: LocationRef
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuestionReject = Omit<Parameters<OpenCodeClient["question"]["reject"]>[0], "sessionID">
|
export type FormCancel = {
|
||||||
|
sessionID: string
|
||||||
|
formID: string
|
||||||
|
location?: LocationRef
|
||||||
|
}
|
||||||
|
|
||||||
export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
|
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "session">
|
||||||
|
|
||||||
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
||||||
// appends content (coalesced in the footer queue), "final" closes it.
|
// appends content (coalesced in the footer queue), "final" closes it.
|
||||||
@@ -465,29 +425,18 @@ export type StreamCommit = {
|
|||||||
messageID?: string
|
messageID?: string
|
||||||
partID?: string
|
partID?: string
|
||||||
tool?: string
|
tool?: string
|
||||||
part?: MiniToolPart
|
directory?: string
|
||||||
|
part?: SessionMessageAssistantTool
|
||||||
interrupted?: boolean
|
interrupted?: boolean
|
||||||
toolState?: StreamToolState
|
toolState?: StreamToolState
|
||||||
toolError?: string
|
toolError?: string
|
||||||
shell?: {
|
shell?: {
|
||||||
callID: string
|
|
||||||
command: string
|
command: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalReplayAnchor = {
|
|
||||||
kind: EntryKind
|
|
||||||
text: string
|
|
||||||
phase: StreamPhase
|
|
||||||
messageID?: string
|
|
||||||
partID?: string
|
|
||||||
toolState?: StreamToolState
|
|
||||||
visible?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalReplayRow = {
|
export type LocalReplayRow = {
|
||||||
commit: StreamCommit
|
commit: StreamCommit
|
||||||
after?: LocalReplayAnchor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The public contract between the stream transport / prompt queue and
|
// The public contract between the stream transport / prompt queue and
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { readdir, readFile } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
export function themeDirectories(config: string, cwd: string) {
|
||||||
|
const directories: string[] = []
|
||||||
|
for (let current = cwd; ; current = path.dirname(current)) {
|
||||||
|
directories.push(path.join(current, ".opencode"))
|
||||||
|
if (path.dirname(current) === current) break
|
||||||
|
}
|
||||||
|
return [config, ...directories.reverse()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverThemes(directories: string[]) {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const directory of directories) {
|
||||||
|
const themeDirectory = path.join(directory, "themes")
|
||||||
|
const entries = await readdir(themeDirectory, { withFileTypes: true }).catch((error: unknown) => {
|
||||||
|
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
|
||||||
|
return Promise.reject(error)
|
||||||
|
})
|
||||||
|
const files = entries
|
||||||
|
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && path.extname(entry.name) === ".json")
|
||||||
|
.map((entry) => path.join(themeDirectory, entry.name))
|
||||||
|
.sort()
|
||||||
|
for (const file of files) {
|
||||||
|
result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -17,11 +17,14 @@ describe("run catalog shared", () => {
|
|||||||
}) as never,
|
}) as never,
|
||||||
)
|
)
|
||||||
|
|
||||||
await expect(waitForDefaultModel({ sdk: client, directory: "/tmp" })).resolves.toEqual({
|
await expect(waitForDefaultModel({ sdk: client, location: { directory: "/tmp" } })).resolves.toEqual({
|
||||||
providerID: "openai",
|
providerID: "openai",
|
||||||
modelID: "gpt-5",
|
modelID: "gpt-5",
|
||||||
})
|
})
|
||||||
expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } })
|
expect(selected).toHaveBeenCalledWith(
|
||||||
|
{ location: { directory: "/tmp", workspace: undefined } },
|
||||||
|
{ signal: expect.any(AbortSignal) },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads visible project references from the current reference catalog", async () => {
|
test("loads visible project references from the current reference catalog", async () => {
|
||||||
@@ -31,23 +34,23 @@ describe("run catalog shared", () => {
|
|||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
|
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
name: "effect",
|
name: "effect",
|
||||||
path: "/repos/effect",
|
path: "/repos/effect",
|
||||||
description: "Effect v4 sources",
|
description: "Effect v4 sources",
|
||||||
source: { type: "local", path: "/repos/effect" },
|
source: { type: "local", path: "/repos/effect" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "secret",
|
name: "secret",
|
||||||
path: "/repos/secret",
|
path: "/repos/secret",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
source: { type: "local", path: "/repos/secret" },
|
source: { type: "local", path: "/repos/secret" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}) as never,
|
}) as never,
|
||||||
)
|
)
|
||||||
|
|
||||||
const references = await loadRunReferences(client, "/tmp")
|
const references = await loadRunReferences(client, { directory: "/tmp" })
|
||||||
|
|
||||||
expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } })
|
expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } })
|
||||||
expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }])
|
expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }])
|
||||||
@@ -103,21 +106,9 @@ describe("run catalog shared", () => {
|
|||||||
name: "OpenAI",
|
name: "OpenAI",
|
||||||
models: {
|
models: {
|
||||||
"gpt-5": {
|
"gpt-5": {
|
||||||
id: "gpt-5",
|
|
||||||
providerID: "openai",
|
|
||||||
name: "Little Frank",
|
name: "Little Frank",
|
||||||
capabilities: expect.objectContaining({ tools: true }),
|
|
||||||
cost: {
|
cost: {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
|
||||||
cache: {
|
|
||||||
read: 0,
|
|
||||||
write: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
context: 128000,
|
|
||||||
output: 8192,
|
|
||||||
},
|
},
|
||||||
status: "active",
|
status: "active",
|
||||||
variants: {
|
variants: {
|
||||||
|
|||||||
@@ -1,31 +1,34 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||||
import { entryBody, entryCanStream, entryDone } from "../../src/mini/entry.body"
|
import { entryBody, entryCanStream, entryDone } from "../../src/mini/entry.body"
|
||||||
import type { MiniToolPart, StreamCommit, ToolSnapshot } from "../../src/mini/types"
|
import type { StreamCommit, ToolSnapshot } from "../../src/mini/types"
|
||||||
|
|
||||||
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
|
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolPart(
|
function toolPart(
|
||||||
tool: string,
|
name: string,
|
||||||
state: MiniToolPart["state"],
|
state: SessionMessageAssistantTool["state"],
|
||||||
id = `${tool}-1`,
|
id = `${name}-1`,
|
||||||
messageID = `msg-${tool}`,
|
): SessionMessageAssistantTool {
|
||||||
): MiniToolPart {
|
|
||||||
return {
|
return {
|
||||||
id,
|
|
||||||
sessionID: "session-1",
|
|
||||||
messageID,
|
|
||||||
type: "tool",
|
type: "tool",
|
||||||
callID: `call-${id}`,
|
id,
|
||||||
tool,
|
name,
|
||||||
state,
|
state,
|
||||||
} as MiniToolPart
|
time:
|
||||||
|
state.status === "streaming"
|
||||||
|
? { created: 1 }
|
||||||
|
: state.status === "completed" || state.status === "error"
|
||||||
|
? { created: 1, ran: 1, completed: 2 }
|
||||||
|
: { created: 1, ran: 1 },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolCommit(input: {
|
function toolCommit(input: {
|
||||||
tool: string
|
tool: string
|
||||||
state: MiniToolPart["state"]
|
state: SessionMessageAssistantTool["state"]
|
||||||
phase?: StreamCommit["phase"]
|
phase?: StreamCommit["phase"]
|
||||||
toolState?: StreamCommit["toolState"]
|
toolState?: StreamCommit["toolState"]
|
||||||
text?: string
|
text?: string
|
||||||
@@ -38,8 +41,11 @@ function toolCommit(input: {
|
|||||||
phase: input.phase ?? "final",
|
phase: input.phase ?? "final",
|
||||||
source: "tool",
|
source: "tool",
|
||||||
tool: input.tool,
|
tool: input.tool,
|
||||||
toolState: input.toolState ?? "completed",
|
toolState:
|
||||||
part: toolPart(input.tool, input.state, input.id, input.messageID),
|
input.toolState ??
|
||||||
|
(input.state.status === "error" ? "error" : input.state.status === "completed" ? "completed" : "running"),
|
||||||
|
messageID: input.messageID,
|
||||||
|
part: toolPart(input.tool, input.state, input.id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +68,13 @@ describe("run entry body", () => {
|
|||||||
text: "Shell exited with code 7",
|
text: "Shell exited with code 7",
|
||||||
phase: "final",
|
phase: "final",
|
||||||
source: "tool",
|
source: "tool",
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
toolState: "error",
|
toolState: "error",
|
||||||
toolError: "Shell exited with code 7",
|
toolError: "Shell exited with code 7",
|
||||||
shell: { callID: "sh_failed", command: "false" },
|
shell: { command: "false" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toEqual({ type: "text", content: "✖ bash failed: Shell exited with code 7" })
|
).toEqual({ type: "text", content: "✖ shell failed: Shell exited with code 7" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders assistant, reasoning, and user entries in their display formats", () => {
|
test("renders assistant, reasoning, and user entries in their display formats", () => {
|
||||||
@@ -136,13 +142,11 @@ describe("run entry body", () => {
|
|||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {
|
input: {
|
||||||
filePath: "src/a.ts",
|
path: "src/a.ts",
|
||||||
content: "const x = 1\n",
|
content: "const x = 1\n",
|
||||||
},
|
},
|
||||||
output: "",
|
structured: {},
|
||||||
title: "",
|
content: [],
|
||||||
metadata: {},
|
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
snapshot: {
|
snapshot: {
|
||||||
@@ -159,14 +163,12 @@ describe("run entry body", () => {
|
|||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {
|
input: {
|
||||||
filePath: "src/a.ts",
|
path: "src/a.ts",
|
||||||
},
|
},
|
||||||
output: "",
|
structured: {
|
||||||
title: "",
|
files: [{ file: "src/a.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new\n" }],
|
||||||
metadata: {
|
|
||||||
diff: "@@ -1 +1 @@\n-old\n+new\n",
|
|
||||||
},
|
},
|
||||||
time: { start: 1, end: 2 },
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
snapshot: {
|
snapshot: {
|
||||||
@@ -181,25 +183,22 @@ describe("run entry body", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "keeps completed apply_patch tool finals structured",
|
name: "keeps completed patch tool finals structured",
|
||||||
commit: toolCommit({
|
commit: toolCommit({
|
||||||
tool: "apply_patch",
|
tool: "patch",
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {},
|
input: {},
|
||||||
output: "",
|
content: [],
|
||||||
title: "",
|
structured: {
|
||||||
metadata: {
|
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
type: "update",
|
status: "modified",
|
||||||
filePath: "src/a.ts",
|
file: "src/a.ts",
|
||||||
relativePath: "src/a.ts",
|
|
||||||
patch: "@@ -1 +1 @@\n-old\n+new\n",
|
patch: "@@ -1 +1 @@\n-old\n+new\n",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
snapshot: {
|
snapshot: {
|
||||||
@@ -215,22 +214,16 @@ describe("run entry body", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
] satisfies Array<{ name: string; commit: StreamCommit; snapshot: ToolSnapshot }>) {
|
] satisfies Array<{ name: string; commit: StreamCommit; snapshot: ToolSnapshot }>) {
|
||||||
if (item.name === "keeps completed apply_patch tool finals structured") {
|
|
||||||
test.skip(item.name, () => {
|
|
||||||
expect(structured(item.commit)).toEqual(item.snapshot)
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
test(item.name, () => {
|
test(item.name, () => {
|
||||||
expect(structured(item.commit)).toEqual(item.snapshot)
|
expect(structured(item.commit)).toEqual(item.snapshot)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
test("keeps running task tool state out of scrollback", () => {
|
test("keeps running subagent tool state out of scrollback", () => {
|
||||||
expect(
|
expect(
|
||||||
entryBody(
|
entryBody(
|
||||||
toolCommit({
|
toolCommit({
|
||||||
tool: "task",
|
tool: "subagent",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
toolState: "running",
|
toolState: "running",
|
||||||
text: "running inspect reducer",
|
text: "running inspect reducer",
|
||||||
@@ -238,9 +231,10 @@ describe("run entry body", () => {
|
|||||||
status: "running",
|
status: "running",
|
||||||
input: {
|
input: {
|
||||||
description: "Inspect reducer",
|
description: "Inspect reducer",
|
||||||
subagent_type: "explore",
|
agent: "explore",
|
||||||
},
|
},
|
||||||
time: { start: 1 },
|
structured: { sessionID: "ses-child-1", status: "running" },
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -249,29 +243,23 @@ describe("run entry body", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("promotes task results to markdown and falls back to structured task summaries", () => {
|
test("promotes subagent results to markdown and falls back to structured summaries", () => {
|
||||||
expect(
|
expect(
|
||||||
entryBody(
|
entryBody(
|
||||||
toolCommit({
|
toolCommit({
|
||||||
tool: "task",
|
tool: "subagent",
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {
|
input: {
|
||||||
description: "Inspect reducer",
|
description: "Inspect reducer",
|
||||||
subagent_type: "explore",
|
agent: "explore",
|
||||||
},
|
},
|
||||||
title: "",
|
content: [{ type: "text", text: "# Findings\n\n- Footer stays live" }],
|
||||||
output: [
|
structured: {
|
||||||
'<task id="child-1" state="completed">',
|
sessionID: "ses-child-1",
|
||||||
"<task_result>",
|
status: "completed",
|
||||||
"# Findings\n\n- Footer stays live",
|
output: "# Findings\n\n- Footer stays live",
|
||||||
"</task_result>",
|
|
||||||
"</task>",
|
|
||||||
].join("\n"),
|
|
||||||
metadata: {
|
|
||||||
sessionId: "child-1",
|
|
||||||
},
|
},
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -283,27 +271,25 @@ describe("run entry body", () => {
|
|||||||
expect(
|
expect(
|
||||||
structured(
|
structured(
|
||||||
toolCommit({
|
toolCommit({
|
||||||
tool: "task",
|
tool: "subagent",
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {
|
input: {
|
||||||
description: "Inspect reducer",
|
description: "Inspect reducer",
|
||||||
subagent_type: "explore",
|
agent: "explore",
|
||||||
},
|
},
|
||||||
title: "",
|
content: [],
|
||||||
output: ['<task id="child-1" state="completed">', "<task_result>", "", "</task_result>", "</task>"].join(
|
structured: {
|
||||||
"\n",
|
sessionID: "ses-child-1",
|
||||||
),
|
status: "completed",
|
||||||
metadata: {
|
output: "",
|
||||||
sessionId: "child-1",
|
|
||||||
},
|
},
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
kind: "task",
|
kind: "task",
|
||||||
title: "# Explore Task",
|
title: "# Explore Subagent",
|
||||||
rows: ["Inspect reducer"],
|
rows: ["Inspect reducer"],
|
||||||
tail: "",
|
tail: "",
|
||||||
})
|
})
|
||||||
@@ -316,7 +302,7 @@ describe("run entry body", () => {
|
|||||||
text: "partial output",
|
text: "partial output",
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
source: "tool",
|
source: "tool",
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
partID: "tool-2",
|
partID: "tool-2",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -332,7 +318,7 @@ describe("run entry body", () => {
|
|||||||
text: "partial output",
|
text: "partial output",
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
source: "tool",
|
source: "tool",
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
}),
|
}),
|
||||||
body,
|
body,
|
||||||
),
|
),
|
||||||
@@ -344,35 +330,30 @@ describe("run entry body", () => {
|
|||||||
text: "output",
|
text: "output",
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
source: "tool",
|
source: "tool",
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
toolState: "completed",
|
toolState: "completed",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip("formats completed bash output with a blank line after the command and no trailing blank row", () => {
|
test("formats completed shell output with a blank line after the command and no trailing blank row", () => {
|
||||||
|
const output = ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join("\n")
|
||||||
expect(
|
expect(
|
||||||
entryBody(
|
entryBody(
|
||||||
toolCommit({
|
toolCommit({
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
toolState: "completed",
|
toolState: "completed",
|
||||||
text: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join("\n"),
|
text: output,
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {
|
input: {
|
||||||
command: "git status",
|
command: "git status",
|
||||||
workdir: "/tmp/demo",
|
workdir: "/tmp/demo",
|
||||||
},
|
},
|
||||||
output: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join(
|
content: [{ type: "text", text: output }],
|
||||||
"\n",
|
structured: { exit: 0, truncated: false },
|
||||||
),
|
|
||||||
title: "git status",
|
|
||||||
metadata: {
|
|
||||||
exitCode: 0,
|
|
||||||
},
|
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -382,11 +363,11 @@ describe("run entry body", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip("renders command-only bash starts without the shell header", () => {
|
test("renders command-only shell starts without the shell header", () => {
|
||||||
expect(
|
expect(
|
||||||
entryBody(
|
entryBody(
|
||||||
toolCommit({
|
toolCommit({
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
toolState: "running",
|
toolState: "running",
|
||||||
text: "running shell",
|
text: "running shell",
|
||||||
@@ -395,7 +376,8 @@ describe("run entry body", () => {
|
|||||||
input: {
|
input: {
|
||||||
command: "ls",
|
command: "ls",
|
||||||
},
|
},
|
||||||
time: { start: 1 },
|
structured: {},
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -413,11 +395,10 @@ describe("run entry body", () => {
|
|||||||
text: "running shell",
|
text: "running shell",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
source: "tool",
|
source: "tool",
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
partID: "shell:call-1",
|
partID: "shell:call-1",
|
||||||
toolState: "running",
|
toolState: "running",
|
||||||
shell: {
|
shell: {
|
||||||
callID: "call-1",
|
|
||||||
command: "pwd",
|
command: "pwd",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -434,11 +415,10 @@ describe("run entry body", () => {
|
|||||||
text: "/tmp/demo\n",
|
text: "/tmp/demo\n",
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
source: "tool",
|
source: "tool",
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
partID: "shell:call-1",
|
partID: "shell:call-1",
|
||||||
toolState: "completed",
|
toolState: "completed",
|
||||||
shell: {
|
shell: {
|
||||||
callID: "call-1",
|
|
||||||
command: "pwd",
|
command: "pwd",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -449,29 +429,25 @@ describe("run entry body", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip("falls back to patch summary when apply_patch has no visible diff items", () => {
|
test("falls back to patch summary when patch has no visible diff items", () => {
|
||||||
expect(
|
expect(
|
||||||
entryBody(
|
entryBody(
|
||||||
toolCommit({
|
toolCommit({
|
||||||
tool: "apply_patch",
|
tool: "patch",
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {
|
input: {
|
||||||
patchText: "*** Begin Patch\n*** End Patch",
|
patchText: "*** Begin Patch\n*** End Patch",
|
||||||
},
|
},
|
||||||
output: "",
|
content: [],
|
||||||
title: "",
|
structured: {
|
||||||
metadata: {
|
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
type: "update",
|
status: "modified",
|
||||||
filePath: "src/a.ts",
|
file: "src/a.ts",
|
||||||
relativePath: "src/a.ts",
|
|
||||||
diff: "@@ -1 +1 @@\n-old\n+new\n",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -481,34 +457,29 @@ describe("run entry body", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip("suppresses redundant patched rows when apply_patch also created a file", () => {
|
test("suppresses redundant patched rows when patch also created a file", () => {
|
||||||
expect(
|
expect(
|
||||||
entryBody(
|
entryBody(
|
||||||
toolCommit({
|
toolCommit({
|
||||||
tool: "apply_patch",
|
tool: "patch",
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {
|
input: {
|
||||||
patchText: "*** Begin Patch\n*** End Patch",
|
patchText: "*** Begin Patch\n*** End Patch",
|
||||||
},
|
},
|
||||||
output: "",
|
content: [],
|
||||||
title: "",
|
structured: {
|
||||||
metadata: {
|
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
type: "update",
|
status: "modified",
|
||||||
filePath: "src/a.ts",
|
file: "src/a.ts",
|
||||||
relativePath: "src/a.ts",
|
|
||||||
diff: "@@ -1 +1 @@\n-old\n+new\n",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "add",
|
status: "added",
|
||||||
filePath: "README-demo.md",
|
file: "README-demo.md",
|
||||||
relativePath: "README-demo.md",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -531,9 +502,9 @@ describe("run entry body", () => {
|
|||||||
pattern: "**/*tool*",
|
pattern: "**/*tool*",
|
||||||
path: "/tmp/demo/run",
|
path: "/tmp/demo/run",
|
||||||
},
|
},
|
||||||
error: "No such file or directory: '/tmp/demo/run'",
|
error: { type: "unknown", message: "No such file or directory: '/tmp/demo/run'" },
|
||||||
metadata: {},
|
structured: {},
|
||||||
time: { start: 1, end: 2 },
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -543,6 +514,31 @@ describe("run entry body", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("renders bounded structured output for completed unknown tools without text", () => {
|
||||||
|
const body = entryBody(
|
||||||
|
toolCommit({
|
||||||
|
tool: "mcp_custom",
|
||||||
|
phase: "final",
|
||||||
|
toolState: "completed",
|
||||||
|
text: "",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { target: "demo" },
|
||||||
|
structured: {
|
||||||
|
result: { ok: true, nested: { values: Array.from({ length: 40 }, (_, index) => ({ index })) } },
|
||||||
|
large: "x".repeat(8_000),
|
||||||
|
},
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(body).toMatchObject({ type: "code", filetype: "json" })
|
||||||
|
expect(body.type === "code" ? body.content : "").toContain('"ok": true')
|
||||||
|
expect(body.type === "code" ? body.content : "").toContain("[truncated]")
|
||||||
|
expect(body.type === "code" ? body.content.length : Infinity).toBeLessThanOrEqual(4_096)
|
||||||
|
})
|
||||||
|
|
||||||
test("renders interrupted assistant finals as text", () => {
|
test("renders interrupted assistant finals as text", () => {
|
||||||
expect(
|
expect(
|
||||||
entryBody(
|
entryBody(
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { resolve, type Info, type Resolved } from "../../../src/config/v1"
|
import { resolve, type Info, type Resolved } from "../../../src/config"
|
||||||
import { TuiKeybind } from "../../../src/config/v1/keybind"
|
import { TuiKeybind } from "../../../src/config/keybind"
|
||||||
|
|
||||||
type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
|
type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader"> & {
|
||||||
attention?: Partial<Resolved["attention"]>
|
attention?: Partial<Resolved["attention"]>
|
||||||
keybinds?: Partial<TuiKeybind.Keybinds>
|
keybinds?: Partial<TuiKeybind.Keybinds>
|
||||||
leader_timeout?: number
|
leader_timeout?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTuiResolvedConfig(input: ResolvedInput = {}) {
|
export function createTuiResolvedConfig(input: ResolvedInput = {}) {
|
||||||
return resolve(input, { terminalSuspend: process.platform !== "win32" })
|
const { leader_timeout, ...current } = input
|
||||||
|
return resolve(
|
||||||
|
{
|
||||||
|
...current,
|
||||||
|
leader: leader_timeout === undefined ? undefined : { timeout: leader_timeout },
|
||||||
|
},
|
||||||
|
{ terminalSuspend: process.platform !== "win32" },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import { Keymap } from "../../src/context/keymap"
|
import { Keymap } from "../../src/context/keymap"
|
||||||
import { resolve } from "../../src/config/v1"
|
import { resolve } from "../../src/config"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { RunFooterView } from "../../src/mini/footer.view"
|
import { RunFooterView } from "../../src/mini/footer.view"
|
||||||
@@ -14,7 +14,6 @@ test("down opens subagents from an empty prompt", async () => {
|
|||||||
status: "",
|
status: "",
|
||||||
queue: 0,
|
queue: 0,
|
||||||
model: "gpt-5",
|
model: "gpt-5",
|
||||||
duration: "",
|
|
||||||
usage: "",
|
usage: "",
|
||||||
first: false,
|
first: false,
|
||||||
interrupt: 0,
|
interrupt: 0,
|
||||||
@@ -25,8 +24,6 @@ test("down opens subagents from an empty prompt", async () => {
|
|||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
sessionID: "subagent-1",
|
sessionID: "subagent-1",
|
||||||
partID: "part-1",
|
|
||||||
callID: "call-1",
|
|
||||||
label: "Explore",
|
label: "Explore",
|
||||||
description: "Inspect the keymap",
|
description: "Inspect the keymap",
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -35,7 +32,7 @@ test("down opens subagents from an empty prompt", async () => {
|
|||||||
],
|
],
|
||||||
details: {},
|
details: {},
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
forms: [],
|
||||||
})
|
})
|
||||||
const config = resolve(
|
const config = resolve(
|
||||||
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
||||||
@@ -45,7 +42,7 @@ test("down opens subagents from an empty prompt", async () => {
|
|||||||
return (
|
return (
|
||||||
<Keymap.Provider config={config}>
|
<Keymap.Provider config={config}>
|
||||||
<RunFooterView
|
<RunFooterView
|
||||||
directory="/tmp"
|
directory={() => "/tmp"}
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
agents={() => []}
|
agents={() => []}
|
||||||
references={() => []}
|
references={() => []}
|
||||||
@@ -59,11 +56,10 @@ test("down opens subagents from an empty prompt", async () => {
|
|||||||
subagent={subagents}
|
subagent={subagents}
|
||||||
theme={() => RUN_THEME_FALLBACK}
|
theme={() => RUN_THEME_FALLBACK}
|
||||||
tuiConfig={config}
|
tuiConfig={config}
|
||||||
agent="opencode"
|
|
||||||
onSubmit={() => true}
|
onSubmit={() => true}
|
||||||
onPermissionReply={() => {}}
|
onPermissionReply={() => {}}
|
||||||
onQuestionReply={() => {}}
|
onFormReply={() => {}}
|
||||||
onQuestionReject={() => {}}
|
onFormCancel={() => {}}
|
||||||
onCycle={() => {}}
|
onCycle={() => {}}
|
||||||
onInterrupt={() => false}
|
onInterrupt={() => false}
|
||||||
onEditorOpen={async () => undefined}
|
onEditorOpen={async () => undefined}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { coalesceProgressCommit } from "../../src/mini/footer"
|
||||||
|
import type { StreamCommit } from "../../src/mini/types"
|
||||||
|
|
||||||
|
function progress(input: Partial<StreamCommit> = {}): StreamCommit {
|
||||||
|
return {
|
||||||
|
kind: "tool",
|
||||||
|
source: "tool",
|
||||||
|
phase: "progress",
|
||||||
|
text: "one",
|
||||||
|
messageID: "msg_1",
|
||||||
|
partID: "part_1",
|
||||||
|
tool: "shell",
|
||||||
|
toolState: "running",
|
||||||
|
...input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("coalesces progress only within the same message and tool state", () => {
|
||||||
|
expect(coalesceProgressCommit(progress(), progress({ messageID: "msg_2" }))).toBeUndefined()
|
||||||
|
expect(coalesceProgressCommit(progress(), progress({ toolState: "completed" }))).toBeUndefined()
|
||||||
|
expect(coalesceProgressCommit(progress(), progress({ text: "two", directory: "/latest" }))).toEqual(
|
||||||
|
progress({ text: "onetwo", directory: "/latest" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import { expect, test } from "bun:test"
|
|||||||
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
|
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import type { QuestionV2Request } from "@opencode-ai/client/promise"
|
import type { FormInfo } from "@opencode-ai/client/promise"
|
||||||
import { Keymap } from "../../src/context/keymap"
|
import { Keymap } from "../../src/context/keymap"
|
||||||
import {
|
import {
|
||||||
RUN_COMMAND_PANEL_ROWS,
|
RUN_COMMAND_PANEL_ROWS,
|
||||||
@@ -30,7 +30,6 @@ import type {
|
|||||||
RunTuiConfig,
|
RunTuiConfig,
|
||||||
StreamCommit,
|
StreamCommit,
|
||||||
} from "../../src/mini/types"
|
} from "../../src/mini/types"
|
||||||
import { RunQuestionBody } from "../../src/mini/footer.question"
|
|
||||||
import { selectedCommand } from "../../src/mini/footer.prompt"
|
import { selectedCommand } from "../../src/mini/footer.prompt"
|
||||||
import { RejectField } from "../../src/mini/footer.permission"
|
import { RejectField } from "../../src/mini/footer.permission"
|
||||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||||
@@ -42,8 +41,6 @@ function command(input: { name: string; description: string; source?: "command"
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
template: "",
|
|
||||||
hints: [],
|
|
||||||
} satisfies RunCommand
|
} satisfies RunCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,51 +52,11 @@ function model(input: {
|
|||||||
variants?: Record<string, Record<string, never>>
|
variants?: Record<string, Record<string, never>>
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
|
||||||
providerID: "opencode",
|
|
||||||
api: {
|
|
||||||
id: "opencode",
|
|
||||||
url: "https://opencode.ai",
|
|
||||||
npm: "@ai-sdk/openai-compatible",
|
|
||||||
},
|
|
||||||
name: input.name,
|
name: input.name,
|
||||||
capabilities: {
|
|
||||||
temperature: true,
|
|
||||||
reasoning: true,
|
|
||||||
attachment: true,
|
|
||||||
toolcall: true,
|
|
||||||
input: {
|
|
||||||
text: true,
|
|
||||||
audio: false,
|
|
||||||
image: true,
|
|
||||||
video: false,
|
|
||||||
pdf: true,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
text: true,
|
|
||||||
audio: false,
|
|
||||||
image: false,
|
|
||||||
video: false,
|
|
||||||
pdf: false,
|
|
||||||
},
|
|
||||||
interleaved: false,
|
|
||||||
},
|
|
||||||
cost: {
|
cost: {
|
||||||
input: input.cost ?? 1,
|
input: input.cost ?? 1,
|
||||||
output: 1,
|
|
||||||
cache: {
|
|
||||||
read: 0,
|
|
||||||
write: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
context: 128000,
|
|
||||||
output: 8192,
|
|
||||||
},
|
},
|
||||||
status: input.status ?? "active",
|
status: input.status ?? "active",
|
||||||
options: {},
|
|
||||||
headers: {},
|
|
||||||
release_date: "2026-01-01",
|
|
||||||
variants: input.variants,
|
variants: input.variants,
|
||||||
} satisfies RunProvider["models"][string]
|
} satisfies RunProvider["models"][string]
|
||||||
}
|
}
|
||||||
@@ -108,9 +65,6 @@ function provider() {
|
|||||||
return {
|
return {
|
||||||
id: "opencode",
|
id: "opencode",
|
||||||
name: "opencode",
|
name: "opencode",
|
||||||
source: "api",
|
|
||||||
env: [],
|
|
||||||
options: {},
|
|
||||||
models: {
|
models: {
|
||||||
"gpt-5": model({ id: "gpt-5", name: "GPT-5", variants: { high: {}, minimal: {} } }),
|
"gpt-5": model({ id: "gpt-5", name: "GPT-5", variants: { high: {}, minimal: {} } }),
|
||||||
"gpt-free": model({ id: "gpt-free", name: "GPT Free", cost: 0 }),
|
"gpt-free": model({ id: "gpt-free", name: "GPT Free", cost: 0 }),
|
||||||
@@ -127,8 +81,6 @@ function subagent(input: {
|
|||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
partID: `part-${input.sessionID}`,
|
|
||||||
callID: `call-${input.sessionID}`,
|
|
||||||
label: input.label,
|
label: input.label,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
status: input.status ?? "running",
|
status: input.status ?? "running",
|
||||||
@@ -142,7 +94,6 @@ function footerState(input: Partial<FooterState> = {}) {
|
|||||||
status: "",
|
status: "",
|
||||||
queue: 0,
|
queue: 0,
|
||||||
model: "gpt-5",
|
model: "gpt-5",
|
||||||
duration: "",
|
|
||||||
usage: "",
|
usage: "",
|
||||||
first: false,
|
first: false,
|
||||||
interrupt: 0,
|
interrupt: 0,
|
||||||
@@ -165,11 +116,13 @@ async function renderFooter(
|
|||||||
state?: Partial<FooterState>
|
state?: Partial<FooterState>
|
||||||
onCycle?: () => void
|
onCycle?: () => void
|
||||||
onSubmit?: (prompt: RunPrompt) => boolean
|
onSubmit?: (prompt: RunPrompt) => boolean
|
||||||
|
view?: FooterView
|
||||||
|
onFormReply?: (input: unknown) => void
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const [view] = createSignal<FooterView>({ type: "prompt" })
|
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
|
||||||
const [subagents] = createSignal<FooterSubagentState>(
|
const [subagents] = createSignal<FooterSubagentState>(
|
||||||
input.subagents ?? { tabs: [], details: {}, permissions: [], questions: [] },
|
input.subagents ?? { tabs: [], details: {}, permissions: [], forms: [] },
|
||||||
)
|
)
|
||||||
const state = footerState(input.state)
|
const state = footerState(input.state)
|
||||||
const config = input.tuiConfig ?? tuiConfig
|
const config = input.tuiConfig ?? tuiConfig
|
||||||
@@ -177,7 +130,7 @@ async function renderFooter(
|
|||||||
return (
|
return (
|
||||||
<Keymap.Provider config={config}>
|
<Keymap.Provider config={config}>
|
||||||
<RunFooterView
|
<RunFooterView
|
||||||
directory="/tmp"
|
directory={() => "/tmp"}
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
agents={() => []}
|
agents={() => []}
|
||||||
references={() => []}
|
references={() => []}
|
||||||
@@ -191,11 +144,10 @@ async function renderFooter(
|
|||||||
subagent={subagents}
|
subagent={subagents}
|
||||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||||
tuiConfig={config}
|
tuiConfig={config}
|
||||||
agent="opencode"
|
|
||||||
onSubmit={input.onSubmit ?? (() => true)}
|
onSubmit={input.onSubmit ?? (() => true)}
|
||||||
onPermissionReply={() => {}}
|
onPermissionReply={() => {}}
|
||||||
onQuestionReply={() => {}}
|
onFormReply={(value) => input.onFormReply?.(value)}
|
||||||
onQuestionReject={() => {}}
|
onFormCancel={() => {}}
|
||||||
onCycle={input.onCycle ?? (() => {})}
|
onCycle={input.onCycle ?? (() => {})}
|
||||||
onInterrupt={() => false}
|
onInterrupt={() => false}
|
||||||
onEditorOpen={async () => undefined}
|
onEditorOpen={async () => undefined}
|
||||||
@@ -223,6 +175,7 @@ async function renderFooter(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...app,
|
...app,
|
||||||
|
setView,
|
||||||
cleanup() {
|
cleanup() {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
@@ -231,6 +184,57 @@ async function renderFooter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("direct footer preserves a partial multi-field form draft across permission preemption", async () => {
|
||||||
|
const request: FormInfo = {
|
||||||
|
id: "frm_preempted",
|
||||||
|
sessionID: "ses_child",
|
||||||
|
title: "Deployment",
|
||||||
|
fields: [
|
||||||
|
{ key: "service", type: "string", title: "Service", required: true },
|
||||||
|
{ key: "notes", type: "string", title: "Notes", required: true },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const app = await renderFooter({
|
||||||
|
height: 16,
|
||||||
|
view: { type: "form", request },
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
"api".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||||
|
app.mockInput.pressEnter()
|
||||||
|
await app.renderOnce()
|
||||||
|
"keep this draft".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||||
|
expect(app.renderer.currentFocusedEditor?.plainText).toBe("keep this draft")
|
||||||
|
|
||||||
|
app.setView({
|
||||||
|
type: "permission",
|
||||||
|
request: {
|
||||||
|
id: "per_preempting",
|
||||||
|
sessionID: "ses_child",
|
||||||
|
action: "read",
|
||||||
|
resources: ["src/index.ts"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.captureCharFrame()).toContain("Permission required")
|
||||||
|
|
||||||
|
app.setView({ type: "form", request })
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.captureCharFrame()).toContain("2/2")
|
||||||
|
expect(app.renderer.currentFocusedEditor?.plainText).toBe("keep this draft")
|
||||||
|
|
||||||
|
app.setView({ type: "prompt" })
|
||||||
|
await app.renderOnce()
|
||||||
|
app.setView({ type: "form", request })
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.captureCharFrame()).toContain("1/2")
|
||||||
|
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||||
|
} finally {
|
||||||
|
app.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
|
function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
|
||||||
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
|
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
|
||||||
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
|
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
|
||||||
@@ -310,13 +314,13 @@ test("run entry content updates when live commit text changes", async () => {
|
|||||||
source: "tool",
|
source: "tool",
|
||||||
messageID: "msg-1",
|
messageID: "msg-1",
|
||||||
partID: "part-1",
|
partID: "part-1",
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
})
|
})
|
||||||
|
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
() => (
|
() => (
|
||||||
<box width={80} height={4}>
|
<box width={80} height={4}>
|
||||||
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} width={80} />
|
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} />
|
||||||
</box>
|
</box>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@@ -336,7 +340,7 @@ test("run entry content updates when live commit text changes", async () => {
|
|||||||
source: "tool",
|
source: "tool",
|
||||||
messageID: "msg-1",
|
messageID: "msg-1",
|
||||||
partID: "part-1",
|
partID: "part-1",
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
})
|
})
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
|
|
||||||
@@ -669,9 +673,7 @@ test("direct subagent panel closes when moving up from the first item", async ()
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("direct queued prompt panel renders pending prompt actions", async () => {
|
test("direct queued prompt panel renders pending prompt actions", async () => {
|
||||||
const [prompts] = createSignal([
|
const [prompts] = createSignal([{ messageID: "m-1", prompt: { text: "fix the auth test", parts: [] } }])
|
||||||
{ messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } },
|
|
||||||
])
|
|
||||||
|
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
() => (
|
() => (
|
||||||
@@ -874,9 +876,11 @@ test("selectedCommand backfills the catalog source for bound drafts", () => {
|
|||||||
source: "skill",
|
source: "skill",
|
||||||
})
|
})
|
||||||
// Plain commands stay untagged.
|
// Plain commands stay untagged.
|
||||||
expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" }, [
|
expect(
|
||||||
command({ name: "deploy", description: "Deploy" }),
|
selectedCommand("/deploy prod", { name: "deploy", arguments: "" }, [
|
||||||
])).toEqual({ name: "deploy", arguments: "prod" })
|
command({ name: "deploy", description: "Deploy" }),
|
||||||
|
]),
|
||||||
|
).toEqual({ name: "deploy", arguments: "prod" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("direct footer tags skill slash submissions with their catalog source", async () => {
|
test("direct footer tags skill slash submissions with their catalog source", async () => {
|
||||||
@@ -936,7 +940,9 @@ test.skip("direct footer skill picker inserts an editable bound skill command",
|
|||||||
app.mockInput.pressEnter()
|
app.mockInput.pressEnter()
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
|
|
||||||
expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task", source: "skill" } }])
|
expect(submits).toEqual([
|
||||||
|
{ text: "/new task", parts: [], command: { name: "new", arguments: "task", source: "skill" } },
|
||||||
|
])
|
||||||
} finally {
|
} finally {
|
||||||
app.cleanup()
|
app.cleanup()
|
||||||
}
|
}
|
||||||
@@ -981,7 +987,6 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
status: "",
|
status: "",
|
||||||
queue: 3,
|
queue: 3,
|
||||||
model: "gpt-5",
|
model: "gpt-5",
|
||||||
duration: "",
|
|
||||||
usage: "",
|
usage: "",
|
||||||
first: false,
|
first: false,
|
||||||
interrupt: 0,
|
interrupt: 0,
|
||||||
@@ -992,13 +997,13 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
|
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
|
||||||
details: {},
|
details: {},
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
forms: [],
|
||||||
})
|
})
|
||||||
function Harness() {
|
function Harness() {
|
||||||
return (
|
return (
|
||||||
<Keymap.Provider config={tuiConfig}>
|
<Keymap.Provider config={tuiConfig}>
|
||||||
<RunFooterView
|
<RunFooterView
|
||||||
directory="/tmp"
|
directory={() => "/tmp"}
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
agents={() => []}
|
agents={() => []}
|
||||||
references={() => []}
|
references={() => []}
|
||||||
@@ -1013,16 +1018,13 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
state={state}
|
state={state}
|
||||||
view={view}
|
view={view}
|
||||||
subagent={subagents}
|
subagent={subagents}
|
||||||
queuedPrompts={() => [
|
queuedPrompts={() => [{ messageID: "m-queued", prompt: { text: "follow up", parts: [] } }]}
|
||||||
{ messageID: "m-queued", partID: "p-queued", prompt: { text: "follow up", parts: [] } },
|
|
||||||
]}
|
|
||||||
theme={() => RUN_THEME_FALLBACK}
|
theme={() => RUN_THEME_FALLBACK}
|
||||||
tuiConfig={tuiConfig}
|
tuiConfig={tuiConfig}
|
||||||
agent="opencode"
|
|
||||||
onSubmit={() => true}
|
onSubmit={() => true}
|
||||||
onPermissionReply={() => {}}
|
onPermissionReply={() => {}}
|
||||||
onQuestionReply={() => {}}
|
onFormReply={() => {}}
|
||||||
onQuestionReject={() => {}}
|
onFormCancel={() => {}}
|
||||||
onCycle={() => {}}
|
onCycle={() => {}}
|
||||||
onInterrupt={() => false}
|
onInterrupt={() => false}
|
||||||
onEditorOpen={async () => undefined}
|
onEditorOpen={async () => undefined}
|
||||||
@@ -1098,7 +1100,7 @@ test("direct footer always offers backgrounding for a foreground subagent", asyn
|
|||||||
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
|
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
|
||||||
details: {},
|
details: {},
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
forms: [],
|
||||||
},
|
},
|
||||||
width: 160,
|
width: 160,
|
||||||
})
|
})
|
||||||
@@ -1125,7 +1127,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
|
|||||||
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" })],
|
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" })],
|
||||||
details: {},
|
details: {},
|
||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
forms: [],
|
||||||
},
|
},
|
||||||
width: 160,
|
width: 160,
|
||||||
})
|
})
|
||||||
@@ -1191,109 +1193,6 @@ test("direct footer mode label keeps left padding without a status pill", async
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("direct question body separates single-select checkmark from label", async () => {
|
|
||||||
const request = {
|
|
||||||
id: "question-1",
|
|
||||||
sessionID: "session-1",
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
question: "Which categorical concept is often described as a universal way to combine two objects?",
|
|
||||||
header: "Universal Product",
|
|
||||||
options: [
|
|
||||||
{ label: "Product", description: "A product comes with projections." },
|
|
||||||
{ label: "Equalizer", description: "An equalizer selects morphisms where arrows agree." },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies QuestionV2Request
|
|
||||||
const replies: unknown[] = []
|
|
||||||
|
|
||||||
const app = await testRender(
|
|
||||||
() => (
|
|
||||||
<box width={100} height={12}>
|
|
||||||
<RunQuestionBody
|
|
||||||
request={request}
|
|
||||||
theme={RUN_THEME_FALLBACK.footer}
|
|
||||||
onReply={(input) => {
|
|
||||||
replies.push(input)
|
|
||||||
}}
|
|
||||||
onReject={() => {}}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 100,
|
|
||||||
height: 12,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
app.mockInput.pressEnter()
|
|
||||||
await app.renderOnce()
|
|
||||||
|
|
||||||
expect(replies).toHaveLength(1)
|
|
||||||
expect(app.captureCharFrame()).toContain("Product ✓")
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// OpenTUI currently segfaults while tearing down this textarea-backed keymap renderer.
|
|
||||||
// Re-enable after the runtime fix.
|
|
||||||
test.skip("direct custom answer submits through keymap return binding", async () => {
|
|
||||||
const question = {
|
|
||||||
id: "question-1",
|
|
||||||
sessionID: "session-1",
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
question: "Which answer should I use?",
|
|
||||||
header: "Answer",
|
|
||||||
options: [{ label: "Provided", description: "Use the listed answer." }],
|
|
||||||
custom: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies QuestionV2Request
|
|
||||||
const questions: unknown[] = []
|
|
||||||
function Harness() {
|
|
||||||
return (
|
|
||||||
<Keymap.Provider config={tuiConfig}>
|
|
||||||
<RunQuestionBody
|
|
||||||
request={question}
|
|
||||||
theme={RUN_THEME_FALLBACK.footer}
|
|
||||||
onReply={(input) => {
|
|
||||||
questions.push(input)
|
|
||||||
}}
|
|
||||||
onReject={() => {}}
|
|
||||||
/>
|
|
||||||
</Keymap.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(
|
|
||||||
() => (
|
|
||||||
<box width={100} height={18}>
|
|
||||||
<Harness />
|
|
||||||
</box>
|
|
||||||
),
|
|
||||||
{ width: 100, height: 18, kittyKeyboard: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await app.renderOnce()
|
|
||||||
app.mockInput.pressKey("2")
|
|
||||||
await app.renderOnce()
|
|
||||||
"typed".split("").forEach((key) => app.mockInput.pressKey(key))
|
|
||||||
await app.renderOnce()
|
|
||||||
app.mockInput.pressEnter()
|
|
||||||
await app.renderOnce()
|
|
||||||
expect(questions).toEqual([{ requestID: "question-1", answers: [["typed"]] }])
|
|
||||||
} finally {
|
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("direct permission rejection submits through keymap return binding", async () => {
|
test("direct permission rejection submits through keymap return binding", async () => {
|
||||||
let text = ""
|
let text = ""
|
||||||
const submits: string[] = []
|
const submits: string[] = []
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { FormField, FormInfo } from "@opencode-ai/client/promise"
|
||||||
|
import {
|
||||||
|
createFormBodyState,
|
||||||
|
formAcknowledge,
|
||||||
|
formAnswer,
|
||||||
|
formCommitInput,
|
||||||
|
formPick,
|
||||||
|
formReply,
|
||||||
|
formSetExternalReady,
|
||||||
|
formSetField,
|
||||||
|
formSetSelected,
|
||||||
|
formUnsupported,
|
||||||
|
formValidate,
|
||||||
|
} from "../../src/mini/form.shared"
|
||||||
|
|
||||||
|
function request(fields: FormField[]): FormInfo {
|
||||||
|
return { id: "frm_1", sessionID: "ses_1", title: "Input", fields: fields as FormInfo["fields"] }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Mini form state", () => {
|
||||||
|
test("builds every supported answer and preserves owner location", () => {
|
||||||
|
const form = request([
|
||||||
|
{ key: "choice", type: "string", options: [{ value: "fast", label: "Fast" }], default: "fast" },
|
||||||
|
{ key: "count", type: "number" },
|
||||||
|
{ key: "whole", type: "integer", default: 2 },
|
||||||
|
{ key: "enabled", type: "boolean", default: false },
|
||||||
|
{ key: "tags", type: "multiselect", options: [], custom: true },
|
||||||
|
{ key: "external", type: "external", url: "https://example.com/action" },
|
||||||
|
])
|
||||||
|
let state = formSetField(createFormBodyState(form), form, 1)
|
||||||
|
state = formCommitInput(state, form, "1.5")
|
||||||
|
state = formSetField(state, form, 4)
|
||||||
|
state = formSetSelected(state, 0)
|
||||||
|
state = formPick(state, form)
|
||||||
|
state = formCommitInput(state, form, "custom")
|
||||||
|
state = formSetField(state, form, 5)
|
||||||
|
state = formAcknowledge(formSetExternalReady(state, "external"), form)
|
||||||
|
|
||||||
|
const answer = { choice: "fast", count: 1.5, whole: 2, enabled: false, tags: ["custom"], external: true }
|
||||||
|
expect(formAnswer(form, state)).toEqual(answer)
|
||||||
|
expect(formReply({ ...form, location: { directory: "/tmp", workspaceID: "wrk_1" } }, state)).toEqual({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
formID: "frm_1",
|
||||||
|
answer,
|
||||||
|
location: { directory: "/tmp", workspaceID: "wrk_1" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects invalid and deliberately unsupported shapes", () => {
|
||||||
|
const invalid = request([
|
||||||
|
{ key: "required", type: "string", required: true },
|
||||||
|
{ key: "external", type: "external", url: "https://example.com" },
|
||||||
|
])
|
||||||
|
expect(formValidate(invalid, createFormBodyState(invalid))).toContain("Answer required")
|
||||||
|
expect(formUnsupported(request([{ key: "value", type: "string", pattern: "^a" }]))).toContain("Pattern")
|
||||||
|
expect(
|
||||||
|
formUnsupported(
|
||||||
|
request([
|
||||||
|
{ key: "toggle", type: "boolean" },
|
||||||
|
{ key: "value", type: "string", when: [{ key: "toggle", op: "eq", value: true }] },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toContain("Conditional")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
|
||||||
import {
|
import {
|
||||||
createPermissionBodyState,
|
createPermissionBodyState,
|
||||||
permissionAlwaysLines,
|
permissionAlwaysLines,
|
||||||
@@ -9,8 +8,9 @@ import {
|
|||||||
permissionReject,
|
permissionReject,
|
||||||
permissionRun,
|
permissionRun,
|
||||||
} from "../../src/mini/permission.shared"
|
} from "../../src/mini/permission.shared"
|
||||||
|
import type { MiniPermissionRequest } from "../../src/mini/types"
|
||||||
|
|
||||||
function req(input: Partial<PermissionV2Request> = {}): PermissionV2Request {
|
function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest {
|
||||||
return {
|
return {
|
||||||
id: "perm-1",
|
id: "perm-1",
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
@@ -22,23 +22,29 @@ function req(input: Partial<PermissionV2Request> = {}): PermissionV2Request {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function body() {
|
||||||
|
return createPermissionBodyState(req())
|
||||||
|
}
|
||||||
|
|
||||||
describe("run permission shared", () => {
|
describe("run permission shared", () => {
|
||||||
test("replies immediately for allow once", () => {
|
test("replies immediately for allow once", () => {
|
||||||
const out = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "once")
|
const out = permissionRun(body(), "perm-1", "once")
|
||||||
|
|
||||||
expect(out.reply).toEqual({
|
expect(out.reply).toEqual({
|
||||||
|
sessionID: "session-1",
|
||||||
requestID: "perm-1",
|
requestID: "perm-1",
|
||||||
reply: "once",
|
reply: "once",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("requires confirmation for allow always", () => {
|
test("requires confirmation for allow always", () => {
|
||||||
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "always")
|
const next = permissionRun(body(), "perm-1", "always")
|
||||||
expect(next.state.stage).toBe("always")
|
expect(next.state.stage).toBe("always")
|
||||||
expect(next.state.selected).toBe("confirm")
|
expect(next.state.selected).toBe("confirm")
|
||||||
expect(next.reply).toBeUndefined()
|
expect(next.reply).toBeUndefined()
|
||||||
|
|
||||||
expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({
|
expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({
|
||||||
|
sessionID: "session-1",
|
||||||
requestID: "perm-1",
|
requestID: "perm-1",
|
||||||
reply: "always",
|
reply: "always",
|
||||||
})
|
})
|
||||||
@@ -50,11 +56,12 @@ describe("run permission shared", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("builds trimmed reject replies and stage transitions", () => {
|
test("builds trimmed reject replies and stage transitions", () => {
|
||||||
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "reject")
|
const next = permissionRun(body(), "perm-1", "reject")
|
||||||
expect(next.state.stage).toBe("reject")
|
expect(next.state.stage).toBe("reject")
|
||||||
|
|
||||||
const out = permissionReject({ ...next.state, message: " use rg " }, "perm-1")
|
const out = permissionReject({ ...next.state, message: " use rg " }, "perm-1")
|
||||||
expect(out).toEqual({
|
expect(out).toEqual({
|
||||||
|
sessionID: "session-1",
|
||||||
requestID: "perm-1",
|
requestID: "perm-1",
|
||||||
reply: "reject",
|
reply: "reject",
|
||||||
message: "use rg",
|
message: "use rg",
|
||||||
@@ -65,7 +72,7 @@ describe("run permission shared", () => {
|
|||||||
selected: "reject",
|
selected: "reject",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(permissionEscape(createPermissionBodyState("perm-1"))).toMatchObject({
|
expect(permissionEscape(body())).toMatchObject({
|
||||||
stage: "reject",
|
stage: "reject",
|
||||||
selected: "reject",
|
selected: "reject",
|
||||||
})
|
})
|
||||||
@@ -76,15 +83,23 @@ describe("run permission shared", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip("maps supported permission types into display info", () => {
|
test("maps supported permission types into display info", () => {
|
||||||
expect(
|
expect(
|
||||||
permissionInfo(
|
permissionInfo(
|
||||||
req({
|
req({
|
||||||
action: "bash",
|
action: "shell",
|
||||||
metadata: {
|
source: { type: "tool", messageID: "msg-shell", callID: "call-shell" },
|
||||||
input: {
|
tool: {
|
||||||
command: "git status --short",
|
type: "tool",
|
||||||
|
id: "call-shell",
|
||||||
|
name: "shell",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: { command: "git status --short" },
|
||||||
|
structured: {},
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
|
time: { created: 1, ran: 1 },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -93,21 +108,6 @@ describe("run permission shared", () => {
|
|||||||
lines: ["$ git status --short"],
|
lines: ["$ git status --short"],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
|
||||||
permissionInfo(
|
|
||||||
req({
|
|
||||||
action: "task",
|
|
||||||
metadata: {
|
|
||||||
description: "investigate stream",
|
|
||||||
subagent_type: "general",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).toMatchObject({
|
|
||||||
title: "General Task",
|
|
||||||
lines: ["◉ investigate stream"],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
permissionInfo(
|
permissionInfo(
|
||||||
req({
|
req({
|
||||||
@@ -130,6 +130,61 @@ describe("run permission shared", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("prefers canonical request metadata over source tool metadata", () => {
|
||||||
|
expect(
|
||||||
|
permissionInfo(
|
||||||
|
req({
|
||||||
|
action: "websearch",
|
||||||
|
metadata: { provider: "parallel" },
|
||||||
|
source: { type: "tool", messageID: "msg-search", callID: "call-search" },
|
||||||
|
tool: {
|
||||||
|
type: "tool",
|
||||||
|
id: "call-search",
|
||||||
|
name: "websearch",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: { query: "current releases" },
|
||||||
|
structured: { provider: "exa", retained: true },
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
time: { created: 1, ran: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
title: 'Parallel Web Search "current releases"',
|
||||||
|
lines: ["Query: current releases"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses source patch text when an edit has no generated diff", () => {
|
||||||
|
expect(
|
||||||
|
permissionInfo(
|
||||||
|
req({
|
||||||
|
action: "edit",
|
||||||
|
resources: ["src/index.ts"],
|
||||||
|
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
|
||||||
|
tool: {
|
||||||
|
type: "tool",
|
||||||
|
id: "call-edit",
|
||||||
|
name: "edit",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: { patchText: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch" },
|
||||||
|
structured: {},
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
time: { created: 1, ran: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
title: "Edit src/index.ts",
|
||||||
|
diff: undefined,
|
||||||
|
patch: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("formats always-allow copy for wildcard and explicit patterns", () => {
|
test("formats always-allow copy for wildcard and explicit patterns", () => {
|
||||||
expect(permissionAlwaysLines(req({ action: "bash", save: ["*"] }))).toEqual([
|
expect(permissionAlwaysLines(req({ action: "bash", save: ["*"] }))).toEqual([
|
||||||
"This will allow bash until OpenCode is restarted.",
|
"This will allow bash until OpenCode is restarted.",
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { QuestionV2Request } from "@opencode-ai/client/promise"
|
|
||||||
import {
|
|
||||||
createQuestionBodyState,
|
|
||||||
questionConfirm,
|
|
||||||
questionReject,
|
|
||||||
questionSave,
|
|
||||||
questionSelect,
|
|
||||||
questionSetSelected,
|
|
||||||
questionStoreCustom,
|
|
||||||
questionSubmit,
|
|
||||||
questionSync,
|
|
||||||
} from "../../src/mini/question.shared"
|
|
||||||
|
|
||||||
function req(input: Partial<QuestionV2Request> = {}): QuestionV2Request {
|
|
||||||
return {
|
|
||||||
id: "question-1",
|
|
||||||
sessionID: "session-1",
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
question: "Mode?",
|
|
||||||
header: "Mode",
|
|
||||||
options: [{ label: "chunked", description: "Incremental output" }],
|
|
||||||
multiple: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...input,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("run question shared", () => {
|
|
||||||
test("replies immediately for a single-select question", () => {
|
|
||||||
const out = questionSelect(createQuestionBodyState("question-1"), req())
|
|
||||||
|
|
||||||
expect(out.reply).toEqual({
|
|
||||||
requestID: "question-1",
|
|
||||||
answers: [["chunked"]],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("advances multi-question flows and submits from confirm", () => {
|
|
||||||
const ask = req({
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
question: "Mode?",
|
|
||||||
header: "Mode",
|
|
||||||
options: [{ label: "chunked", description: "Incremental output" }],
|
|
||||||
multiple: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Output?",
|
|
||||||
header: "Output",
|
|
||||||
options: [
|
|
||||||
{ label: "yes", description: "Show tool output" },
|
|
||||||
{ label: "no", description: "Hide tool output" },
|
|
||||||
],
|
|
||||||
multiple: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
|
|
||||||
expect(state.tab).toBe(1)
|
|
||||||
|
|
||||||
state = questionSetSelected(state, 1)
|
|
||||||
state = questionSelect(state, ask).state
|
|
||||||
expect(questionConfirm(ask, state)).toBe(true)
|
|
||||||
expect(questionSubmit(ask, state)).toEqual({
|
|
||||||
requestID: "question-1",
|
|
||||||
answers: [["chunked"], ["no"]],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("toggles answers for multiple-choice questions", () => {
|
|
||||||
const ask = req({
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
question: "Tags?",
|
|
||||||
header: "Tags",
|
|
||||||
options: [{ label: "bug", description: "Bug fix" }],
|
|
||||||
multiple: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
|
|
||||||
expect(state.answers).toEqual([["bug"]])
|
|
||||||
|
|
||||||
state = questionSelect(state, ask).state
|
|
||||||
expect(state.answers).toEqual([[]])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("stores and submits custom answers", () => {
|
|
||||||
let state = questionSetSelected(createQuestionBodyState("question-1"), 1)
|
|
||||||
let next = questionSelect(state, req())
|
|
||||||
expect(next.state.editing).toBe(true)
|
|
||||||
|
|
||||||
state = questionStoreCustom(next.state, 0, " custom mode ")
|
|
||||||
next = questionSave(state, req())
|
|
||||||
expect(next.reply).toEqual({
|
|
||||||
requestID: "question-1",
|
|
||||||
answers: [["custom mode"]],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resets state when the request id changes and builds reject payloads", () => {
|
|
||||||
const state = questionSetSelected(createQuestionBodyState("question-1"), 1)
|
|
||||||
|
|
||||||
expect(questionSync(state, "question-1")).toBe(state)
|
|
||||||
expect(questionSync(state, "question-2")).toEqual(createQuestionBodyState("question-2"))
|
|
||||||
expect(questionReject(req())).toEqual({
|
|
||||||
requestID: "question-1",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import type { Resolved } from "../../src/config/v1"
|
import type { Resolved } from "../../src/config"
|
||||||
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
|
import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
|
||||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||||
|
|
||||||
function ok<T>(data: T) {
|
function ok<T>(data: T) {
|
||||||
@@ -66,7 +66,6 @@ function model(id: string, providerID: string, context: number, variants: string
|
|||||||
function config(input?: {
|
function config(input?: {
|
||||||
leader?: string
|
leader?: string
|
||||||
leaderTimeout?: number
|
leaderTimeout?: number
|
||||||
diff_style?: "auto" | "stacked"
|
|
||||||
bindings?: Partial<{
|
bindings?: Partial<{
|
||||||
commandList: string[]
|
commandList: string[]
|
||||||
variantCycle: string[]
|
variantCycle: string[]
|
||||||
@@ -80,7 +79,6 @@ function config(input?: {
|
|||||||
}): Resolved {
|
}): Resolved {
|
||||||
const bind = input?.bindings
|
const bind = input?.bindings
|
||||||
return createTuiResolvedConfig({
|
return createTuiResolvedConfig({
|
||||||
diff_style: input?.diff_style,
|
|
||||||
leader_timeout: input?.leaderTimeout,
|
leader_timeout: input?.leaderTimeout,
|
||||||
keybinds: {
|
keybinds: {
|
||||||
...(input?.leader && { leader: input.leader }),
|
...(input?.leader && { leader: input.leader }),
|
||||||
@@ -119,7 +117,7 @@ describe("run runtime boot", () => {
|
|||||||
const result = await resolveRunTuiConfig(input)
|
const result = await resolveRunTuiConfig(input)
|
||||||
|
|
||||||
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
|
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
|
||||||
expect(result.leader_timeout).toBe(2000)
|
expect(result.leader.timeout).toBe(2000)
|
||||||
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
|
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
|
||||||
expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
|
expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
|
||||||
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
|
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
|
||||||
@@ -134,8 +132,7 @@ describe("run runtime boot", () => {
|
|||||||
const result = await resolveRunTuiConfig(Promise.reject(new Error("boom")))
|
const result = await resolveRunTuiConfig(Promise.reject(new Error("boom")))
|
||||||
|
|
||||||
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
|
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
|
||||||
expect(result.leader_timeout).toBe(2000)
|
expect(result.leader.timeout).toBe(2000)
|
||||||
expect(result.diff_style).toBe("auto")
|
|
||||||
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
|
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
|
||||||
expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t")
|
expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t")
|
||||||
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape")
|
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape")
|
||||||
@@ -152,10 +149,18 @@ describe("run runtime boot", () => {
|
|||||||
expect(result.keybinds.get("leader")).toEqual([])
|
expect(result.keybinds.get("leader")).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reads diff style and falls back to auto", async () => {
|
test("preserves current theme mode, leader, and thinking config", async () => {
|
||||||
await expect(resolveDiffStyle(config({ diff_style: "stacked" }))).resolves.toBe("stacked")
|
const result = await resolveRunTuiConfig(
|
||||||
|
createTuiResolvedConfig({
|
||||||
|
theme: { mode: "light" },
|
||||||
|
leader_timeout: 450,
|
||||||
|
session: { thinking: "hide" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
await expect(resolveDiffStyle(Promise.reject(new Error("boom")))).resolves.toBe("auto")
|
expect(result.theme).toEqual({ mode: "light" })
|
||||||
|
expect(result.leader.timeout).toBe(450)
|
||||||
|
expect(result.session?.thinking).toBe("hide")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads v2 providers and models for model selector data", async () => {
|
test("loads v2 providers and models for model selector data", async () => {
|
||||||
@@ -165,32 +170,16 @@ describe("run runtime boot", () => {
|
|||||||
const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
|
const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
|
||||||
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
|
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
|
||||||
|
|
||||||
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
|
await expect(resolveModelInfo(sdk, { directory: "/workspace" })).resolves.toEqual({
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
id: "openai",
|
id: "openai",
|
||||||
name: "OpenAI",
|
name: "OpenAI",
|
||||||
models: {
|
models: {
|
||||||
"gpt-5": {
|
"gpt-5": {
|
||||||
id: "gpt-5",
|
|
||||||
providerID: "openai",
|
|
||||||
name: "gpt-5",
|
name: "gpt-5",
|
||||||
capabilities: {
|
|
||||||
tools: true,
|
|
||||||
input: ["text"],
|
|
||||||
output: ["text"],
|
|
||||||
},
|
|
||||||
cost: {
|
cost: {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
|
||||||
cache: {
|
|
||||||
read: 0,
|
|
||||||
write: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
context: 128000,
|
|
||||||
output: 8192,
|
|
||||||
},
|
},
|
||||||
status: "active",
|
status: "active",
|
||||||
variants: {
|
variants: {
|
||||||
@@ -201,55 +190,10 @@ describe("run runtime boot", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
variants: ["high", "minimal"],
|
|
||||||
limits: {
|
|
||||||
"openai/gpt-5": 128000,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
expect(providerList).toHaveBeenCalledWith(
|
expect(providerList).toHaveBeenCalledWith({
|
||||||
{
|
location: {
|
||||||
location: {
|
directory: "/workspace",
|
||||||
directory: "/workspace",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads context limits across v2 providers", async () => {
|
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
|
||||||
const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")]
|
|
||||||
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)]
|
|
||||||
spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
|
|
||||||
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
|
|
||||||
|
|
||||||
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
|
|
||||||
providers: [
|
|
||||||
expect.objectContaining({
|
|
||||||
id: "openai",
|
|
||||||
name: "OpenAI",
|
|
||||||
models: expect.objectContaining({
|
|
||||||
"gpt-5": expect.objectContaining({
|
|
||||||
variants: {
|
|
||||||
high: {},
|
|
||||||
minimal: {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
id: "anthropic",
|
|
||||||
name: "Anthropic",
|
|
||||||
models: expect.objectContaining({
|
|
||||||
sonnet: expect.objectContaining({
|
|
||||||
variants: {},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
variants: ["high", "minimal"],
|
|
||||||
limits: {
|
|
||||||
"openai/gpt-5": 128000,
|
|
||||||
"anthropic/sonnet": 200000,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,41 +1,9 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { runMiniFrontend } from "../../src/mini"
|
import { runInteractiveDeferredMode } from "../../src/mini/runtime"
|
||||||
import { runInteractiveDeferredMode, runInteractiveMode } from "../../src/mini/runtime"
|
import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
|
||||||
import type { FooterApi, FooterEvent, MiniHost, RunProvider } from "../../src/mini/types"
|
import type { FooterApi, FooterEvent, MiniHost } from "../../src/mini/types"
|
||||||
|
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||||
const provider: RunProvider = {
|
|
||||||
id: "openai",
|
|
||||||
name: "OpenAI",
|
|
||||||
models: {
|
|
||||||
"gpt-5": {
|
|
||||||
id: "gpt-5",
|
|
||||||
providerID: "openai",
|
|
||||||
name: "Little Frank",
|
|
||||||
capabilities: {
|
|
||||||
tools: true,
|
|
||||||
input: ["text"],
|
|
||||||
output: ["text"],
|
|
||||||
},
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cache: {
|
|
||||||
read: 0,
|
|
||||||
write: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
context: 128000,
|
|
||||||
output: 8192,
|
|
||||||
},
|
|
||||||
status: "active",
|
|
||||||
variants: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const transportProviders: RunProvider[][] = []
|
|
||||||
|
|
||||||
function defer<T>() {
|
function defer<T>() {
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
@@ -51,18 +19,18 @@ function ok<T>(data: T) {
|
|||||||
|
|
||||||
function host(): MiniHost {
|
function host(): MiniHost {
|
||||||
return {
|
return {
|
||||||
terminal: { stdin: process.stdin, cleanup() {} },
|
terminal: { stdin: process.stdin },
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
stdout: { write() {} },
|
stdout: { write() {} },
|
||||||
files: { readText: async () => "" },
|
files: { readText: async () => "" },
|
||||||
editor: { open: async () => undefined },
|
editor: { open: async () => undefined },
|
||||||
paths: { home: "/home/test", state: "/tmp/state", log: "/tmp/log" },
|
paths: { home: "/home/test" },
|
||||||
signals: {
|
signals: {
|
||||||
sigint: { subscribe: () => () => {} },
|
sigint: { subscribe: () => () => {} },
|
||||||
sigusr2: { subscribe: () => () => {} },
|
sigusr2: { subscribe: () => () => {} },
|
||||||
},
|
},
|
||||||
startup: { showTiming: false, now: () => 0 },
|
startup: { showTiming: false, now: () => 0 },
|
||||||
diagnostics: { pid: 1, cwd: "/tmp", argv: [] },
|
diagnostics: {},
|
||||||
preferences: {
|
preferences: {
|
||||||
resolveVariant: async () => undefined,
|
resolveVariant: async () => undefined,
|
||||||
saveVariant: async () => {},
|
saveVariant: async () => {},
|
||||||
@@ -123,36 +91,101 @@ function footer(events: FooterEvent[] = []): FooterApi {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
transportProviders.length = 0
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("run interactive runtime", () => {
|
describe("run interactive runtime", () => {
|
||||||
test("leaves host terminal cleanup to the caller when startup fails before renderer creation", async () => {
|
test("routes form responses to their owners with global location and local settlement", async () => {
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
const inputHost = host()
|
const api = footer()
|
||||||
let cleaned = 0
|
const streamStarted = defer<void>()
|
||||||
inputHost.terminal.cleanup = () => {
|
let lifecycle!: LifecycleInput
|
||||||
cleaned++
|
const settled: Array<{ sessionID: string; formID: string }> = []
|
||||||
}
|
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
inputHost.preferences.resolveVariant = async () => {
|
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
throw new Error("preference failed")
|
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
}
|
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
|
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
|
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||||
|
const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
|
||||||
|
|
||||||
await expect(
|
const task = runInteractiveDeferredMode(
|
||||||
runMiniFrontend({
|
{
|
||||||
host: inputHost,
|
host: host(),
|
||||||
sdk,
|
sdk,
|
||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
resolveAgent: async () => "build",
|
target: async () => ({
|
||||||
session: async () => ({ id: "ses-never" }),
|
sessionID: "ses_root",
|
||||||
|
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "test", modelID: "model" },
|
||||||
|
variant: undefined,
|
||||||
|
resume: false,
|
||||||
|
}),
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: undefined,
|
model: { providerID: "test", modelID: "model" },
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
files: [],
|
files: [],
|
||||||
thinking: false,
|
thinking: false,
|
||||||
}),
|
},
|
||||||
).rejects.toThrow("preference failed")
|
{
|
||||||
expect(cleaned).toBe(0)
|
createRuntimeLifecycle: async (input) => {
|
||||||
|
lifecycle = input
|
||||||
|
return {
|
||||||
|
footer: api,
|
||||||
|
onResize: () => () => {},
|
||||||
|
refreshTheme: () => {},
|
||||||
|
resetForReplay: () => Promise.resolve(),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
streamTransport: Promise.resolve({
|
||||||
|
createSessionTransport: async () => {
|
||||||
|
streamStarted.resolve()
|
||||||
|
return {
|
||||||
|
runPromptTurn: async () => {},
|
||||||
|
interruptActiveTurn: async () => {},
|
||||||
|
selectSubagent: () => {},
|
||||||
|
settleForm: (sessionID: string, formID: string) => settled.push({ sessionID, formID }),
|
||||||
|
replayOnResize: async () => false,
|
||||||
|
close: async () => {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await streamStarted.promise
|
||||||
|
|
||||||
|
await lifecycle.onFormReply({
|
||||||
|
sessionID: "global",
|
||||||
|
formID: "frm_global",
|
||||||
|
answer: { value: "yes" },
|
||||||
|
location: { directory: "/remote work", workspaceID: "wrk_1" },
|
||||||
|
})
|
||||||
|
expect(reply).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
sessionID: "global",
|
||||||
|
formID: "frm_global",
|
||||||
|
answer: { value: "yes" },
|
||||||
|
location: { directory: "/remote work", workspaceID: "wrk_1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"x-opencode-directory": "%2Fremote%20work",
|
||||||
|
"x-opencode-workspace": "wrk_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(settled).toEqual([{ sessionID: "global", formID: "frm_global" }])
|
||||||
|
|
||||||
|
reply.mockImplementationOnce(() => Promise.reject({ _tag: "FormInvalidAnswerError", message: "Invalid answer" }))
|
||||||
|
await expect(
|
||||||
|
lifecycle.onFormReply({ sessionID: "ses_child", formID: "frm_invalid", answer: { value: 3 } }),
|
||||||
|
).rejects.toEqual({ _tag: "FormInvalidAnswerError", message: "Invalid answer" })
|
||||||
|
expect(settled.some((item) => item.formID === "frm_invalid")).toBe(false)
|
||||||
|
|
||||||
|
api.close()
|
||||||
|
await task
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves the deferred session only after first paint", async () => {
|
test("resolves the deferred session only after first paint", async () => {
|
||||||
@@ -174,11 +207,18 @@ describe("run interactive runtime", () => {
|
|||||||
host: host(),
|
host: host(),
|
||||||
sdk,
|
sdk,
|
||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
resolveAgent: async () => "build",
|
target: async () => {
|
||||||
session: async () => {
|
|
||||||
resolved++
|
resolved++
|
||||||
api.close()
|
api.close()
|
||||||
return { id: "ses-deferred", title: "Deferred" }
|
return {
|
||||||
|
sessionID: "ses-deferred",
|
||||||
|
sessionTitle: "Deferred",
|
||||||
|
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
variant: undefined,
|
||||||
|
resume: false,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { providerID: "openai", modelID: "gpt-5" },
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
@@ -277,8 +317,15 @@ describe("run interactive runtime", () => {
|
|||||||
host: host(),
|
host: host(),
|
||||||
sdk,
|
sdk,
|
||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
resolveAgent: async () => "build",
|
target: async () => ({
|
||||||
session: async () => ({ id: "ses-resume", title: "Resume", resume: true }),
|
sessionID: "ses-resume",
|
||||||
|
sessionTitle: "Resume",
|
||||||
|
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||||
|
agent: "review",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
variant: "high",
|
||||||
|
resume: true,
|
||||||
|
}),
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: undefined,
|
model: undefined,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
@@ -308,6 +355,7 @@ describe("run interactive runtime", () => {
|
|||||||
type: "history",
|
type: "history",
|
||||||
history: [{ text: "previous prompt", parts: [] }],
|
history: [{ text: "previous prompt", parts: [] }],
|
||||||
})
|
})
|
||||||
|
expect(events).toContainEqual({ type: "agent", agent: "review" })
|
||||||
expect(events).toContainEqual({
|
expect(events).toContainEqual({
|
||||||
type: "model",
|
type: "model",
|
||||||
model: "Little Frank · OpenAI · high",
|
model: "Little Frank · OpenAI · high",
|
||||||
@@ -315,294 +363,56 @@ describe("run interactive runtime", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("waits for provider metadata before eager replay transport bootstrap", async () => {
|
test("aborts deferred resume history on close and uses the cached exit title", async () => {
|
||||||
const providersStarted = defer<void>()
|
|
||||||
const providers = defer<void>()
|
|
||||||
const lifecycleModels: unknown[] = []
|
|
||||||
|
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
spyOn(sdk.provider, "list").mockImplementation(async () => {
|
|
||||||
providersStarted.resolve()
|
|
||||||
await providers.promise
|
|
||||||
return ok({
|
|
||||||
location: {
|
|
||||||
directory: "/tmp",
|
|
||||||
},
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "openai",
|
|
||||||
name: "OpenAI",
|
|
||||||
api: {
|
|
||||||
type: "native",
|
|
||||||
settings: {},
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
headers: {},
|
|
||||||
body: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}) as never
|
|
||||||
})
|
|
||||||
spyOn(sdk.model, "list").mockImplementation(
|
|
||||||
() =>
|
|
||||||
ok({
|
|
||||||
location: {
|
|
||||||
directory: "/tmp",
|
|
||||||
},
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "gpt-5",
|
|
||||||
providerID: "openai",
|
|
||||||
name: "Little Frank",
|
|
||||||
api: {
|
|
||||||
id: "openai",
|
|
||||||
type: "native",
|
|
||||||
settings: {},
|
|
||||||
},
|
|
||||||
capabilities: {
|
|
||||||
tools: true,
|
|
||||||
input: ["text"],
|
|
||||||
output: ["text"],
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
headers: {},
|
|
||||||
body: {},
|
|
||||||
},
|
|
||||||
variants: [],
|
|
||||||
time: {
|
|
||||||
released: 1,
|
|
||||||
},
|
|
||||||
cost: [
|
|
||||||
{
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cache: {
|
|
||||||
read: 0,
|
|
||||||
write: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: "active",
|
|
||||||
enabled: true,
|
|
||||||
limit: {
|
|
||||||
context: 128000,
|
|
||||||
output: 8192,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}) as never,
|
|
||||||
)
|
|
||||||
spyOn(sdk.message, "list").mockImplementation(() =>
|
|
||||||
ok({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "msg-user-1",
|
|
||||||
type: "user",
|
|
||||||
text: "hello",
|
|
||||||
time: {
|
|
||||||
created: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
cursor: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
spyOn(sdk.session, "get").mockImplementation(
|
|
||||||
() =>
|
|
||||||
ok({
|
|
||||||
id: "ses-1",
|
|
||||||
projectID: "pro-1",
|
|
||||||
title: "Session",
|
|
||||||
cost: 0,
|
|
||||||
tokens: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
reasoning: 0,
|
|
||||||
cache: {
|
|
||||||
read: 0,
|
|
||||||
write: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
time: {
|
|
||||||
created: 1,
|
|
||||||
updated: 1,
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
directory: "/tmp",
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
providerID: "openai",
|
|
||||||
id: "gpt-5",
|
|
||||||
},
|
|
||||||
}) as never,
|
|
||||||
)
|
|
||||||
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
|
|
||||||
const task = runInteractiveMode(
|
|
||||||
{
|
|
||||||
host: host(),
|
|
||||||
sdk,
|
|
||||||
directory: "/tmp",
|
|
||||||
sessionID: "ses-1",
|
|
||||||
sessionTitle: "Session",
|
|
||||||
resume: true,
|
|
||||||
replay: true,
|
|
||||||
replayLimit: 100,
|
|
||||||
agent: "build",
|
|
||||||
model: undefined,
|
|
||||||
variant: undefined,
|
|
||||||
files: [],
|
|
||||||
thinking: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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?.() ?? [])
|
|
||||||
setTimeout(() => {
|
|
||||||
input.footer.close()
|
|
||||||
}, 0)
|
|
||||||
return {
|
|
||||||
runPromptTurn: async () => {},
|
|
||||||
interruptActiveTurn: async () => {},
|
|
||||||
selectSubagent: () => {},
|
|
||||||
replayOnResize: async () => false,
|
|
||||||
close: async () => {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await providersStarted.promise
|
|
||||||
|
|
||||||
expect(transportProviders).toEqual([])
|
|
||||||
|
|
||||||
providers.resolve()
|
|
||||||
|
|
||||||
await task
|
|
||||||
|
|
||||||
expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }])
|
|
||||||
expect(transportProviders).toEqual([[provider]])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("defers catalog-selected model resolution until after first paint", async () => {
|
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
|
||||||
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.model, "default").mockImplementation(async () => {
|
|
||||||
defaultRequested = true
|
|
||||||
defaultStarted.resolve()
|
|
||||||
await releaseDefault.promise
|
|
||||||
return ok({
|
|
||||||
location: { directory: "/tmp" },
|
|
||||||
data: { id: "catalog-default-test-model", providerID: "openai" },
|
|
||||||
}) as never
|
|
||||||
})
|
|
||||||
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
|
|
||||||
const task = runInteractiveMode(
|
|
||||||
{
|
|
||||||
host: host(),
|
|
||||||
sdk,
|
|
||||||
directory: "/tmp",
|
|
||||||
sessionID: "ses-fresh",
|
|
||||||
resume: false,
|
|
||||||
agent: "build",
|
|
||||||
model: undefined,
|
|
||||||
variant: undefined,
|
|
||||||
files: [],
|
|
||||||
thinking: 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: "catalog-default-test-model · openai",
|
|
||||||
selection: { providerID: "openai", modelID: "catalog-default-test-model" },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not start deferred work after the footer closes", async () => {
|
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
|
||||||
const lifecycleStarted = defer<void>()
|
|
||||||
const painted = defer<void>()
|
const painted = defer<void>()
|
||||||
|
const readsStarted = defer<void>()
|
||||||
const api = footer()
|
const api = footer()
|
||||||
api.idle = () => painted.promise
|
api.idle = () => painted.promise
|
||||||
const defaultModel = spyOn(sdk.model, "default")
|
let reads = 0
|
||||||
|
let aborted = 0
|
||||||
|
let closedTitle: string | undefined
|
||||||
|
const pending = (signal: AbortSignal | undefined) =>
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
reads++
|
||||||
|
if (reads === 2) readsStarted.resolve()
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
aborted++
|
||||||
|
reject(new Error("resume history aborted"))
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const messages = spyOn(sdk.message, "list").mockImplementation(
|
||||||
|
(_request, options) => pending(options?.signal) as never,
|
||||||
|
)
|
||||||
|
const session = spyOn(sdk.session, "get").mockImplementation(
|
||||||
|
(_request, options) => pending(options?.signal) as never,
|
||||||
|
)
|
||||||
|
const response = { location: { directory: "/tmp" }, data: [] }
|
||||||
|
spyOn(sdk.provider, "list").mockResolvedValue(response as never)
|
||||||
|
spyOn(sdk.model, "list").mockResolvedValue(response as never)
|
||||||
|
spyOn(sdk.agent, "list").mockResolvedValue(response as never)
|
||||||
|
spyOn(sdk.reference, "list").mockResolvedValue(response as never)
|
||||||
|
spyOn(sdk.command, "list").mockResolvedValue(response as never)
|
||||||
|
spyOn(sdk.skill, "list").mockResolvedValue(response as never)
|
||||||
|
|
||||||
const task = runInteractiveMode(
|
const task = runInteractiveDeferredMode(
|
||||||
{
|
{
|
||||||
host: host(),
|
host: host(),
|
||||||
sdk,
|
sdk,
|
||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
sessionID: "ses-closed",
|
target: async () => ({
|
||||||
resume: false,
|
sessionID: "ses-resume-abort",
|
||||||
|
sessionTitle: "Cached title",
|
||||||
|
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||||
|
agent: "build",
|
||||||
|
model: undefined,
|
||||||
|
variant: undefined,
|
||||||
|
resume: true,
|
||||||
|
}),
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: undefined,
|
model: undefined,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
@@ -610,168 +420,89 @@ describe("run interactive runtime", () => {
|
|||||||
thinking: false,
|
thinking: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
createRuntimeLifecycle: async () => {
|
createRuntimeLifecycle: async () => ({
|
||||||
lifecycleStarted.resolve()
|
footer: api,
|
||||||
return {
|
onResize: () => () => {},
|
||||||
footer: api,
|
refreshTheme: () => {},
|
||||||
onResize: () => () => {},
|
resetForReplay: () => Promise.resolve(),
|
||||||
refreshTheme: () => {},
|
close: async (input) => {
|
||||||
resetForReplay: () => Promise.resolve(),
|
closedTitle = input.sessionTitle
|
||||||
close: () => Promise.resolve(),
|
},
|
||||||
}
|
}),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await lifecycleStarted.promise
|
painted.resolve()
|
||||||
|
await readsStarted.promise
|
||||||
api.close()
|
api.close()
|
||||||
painted.resolve()
|
|
||||||
await task
|
await task
|
||||||
|
|
||||||
expect(defaultModel).not.toHaveBeenCalled()
|
expect(aborted).toBe(2)
|
||||||
|
expect(messages).toHaveBeenCalledWith(
|
||||||
|
{ sessionID: "ses-resume-abort", limit: 200, order: "desc" },
|
||||||
|
{ signal: expect.any(AbortSignal) },
|
||||||
|
)
|
||||||
|
expect(session).toHaveBeenCalledTimes(1)
|
||||||
|
expect(session).toHaveBeenCalledWith({ sessionID: "ses-resume-abort" }, { signal: expect.any(AbortSignal) })
|
||||||
|
expect(closedTitle).toBe("Cached title")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("searches files through the V2 file API", async () => {
|
test("adopts the deferred target location for catalogs, files, and runtime placement", async () => {
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
|
const lifecycleStarted = defer<void>()
|
||||||
|
const painted = defer<void>()
|
||||||
const api = footer()
|
const api = footer()
|
||||||
const find = spyOn(sdk.file, "find").mockImplementation(
|
api.idle = () => painted.promise
|
||||||
() =>
|
let targets = 0
|
||||||
ok({
|
let getDirectory: (() => string) | undefined
|
||||||
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
|
let findFiles: ((query: string) => Promise<string[]>) | undefined
|
||||||
data: [{ path: "src/index.ts", type: "file" }],
|
let transportLocation: unknown
|
||||||
}) as never,
|
const response = { location: { directory: "/session", workspaceID: "work-1" }, data: [] }
|
||||||
)
|
const providerList = spyOn(sdk.provider, "list").mockResolvedValue(response as never)
|
||||||
|
const modelList = spyOn(sdk.model, "list").mockResolvedValue(response as never)
|
||||||
|
const agentList = spyOn(sdk.agent, "list").mockResolvedValue(response as never)
|
||||||
|
const referenceList = spyOn(sdk.reference, "list").mockResolvedValue(response as never)
|
||||||
|
const commandList = spyOn(sdk.command, "list").mockResolvedValue(response as never)
|
||||||
|
const skillList = spyOn(sdk.skill, "list").mockResolvedValue(response as never)
|
||||||
|
const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
|
||||||
|
location: {
|
||||||
|
directory: "/session",
|
||||||
|
workspaceID: "work-1",
|
||||||
|
project: { id: "pro-1", directory: "/session" },
|
||||||
|
},
|
||||||
|
data: [{ path: "src/index.ts", type: "file" }],
|
||||||
|
} as never)
|
||||||
|
|
||||||
await runInteractiveMode(
|
const task = runInteractiveDeferredMode(
|
||||||
{
|
{
|
||||||
host: host(),
|
host: host(),
|
||||||
sdk,
|
sdk,
|
||||||
directory: "/tmp",
|
directory: "/launch",
|
||||||
sessionID: "ses-files",
|
target: async () => {
|
||||||
resume: false,
|
targets++
|
||||||
agent: "build",
|
return {
|
||||||
|
sessionID: "ses-target",
|
||||||
|
location: {
|
||||||
|
directory: "/session",
|
||||||
|
workspaceID: "work-1",
|
||||||
|
project: { id: "location-project", directory: "/session" },
|
||||||
|
},
|
||||||
|
agent: "review",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
variant: "high",
|
||||||
|
resume: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
agent: undefined,
|
||||||
model: undefined,
|
model: undefined,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
files: [],
|
files: [],
|
||||||
thinking: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
createRuntimeLifecycle: async (input) => {
|
createRuntimeLifecycle: async (input) => {
|
||||||
await expect(input.findFiles("index")).resolves.toEqual(["src/index.ts"])
|
getDirectory = input.getDirectory
|
||||||
api.close()
|
findFiles = input.findFiles
|
||||||
return {
|
lifecycleStarted.resolve()
|
||||||
footer: api,
|
|
||||||
onResize: () => () => {},
|
|
||||||
refreshTheme: () => {},
|
|
||||||
resetForReplay: () => Promise.resolve(),
|
|
||||||
close: () => Promise.resolve(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(find).toHaveBeenCalledWith({ query: "index", type: "file", location: { directory: "/tmp" } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test.skip("retains last-known-good state across failed coalesced refreshes and retries later", async () => {
|
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
|
||||||
const refreshGate = defer<void>()
|
|
||||||
let providerCalls = 0
|
|
||||||
let modelCalls = 0
|
|
||||||
let agentCalls = 0
|
|
||||||
let referenceCalls = 0
|
|
||||||
const events: FooterEvent[] = []
|
|
||||||
const api = footer(events)
|
|
||||||
spyOn(sdk.provider, "list").mockImplementation(async () => {
|
|
||||||
providerCalls++
|
|
||||||
if (providerCalls === 2) {
|
|
||||||
await refreshGate.promise
|
|
||||||
throw new Error("provider refresh failed")
|
|
||||||
}
|
|
||||||
return ok({
|
|
||||||
location: { directory: "/tmp" },
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "openai",
|
|
||||||
name: providerCalls >= 3 ? "OpenAI refreshed" : "OpenAI",
|
|
||||||
api: { type: "native", settings: {} },
|
|
||||||
request: { headers: {}, body: {} },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}) as never
|
|
||||||
})
|
|
||||||
spyOn(sdk.model, "list").mockImplementation(() => {
|
|
||||||
modelCalls++
|
|
||||||
return ok({
|
|
||||||
location: { directory: "/tmp" },
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "gpt-5",
|
|
||||||
providerID: "openai",
|
|
||||||
name: "Little Frank",
|
|
||||||
api: { id: "openai", type: "native", settings: {} },
|
|
||||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
|
||||||
request: { headers: {}, body: {} },
|
|
||||||
variants:
|
|
||||||
modelCalls >= 4 ? [] : [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }],
|
|
||||||
time: { released: 1 },
|
|
||||||
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
|
|
||||||
status: "active",
|
|
||||||
enabled: true,
|
|
||||||
limit: { context: modelCalls >= 3 ? 256000 : 128000, output: 8192 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}) as never
|
|
||||||
})
|
|
||||||
spyOn(sdk.agent, "list").mockImplementation(async () => {
|
|
||||||
agentCalls++
|
|
||||||
if (agentCalls === 2) throw new Error("agent refresh failed")
|
|
||||||
return ok({
|
|
||||||
location: { directory: "/tmp" },
|
|
||||||
data: [{ id: "build", description: agentCalls >= 3 ? "Refreshed agent" : "Agent", mode: "primary" }],
|
|
||||||
}) as never
|
|
||||||
})
|
|
||||||
spyOn(sdk.reference, "list").mockImplementation(() => {
|
|
||||||
referenceCalls++
|
|
||||||
return ok({
|
|
||||||
location: { directory: "/tmp" },
|
|
||||||
data: [
|
|
||||||
{ name: "effect", path: "/effect", description: referenceCalls >= 3 ? "Refreshed reference" : "Reference" },
|
|
||||||
],
|
|
||||||
}) as never
|
|
||||||
})
|
|
||||||
spyOn(sdk.command, "list").mockImplementation(
|
|
||||||
() => ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never,
|
|
||||||
)
|
|
||||||
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
|
||||||
let finalProviders: RunProvider[] = []
|
|
||||||
let finalLimits: Record<string, number> = {}
|
|
||||||
let retainedProviders: RunProvider[] = []
|
|
||||||
let retainedLimits: Record<string, number> = {}
|
|
||||||
let retainedCatalog: FooterEvent | undefined
|
|
||||||
let selectedDefault: unknown
|
|
||||||
let selectDefault: (() => unknown) | undefined
|
|
||||||
let selectVariant: ((variant: string | undefined) => unknown) | undefined
|
|
||||||
let defaultRefreshVariants: FooterEvent | undefined
|
|
||||||
|
|
||||||
await runInteractiveMode(
|
|
||||||
{
|
|
||||||
host: host(),
|
|
||||||
sdk,
|
|
||||||
directory: "/tmp",
|
|
||||||
sessionID: "ses-1",
|
|
||||||
sessionTitle: "Session",
|
|
||||||
resume: false,
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "openai", modelID: "gpt-5" },
|
|
||||||
variant: "low",
|
|
||||||
files: [],
|
|
||||||
thinking: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
createRuntimeLifecycle: async (input) => {
|
|
||||||
selectDefault = () => input.onVariantSelect?.(undefined)
|
|
||||||
selectVariant = (variant) => input.onVariantSelect?.(variant)
|
|
||||||
return {
|
return {
|
||||||
footer: api,
|
footer: api,
|
||||||
onResize: () => () => {},
|
onResize: () => () => {},
|
||||||
@@ -782,33 +513,8 @@ describe("run interactive runtime", () => {
|
|||||||
},
|
},
|
||||||
streamTransport: Promise.resolve({
|
streamTransport: Promise.resolve({
|
||||||
createSessionTransport: async (input) => {
|
createSessionTransport: async (input) => {
|
||||||
while (
|
transportLocation = input.location
|
||||||
!events.some(
|
await findFiles?.("index")
|
||||||
(event) => event.type === "variants" && event.variants.includes("low") && event.current === "low",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await Bun.sleep(0)
|
|
||||||
selectedDefault = await Promise.resolve(selectDefault?.())
|
|
||||||
input.onCatalogRefresh?.()
|
|
||||||
input.onCatalogRefresh?.()
|
|
||||||
input.onCatalogRefresh?.()
|
|
||||||
while (providerCalls < 2) await Bun.sleep(0)
|
|
||||||
refreshGate.resolve()
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
retainedProviders = input.providers?.() ?? []
|
|
||||||
retainedLimits = input.limits()
|
|
||||||
retainedCatalog = events.filter((event) => event.type === "catalog").at(-1)
|
|
||||||
input.onCatalogRefresh?.()
|
|
||||||
input.onCatalogRefresh?.()
|
|
||||||
while (providerCalls < 3 || modelCalls < 3 || agentCalls < 3) await Bun.sleep(0)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
defaultRefreshVariants = events.filter((event) => event.type === "variants").at(-1)
|
|
||||||
await Promise.resolve(selectVariant?.("high"))
|
|
||||||
input.onCatalogRefresh?.()
|
|
||||||
while (providerCalls < 4 || modelCalls < 4) await Bun.sleep(0)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
finalProviders = input.providers?.() ?? []
|
|
||||||
finalLimits = input.limits()
|
|
||||||
setTimeout(() => input.footer.close(), 0)
|
setTimeout(() => input.footer.close(), 0)
|
||||||
return {
|
return {
|
||||||
runPromptTurn: async () => {},
|
runPromptTurn: async () => {},
|
||||||
@@ -818,33 +524,26 @@ describe("run interactive runtime", () => {
|
|||||||
close: async () => {},
|
close: async () => {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
formatUnknownError: (error: unknown) => String(error),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(providerCalls).toBe(4)
|
await lifecycleStarted.promise
|
||||||
expect(modelCalls).toBe(4)
|
expect(targets).toBe(0)
|
||||||
expect(retainedProviders[0]?.name).toBe("OpenAI")
|
expect(getDirectory?.()).toBe("/launch")
|
||||||
expect(retainedProviders[0]?.models["gpt-5"]?.variants).toEqual({ low: {} })
|
painted.resolve()
|
||||||
expect(retainedLimits["openai/gpt-5"]).toBe(128000)
|
await task
|
||||||
expect(retainedCatalog).toMatchObject({
|
|
||||||
agents: [{ name: "build", description: "Agent" }],
|
const query = { location: { directory: "/session", workspace: "work-1" } }
|
||||||
references: [{ name: "effect", description: "Reference" }],
|
expect(getDirectory?.()).toBe("/session")
|
||||||
})
|
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
|
||||||
expect(selectedDefault).toMatchObject({ variant: undefined })
|
expect(providerList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(defaultRefreshVariants).toMatchObject({ variants: ["high"], current: undefined })
|
expect(modelList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(finalProviders[0]?.name).toBe("OpenAI refreshed")
|
expect(agentList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(finalProviders[0]?.models["gpt-5"]?.variants).toEqual({})
|
expect(referenceList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(finalLimits["openai/gpt-5"]).toBe(256000)
|
expect(commandList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(events.filter((event) => event.type === "variants").at(-1)).toMatchObject({
|
expect(skillList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
variants: [],
|
expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
|
||||||
current: undefined,
|
|
||||||
})
|
|
||||||
expect(events.filter((event) => event.type === "catalog").at(-1)).toMatchObject({
|
|
||||||
agents: [{ name: "build", description: "Refreshed agent" }],
|
|
||||||
references: [{ name: "effect", description: "Refreshed reference" }],
|
|
||||||
commands: [{ name: "check", description: "Check" }],
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { afterEach, expect, test } from "bun:test"
|
import { afterEach, expect, test } from "bun:test"
|
||||||
|
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||||
import { RGBA, SyntaxStyle } from "@opentui/core"
|
import { RGBA, SyntaxStyle } from "@opentui/core"
|
||||||
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
||||||
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
|
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
|
||||||
|
import { entryGroupKey } from "../../src/mini/scrollback.writer"
|
||||||
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
||||||
import type { MiniToolPart, StreamCommit } from "../../src/mini/types"
|
import type { StreamCommit } from "../../src/mini/types"
|
||||||
|
|
||||||
type ClaimedCommit = {
|
type ClaimedCommit = {
|
||||||
snapshot: {
|
snapshot: {
|
||||||
@@ -218,16 +220,19 @@ function error(text: string): StreamCommit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolPart(tool: string, state: Record<string, unknown>, id: string, messageID: string): MiniToolPart {
|
function toolPart(name: string, state: SessionMessageAssistantTool["state"], id: string): SessionMessageAssistantTool {
|
||||||
return {
|
return {
|
||||||
id,
|
|
||||||
sessionID: "session-1",
|
|
||||||
messageID,
|
|
||||||
type: "tool",
|
type: "tool",
|
||||||
callID: `call-${id}`,
|
id,
|
||||||
tool,
|
name,
|
||||||
state,
|
state,
|
||||||
} as MiniToolPart
|
time:
|
||||||
|
state.status === "streaming"
|
||||||
|
? { created: 1 }
|
||||||
|
: state.status === "completed" || state.status === "error"
|
||||||
|
? { created: 1, ran: 1, completed: 2 }
|
||||||
|
: { created: 1, ran: 1 },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolCommit(input: {
|
function toolCommit(input: {
|
||||||
@@ -235,7 +240,7 @@ function toolCommit(input: {
|
|||||||
phase: StreamCommit["phase"]
|
phase: StreamCommit["phase"]
|
||||||
toolState?: StreamCommit["toolState"]
|
toolState?: StreamCommit["toolState"]
|
||||||
text?: string
|
text?: string
|
||||||
state?: Record<string, unknown>
|
state?: SessionMessageAssistantTool["state"]
|
||||||
id?: string
|
id?: string
|
||||||
messageID?: string
|
messageID?: string
|
||||||
}): StreamCommit {
|
}): StreamCommit {
|
||||||
@@ -251,10 +256,23 @@ function toolCommit(input: {
|
|||||||
messageID,
|
messageID,
|
||||||
tool: input.tool,
|
tool: input.tool,
|
||||||
...(input.toolState ? { toolState: input.toolState } : {}),
|
...(input.toolState ? { toolState: input.toolState } : {}),
|
||||||
...(input.state ? { part: toolPart(input.tool, input.state, id, messageID) } : {}),
|
...(input.state ? { part: toolPart(input.tool, input.state, id) } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("scopes repeated tool part IDs to their assistant messages", () => {
|
||||||
|
const first = toolCommit({
|
||||||
|
tool: "read",
|
||||||
|
phase: "start",
|
||||||
|
id: "call-repeated",
|
||||||
|
messageID: "msg-one",
|
||||||
|
toolState: "running",
|
||||||
|
})
|
||||||
|
const second = { ...first, messageID: "msg-two" }
|
||||||
|
|
||||||
|
expect(entryGroupKey(first)).not.toBe(entryGroupKey(second))
|
||||||
|
})
|
||||||
|
|
||||||
test("finalizes markdown tables for streamed and coalesced input", async () => {
|
test("finalizes markdown tables for streamed and coalesced input", async () => {
|
||||||
const text =
|
const text =
|
||||||
"| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
|
"| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
|
||||||
@@ -342,7 +360,8 @@ test("renders question summaries without boilerplate footer copy", async () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
time: { start: 1 },
|
structured: {},
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
final: toolCommit({
|
final: toolCommit({
|
||||||
@@ -361,10 +380,10 @@ test("renders question summaries without boilerplate footer copy", async () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
metadata: {
|
structured: {
|
||||||
answers: [["Bug fix"]],
|
answers: [["Bug fix"]],
|
||||||
},
|
},
|
||||||
time: { start: 1, end: 2100 },
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -436,7 +455,8 @@ test("inserts spacers for new visible groups", async () => {
|
|||||||
input: {
|
input: {
|
||||||
pattern: "**/run.ts",
|
pattern: "**/run.ts",
|
||||||
},
|
},
|
||||||
time: { start: 1 },
|
structured: {},
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -526,13 +546,13 @@ test.skipIf(process.platform === "win32")(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
test.skip("coalesces same-line tool progress into one snapshot", async () => {
|
test("coalesces same-line tool progress into one snapshot", async () => {
|
||||||
const out = await setup()
|
const out = await setup()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "abc" }))
|
await out.scrollback.append(toolCommit({ tool: "shell", phase: "progress", text: "abc" }))
|
||||||
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "def" }))
|
await out.scrollback.append(toolCommit({ tool: "shell", phase: "progress", text: "def" }))
|
||||||
await out.scrollback.append(toolCommit({ tool: "bash", phase: "final", text: "", toolState: "completed" }))
|
await out.scrollback.append(toolCommit({ tool: "shell", phase: "final", text: "", toolState: "completed" }))
|
||||||
|
|
||||||
const commits = claim(out.renderer)
|
const commits = claim(out.renderer)
|
||||||
try {
|
try {
|
||||||
@@ -546,102 +566,7 @@ test.skip("coalesces same-line tool progress into one snapshot", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip("omits the current directory from bash titles", async () => {
|
test("does not double-space before completed shell output when inline tool headers intervene", async () => {
|
||||||
const out = await setup()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "bash",
|
|
||||||
phase: "start",
|
|
||||||
toolState: "running",
|
|
||||||
state: {
|
|
||||||
status: "running",
|
|
||||||
input: {
|
|
||||||
command: "pwd",
|
|
||||||
workdir: process.cwd(),
|
|
||||||
},
|
|
||||||
time: { start: 1 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const commits = claim(out.renderer)
|
|
||||||
try {
|
|
||||||
expect(render(commits)).toContain("$ pwd")
|
|
||||||
expect(render(commits)).not.toContain("Running in .")
|
|
||||||
} finally {
|
|
||||||
destroy(commits)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
out.scrollback.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test.skip("renders completed bash output with one blank line after the command and before the next group", async () => {
|
|
||||||
const out = await setup()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lines: string[] = []
|
|
||||||
const take = () => {
|
|
||||||
const commits = claim(out.renderer)
|
|
||||||
try {
|
|
||||||
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
|
|
||||||
} finally {
|
|
||||||
destroy(commits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await out.scrollback.append(user("/fmt bash"))
|
|
||||||
take()
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "bash",
|
|
||||||
phase: "start",
|
|
||||||
toolState: "running",
|
|
||||||
state: {
|
|
||||||
status: "running",
|
|
||||||
input: {
|
|
||||||
command: "git status",
|
|
||||||
workdir: "/tmp/demo",
|
|
||||||
},
|
|
||||||
time: { start: 1 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
take()
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "bash",
|
|
||||||
phase: "progress",
|
|
||||||
toolState: "completed",
|
|
||||||
text: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join("\n"),
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: {
|
|
||||||
command: "git status",
|
|
||||||
workdir: "/tmp/demo",
|
|
||||||
},
|
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
take()
|
|
||||||
await out.scrollback.append(assistant("oc-run-dev ahead 1"))
|
|
||||||
await out.scrollback.complete()
|
|
||||||
take()
|
|
||||||
|
|
||||||
const output = lines.join("\n")
|
|
||||||
expect(output).toContain("# Running in /tmp/demo\n$ git status")
|
|
||||||
expect(output).toContain("$ git status\n\nOn branch demo")
|
|
||||||
expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1")
|
|
||||||
expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1")
|
|
||||||
} finally {
|
|
||||||
out.scrollback.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test.skip("inserts a spacer before the next tool after completed multiline bash output", async () => {
|
|
||||||
const out = await setup()
|
const out = await setup()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -657,83 +582,7 @@ test.skip("inserts a spacer before the next tool after completed multiline bash
|
|||||||
|
|
||||||
await out.scrollback.append(
|
await out.scrollback.append(
|
||||||
toolCommit({
|
toolCommit({
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
phase: "start",
|
|
||||||
toolState: "running",
|
|
||||||
state: {
|
|
||||||
status: "running",
|
|
||||||
input: {
|
|
||||||
command: "pwd; ls -la",
|
|
||||||
workdir: "/tmp/demo",
|
|
||||||
},
|
|
||||||
time: { start: 1 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
take()
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "bash",
|
|
||||||
phase: "progress",
|
|
||||||
toolState: "completed",
|
|
||||||
text: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"),
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: {
|
|
||||||
command: "pwd; ls -la",
|
|
||||||
workdir: "/tmp/demo",
|
|
||||||
},
|
|
||||||
output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"),
|
|
||||||
title: "pwd; ls -la",
|
|
||||||
metadata: {
|
|
||||||
exitCode: 0,
|
|
||||||
},
|
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
take()
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "glob",
|
|
||||||
phase: "start",
|
|
||||||
toolState: "running",
|
|
||||||
state: {
|
|
||||||
status: "running",
|
|
||||||
input: {
|
|
||||||
pattern: "**/*tool*",
|
|
||||||
path: "src/cli/cmd",
|
|
||||||
},
|
|
||||||
time: { start: 3 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
take()
|
|
||||||
|
|
||||||
const output = lines.join("\n")
|
|
||||||
expect(output).toContain('total 4\n\n✱ Glob "**/*tool*" in src/cli/cmd')
|
|
||||||
} finally {
|
|
||||||
out.scrollback.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test.skip("does not double-space before completed bash output when inline tool headers intervene", async () => {
|
|
||||||
const out = await setup()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lines: string[] = []
|
|
||||||
const take = () => {
|
|
||||||
const commits = claim(out.renderer)
|
|
||||||
try {
|
|
||||||
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
|
|
||||||
} finally {
|
|
||||||
destroy(commits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "bash",
|
|
||||||
phase: "start",
|
phase: "start",
|
||||||
toolState: "running",
|
toolState: "running",
|
||||||
state: {
|
state: {
|
||||||
@@ -742,7 +591,8 @@ test.skip("does not double-space before completed bash output when inline tool h
|
|||||||
command: "ls",
|
command: "ls",
|
||||||
workdir: "src/cli/cmd/run",
|
workdir: "src/cli/cmd/run",
|
||||||
},
|
},
|
||||||
time: { start: 1 },
|
structured: {},
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -758,7 +608,8 @@ test.skip("does not double-space before completed bash output when inline tool h
|
|||||||
pattern: "**/*tool*",
|
pattern: "**/*tool*",
|
||||||
path: "src/cli/cmd/run",
|
path: "src/cli/cmd/run",
|
||||||
},
|
},
|
||||||
time: { start: 2 },
|
structured: {},
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -774,14 +625,15 @@ test.skip("does not double-space before completed bash output when inline tool h
|
|||||||
pattern: "tool",
|
pattern: "tool",
|
||||||
path: "src/cli/cmd/run",
|
path: "src/cli/cmd/run",
|
||||||
},
|
},
|
||||||
time: { start: 3 },
|
structured: {},
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
take()
|
take()
|
||||||
await out.scrollback.append(
|
await out.scrollback.append(
|
||||||
toolCommit({
|
toolCommit({
|
||||||
tool: "bash",
|
tool: "shell",
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
toolState: "completed",
|
toolState: "completed",
|
||||||
text: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
|
text: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
|
||||||
@@ -791,12 +643,8 @@ test.skip("does not double-space before completed bash output when inline tool h
|
|||||||
command: "ls",
|
command: "ls",
|
||||||
workdir: "src/cli/cmd/run",
|
workdir: "src/cli/cmd/run",
|
||||||
},
|
},
|
||||||
output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
|
content: [{ type: "text", text: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n") }],
|
||||||
title: "ls",
|
structured: { exit: 0, truncated: false },
|
||||||
metadata: {
|
|
||||||
exitCode: 0,
|
|
||||||
},
|
|
||||||
time: { start: 1, end: 4 },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -810,105 +658,6 @@ test.skip("does not double-space before completed bash output when inline tool h
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip("does not emit blank patch snapshots between edit and task", async () => {
|
|
||||||
const out = await setup()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lines: string[] = []
|
|
||||||
const take = () => {
|
|
||||||
const commits = claim(out.renderer)
|
|
||||||
try {
|
|
||||||
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
|
|
||||||
} finally {
|
|
||||||
destroy(commits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "edit",
|
|
||||||
phase: "final",
|
|
||||||
toolState: "completed",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: {
|
|
||||||
filePath: "src/demo-format.ts",
|
|
||||||
},
|
|
||||||
output: "",
|
|
||||||
title: "edit",
|
|
||||||
metadata: {
|
|
||||||
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
|
||||||
},
|
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
take()
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "apply_patch",
|
|
||||||
phase: "final",
|
|
||||||
toolState: "completed",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: {
|
|
||||||
patchText: "*** Begin Patch\n*** End Patch",
|
|
||||||
},
|
|
||||||
output: "",
|
|
||||||
title: "apply_patch",
|
|
||||||
metadata: {
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
filePath: "src/demo-format.ts",
|
|
||||||
relativePath: "src/demo-format.ts",
|
|
||||||
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
|
||||||
deletions: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "add",
|
|
||||||
filePath: "README-demo.md",
|
|
||||||
relativePath: "README-demo.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
time: { start: 2, end: 3 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
take()
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "task",
|
|
||||||
phase: "final",
|
|
||||||
toolState: "completed",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: {
|
|
||||||
description: "Scan run/* for reducer touchpoints",
|
|
||||||
subagent_type: "explore",
|
|
||||||
},
|
|
||||||
output: "",
|
|
||||||
title: "task",
|
|
||||||
metadata: {
|
|
||||||
sessionId: "sub_demo_1",
|
|
||||||
},
|
|
||||||
time: { start: 3, end: 4 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
take()
|
|
||||||
|
|
||||||
const output = lines.join("\n")
|
|
||||||
expect(output).toContain("+ Created README-demo.md")
|
|
||||||
expect(output).not.toContain("~ Patched src/demo-format.ts")
|
|
||||||
expect(output).toContain("+ Created README-demo.md\n\n# Explore Task")
|
|
||||||
expect(output).not.toContain("+ Created README-demo.md\n\n\n# Explore Task")
|
|
||||||
} finally {
|
|
||||||
out.scrollback.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders plain errors with one blank line before and after the error block", async () => {
|
test("renders plain errors with one blank line before and after the error block", async () => {
|
||||||
const out = await setup()
|
const out = await setup()
|
||||||
|
|
||||||
@@ -957,10 +706,11 @@ test("renders structured write finals once as code blocks", async () => {
|
|||||||
state: {
|
state: {
|
||||||
status: "running",
|
status: "running",
|
||||||
input: {
|
input: {
|
||||||
filePath: "src/a.ts",
|
path: "src/a.ts",
|
||||||
content: "const x = 1\nconst y = 2\n",
|
content: "const x = 1\nconst y = 2\n",
|
||||||
},
|
},
|
||||||
time: { start: 1 },
|
structured: {},
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -976,11 +726,11 @@ test("renders structured write finals once as code blocks", async () => {
|
|||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: {
|
input: {
|
||||||
filePath: "src/a.ts",
|
path: "src/a.ts",
|
||||||
content: "const x = 1\nconst y = 2\n",
|
content: "const x = 1\nconst y = 2\n",
|
||||||
},
|
},
|
||||||
metadata: {},
|
structured: {},
|
||||||
time: { start: 1, end: 2 },
|
content: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -999,51 +749,3 @@ test("renders structured write finals once as code blocks", async () => {
|
|||||||
out.scrollback.destroy()
|
out.scrollback.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders promoted task markdown without a leading blank row", async () => {
|
|
||||||
const out = await setup()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await out.scrollback.append(
|
|
||||||
toolCommit({
|
|
||||||
tool: "task",
|
|
||||||
phase: "final",
|
|
||||||
toolState: "completed",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: {
|
|
||||||
description: "Explore run.ts",
|
|
||||||
subagent_type: "explore",
|
|
||||||
},
|
|
||||||
output: [
|
|
||||||
'<task id="child-1" state="completed">',
|
|
||||||
"<task_result>",
|
|
||||||
"Location: `/tmp/run.ts`",
|
|
||||||
"",
|
|
||||||
"Summary:",
|
|
||||||
"- Local interactive mode",
|
|
||||||
"- Attach mode",
|
|
||||||
"</task_result>",
|
|
||||||
"</task>",
|
|
||||||
].join("\n"),
|
|
||||||
metadata: {
|
|
||||||
sessionId: "child-1",
|
|
||||||
},
|
|
||||||
time: { start: 1, end: 2 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const commits = claim(out.renderer)
|
|
||||||
try {
|
|
||||||
const output = render(commits)
|
|
||||||
expect(output.startsWith("\n")).toBe(false)
|
|
||||||
expect(output).toContain("Summary:")
|
|
||||||
expect(output).toContain("Local interactive mode")
|
|
||||||
} finally {
|
|
||||||
destroy(commits)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
out.scrollback.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -193,7 +193,8 @@ describe("run session shared", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const out = await resolveCurrentSession(client, "ses_1")
|
const controller = new AbortController()
|
||||||
|
const out = await resolveCurrentSession(client, "ses_1", controller.signal)
|
||||||
|
|
||||||
expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
|
expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
|
||||||
expect(out.variant).toBe("high")
|
expect(out.variant).toBe("high")
|
||||||
@@ -213,5 +214,10 @@ describe("run session shared", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
expect(client.message.list).toHaveBeenCalledWith(
|
||||||
|
{ sessionID: "ses_1", limit: 200, order: "desc" },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
expect(client.session.get).toHaveBeenCalledWith({ sessionID: "ses_1" }, { signal: controller.signal })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -71,9 +71,6 @@ test("returns syntax styles and indexed splash colors", async () => {
|
|||||||
expectIndexed(theme.splash.left)
|
expectIndexed(theme.splash.left)
|
||||||
expectIndexed(theme.splash.right)
|
expectIndexed(theme.splash.right)
|
||||||
expectIndexed(theme.splash.leftShadow)
|
expectIndexed(theme.splash.leftShadow)
|
||||||
expectIndexed(theme.splash.rightShadow)
|
|
||||||
expectIndexed(theme.block.highlight)
|
|
||||||
expectIndexed(theme.block.warning)
|
|
||||||
expectRgba(theme.footer.highlight)
|
expectRgba(theme.footer.highlight)
|
||||||
expectRgba(theme.footer.statusAccent)
|
expectRgba(theme.footer.statusAccent)
|
||||||
expectRgba(theme.footer.surface)
|
expectRgba(theme.footer.surface)
|
||||||
@@ -96,8 +93,6 @@ test("keeps footer surfaces exact while scrollback stays palette matched", async
|
|||||||
expect(expectRgba(theme.footer.border).toInts()).toEqual(expectRgba(exact.border).toInts())
|
expect(expectRgba(theme.footer.border).toInts()).toEqual(expectRgba(exact.border).toInts())
|
||||||
expect(expectRgba(theme.footer.pane).toInts()).toEqual(expectRgba(exact.backgroundMenu).toInts())
|
expect(expectRgba(theme.footer.pane).toInts()).toEqual(expectRgba(exact.backgroundMenu).toInts())
|
||||||
expect(expectRgba(theme.footer.selected).intent).toBe("rgb")
|
expect(expectRgba(theme.footer.selected).intent).toBe("rgb")
|
||||||
expectIndexed(theme.block.highlight)
|
|
||||||
expectIndexed(theme.block.warning)
|
|
||||||
} finally {
|
} finally {
|
||||||
theme.block.syntax?.destroy()
|
theme.block.syntax?.destroy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,15 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { toolInlineInfo, toolOutputText, toolView } from "../../src/mini/tool"
|
import { normalizeTool, toolOutputText } from "../../src/mini/tool"
|
||||||
|
|
||||||
describe("Mini tool presentation", () => {
|
describe("Mini tool presentation", () => {
|
||||||
test("renders the renamed shell tool with the shell rule", () => {
|
test("uses V2 shell output without the model-facing status", () => {
|
||||||
const part = {
|
|
||||||
id: "part-shell",
|
|
||||||
sessionID: "session-shell",
|
|
||||||
messageID: "message-shell",
|
|
||||||
callID: "call-shell",
|
|
||||||
tool: "shell",
|
|
||||||
state: {
|
|
||||||
status: "pending" as const,
|
|
||||||
input: { command: "pwd" },
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
expect(toolView(part.tool)).toEqual({ output: true, final: false })
|
|
||||||
expect(toolInlineInfo(part)).toMatchObject({ icon: "$", title: "pwd", mode: "block" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses non-empty V2 shell output without the model-facing status", () => {
|
|
||||||
expect(
|
expect(
|
||||||
toolOutputText("shell", [
|
toolOutputText("shell", [
|
||||||
{ type: "text", text: "mini-output\n" },
|
{ type: "text", text: "mini-output\n" },
|
||||||
{ type: "text", text: "Command exited with code 0." },
|
{ type: "text", text: "Command exited with code 0." },
|
||||||
]),
|
]),
|
||||||
).toBe("mini-output\n")
|
).toBe("mini-output\n")
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps empty V2 shell output empty", () => {
|
|
||||||
expect(
|
expect(
|
||||||
toolOutputText("shell", [
|
toolOutputText("shell", [
|
||||||
{ type: "text", text: "" },
|
{ type: "text", text: "" },
|
||||||
@@ -36,4 +17,59 @@ describe("Mini tool presentation", () => {
|
|||||||
]),
|
]),
|
||||||
).toBe("")
|
).toBe("")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("normalizes only persisted tool aliases into current fields", () => {
|
||||||
|
expect(
|
||||||
|
normalizeTool({
|
||||||
|
type: "tool",
|
||||||
|
id: "call-patch",
|
||||||
|
name: "apply_patch",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { patchText: "*** Begin Patch\n*** End Patch" },
|
||||||
|
structured: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
filePath: "/tmp/project/src/a.ts",
|
||||||
|
relativePath: "src/a.ts",
|
||||||
|
diff: "@@ -1 +1 @@\n-old\n+new",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "patched" }],
|
||||||
|
},
|
||||||
|
time: { created: 1, ran: 1, completed: 2 },
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
name: "patch",
|
||||||
|
state: {
|
||||||
|
structured: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
status: "modified",
|
||||||
|
file: "src/a.ts",
|
||||||
|
patch: "@@ -1 +1 @@\n-old\n+new",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "patched" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeTool({
|
||||||
|
type: "tool",
|
||||||
|
id: "call-subagent",
|
||||||
|
name: "task",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: { subagent_type: "explore", description: "Inspect" },
|
||||||
|
structured: {},
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
time: { created: 1, ran: 1 },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ name: "subagent", state: { input: { agent: "explore" } } })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,56 +12,9 @@ const providers: RunProvider[] = [
|
|||||||
{
|
{
|
||||||
id: "openai",
|
id: "openai",
|
||||||
name: "OpenAI",
|
name: "OpenAI",
|
||||||
source: "api",
|
|
||||||
env: [],
|
|
||||||
options: {},
|
|
||||||
models: {
|
models: {
|
||||||
"gpt-5": {
|
"gpt-5": {
|
||||||
id: "gpt-5",
|
|
||||||
providerID: "openai",
|
|
||||||
api: {
|
|
||||||
id: "gpt-5",
|
|
||||||
url: "https://openai.test",
|
|
||||||
npm: "@ai-sdk/openai",
|
|
||||||
},
|
|
||||||
name: "GPT-5",
|
name: "GPT-5",
|
||||||
capabilities: {
|
|
||||||
temperature: true,
|
|
||||||
reasoning: true,
|
|
||||||
attachment: true,
|
|
||||||
toolcall: true,
|
|
||||||
input: {
|
|
||||||
text: true,
|
|
||||||
audio: false,
|
|
||||||
image: false,
|
|
||||||
video: false,
|
|
||||||
pdf: false,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
text: true,
|
|
||||||
audio: false,
|
|
||||||
image: false,
|
|
||||||
video: false,
|
|
||||||
pdf: false,
|
|
||||||
},
|
|
||||||
interleaved: false,
|
|
||||||
},
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cache: {
|
|
||||||
read: 0,
|
|
||||||
write: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
context: 128000,
|
|
||||||
output: 8192,
|
|
||||||
},
|
|
||||||
status: "active",
|
|
||||||
options: {},
|
|
||||||
headers: {},
|
|
||||||
release_date: "2026-01-01",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -100,5 +53,4 @@ describe("run variant shared", () => {
|
|||||||
|
|
||||||
expect(pickVariant(model, session)).toBe("minimal")
|
expect(pickVariant(model, session)).toBe("minimal")
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises"
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import type { TerminalColors } from "@opentui/core"
|
import type { TerminalColors } from "@opentui/core"
|
||||||
import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme } from "../src/theme"
|
import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme } from "../src/theme"
|
||||||
import { discoverThemes } from "../src/context/theme"
|
import { discoverThemes, themeDirectories } from "../src/theme/discovery"
|
||||||
import { terminalMode } from "../src/theme/system"
|
import { terminalMode } from "../src/theme/system"
|
||||||
import { tmpdir } from "./fixture/fixture"
|
import { tmpdir } from "./fixture/fixture"
|
||||||
|
|
||||||
@@ -80,3 +80,18 @@ test("custom theme precedence follows directory order", async () => {
|
|||||||
|
|
||||||
await expect(discoverThemes([global, project])).resolves.toEqual({ custom: { source: "project" } })
|
await expect(discoverThemes([global, project])).resolves.toEqual({ custom: { source: "project" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("theme directories include global config before project directories", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const global = path.join(tmp.path, "global")
|
||||||
|
const project = path.join(tmp.path, "repo", "package")
|
||||||
|
await mkdir(path.join(global, "themes"), { recursive: true })
|
||||||
|
await mkdir(path.join(project, ".opencode", "themes"), { recursive: true })
|
||||||
|
await writeFile(path.join(global, "themes", "global.json"), JSON.stringify({ source: "global" }))
|
||||||
|
await writeFile(path.join(project, ".opencode", "themes", "project.json"), JSON.stringify({ source: "project" }))
|
||||||
|
|
||||||
|
await expect(discoverThemes(themeDirectories(global, project))).resolves.toEqual({
|
||||||
|
global: { source: "global" },
|
||||||
|
project: { source: "project" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user