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:
Simon Klee 2026-07-19 22:45:10 +02:00 committed by GitHub
commit 925c2423de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 5631 additions and 4292 deletions

View file

@ -17,6 +17,7 @@ import {
type ThemeJson,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes, themeDirectories } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
import { resolveThemeFile } from "../theme/v2/resolve"
import { migrateV1 } from "../theme/v2/v1-migrate"
@ -25,9 +26,6 @@ import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
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"
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
@ -39,12 +37,7 @@ export type ThemeSource = Readonly<{
const themeSource: ThemeSource = {
async discover() {
const directories = [Global.Path.config]
for (let current = process.cwd(); ; current = path.dirname(current)) {
directories.push(path.join(current, ".opencode"))
if (path.dirname(current) === current) break
}
return discoverThemes(directories)
return discoverThemes(themeDirectories(Global.Path.config, process.cwd()))
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
@ -52,16 +45,7 @@ const themeSource: ThemeSource = {
},
}
export async function discoverThemes(directories: string[]) {
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 { discoverThemes } from "../theme/discovery"
export {
DEFAULT_THEMES,

View file

@ -1,6 +1,7 @@
import type {
AgentListOutput,
CommandListOutput,
LocationRef,
ModelListOutput,
OpenCodeClient,
ProviderListOutput,
@ -14,45 +15,38 @@ type CurrentSkill = SkillListOutput["data"][number]
type CurrentProvider = ProviderListOutput["data"][number]
type CurrentModel = ModelListOutput["data"][number]
function location(directory: string, workspace?: string) {
function location(ref: LocationRef) {
return {
location: {
directory,
workspace,
directory: ref.directory,
workspace: ref.workspaceID,
},
}
}
function defaultCost(model: CurrentModel) {
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
if (!picked) {
return undefined
}
return {
...picked,
input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input,
}
if (!picked) return
return model.cost.every((cost) => cost.input === 0) ? 0 : picked.input
}
export function runAgent(input: CurrentAgent): RunAgent {
function runAgent(input: CurrentAgent): RunAgent {
return {
id: input.id,
name: input.name,
description: input.description,
mode: input.mode,
hidden: input.hidden,
}
}
export function runCommand(input: CurrentCommand): RunCommand {
function runCommand(input: CurrentCommand): RunCommand {
return {
name: input.name,
description: input.description,
}
}
export function runSkill(input: CurrentSkill): RunCommand {
function runSkill(input: CurrentSkill): RunCommand {
return {
name: input.id,
description: input.description,
@ -77,13 +71,10 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
name: model.providerID,
models: {},
}
const cost = defaultCost(model)
provider.models[model.id] = {
id: model.id,
providerID: model.providerID,
name: model.name,
capabilities: model.capabilities,
cost: defaultCost(model),
limit: model.limit,
cost: cost === undefined ? undefined : { input: cost },
status: model.status,
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: {
sdk: OpenCodeClient
directory: string
location: LocationRef
timeoutMs?: number
requestTimeoutMs?: number
active?: () => boolean
signal?: AbortSignal
}): Promise<{ providerID: string; modelID: string } | undefined> {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && (input.active?.() ?? true)) {
const model = await input.sdk.model
.default(location(input.directory))
.then((result) => result.data)
.catch(() => undefined)
while (Date.now() < deadline && !input.signal?.aborted && (input.active?.() ?? true)) {
const controller = new AbortController()
const timeout = setTimeout(
() => controller.abort(),
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 }
await new Promise((resolve) => setTimeout(resolve, 25))
await wait(25, input.signal)
}
}
export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.agent.list(location(directory))
function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefined> {
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)
}
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([
sdk.command.list(location(directory)),
sdk.skill.list(location(directory)),
sdk.command.list(location(ref), ...requestOptions(signal)),
sdk.skill.list(location(ref), ...requestOptions(signal)),
])
return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)]
}
export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.reference.list(location(directory))
export async function loadRunReferences(
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)
}
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([
sdk.provider.list(location(directory)),
sdk.model.list(location(directory)),
sdk.provider.list(location(ref), ...requestOptions(signal)),
sdk.model.list(location(ref), ...requestOptions(signal)),
])
return runProviders([...providers.data], [...models.data])
}
function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] {
return signal ? [{ signal }] : []
}

View file

@ -2,28 +2,30 @@
//
// Enabled with `--demo`. Intercepts prompt submissions and drives the same
// 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
// the initial interactive message to trigger a preview immediately.
//
// Slash commands:
// /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,
// write, edit, patch, task, question, error, mix)
// write, edit, patch, subagent, question, error, mix)
//
// Demo mode also handles permission and question replies locally, completing
// or failing the synthetic tool parts as appropriate.
// Demo mode handles permission and Form replies locally, completing or failing
// the synthetic tool parts through the same callbacks used by the live footer.
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 { toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
MiniToolPart,
FooterView,
FormCancel,
FormReply,
MiniFormRequest,
MiniPermissionRequest,
PermissionReply,
QuestionReject,
QuestionReply,
RunPrompt,
StreamCommit,
} from "./types"
@ -37,25 +39,25 @@ const KINDS = [
"write",
"edit",
"patch",
"task",
"subagent",
"question",
"error",
"mix",
]
const PERMISSIONS = ["edit", "shell", "read", "task", "external", "doom"] as const
const QUESTIONS = ["multi", "single", "checklist", "custom"] as const
const PERMISSIONS = ["edit", "shell", "read", "subagent", "external", "doom"] as const
const FORMS = ["question", "external"] as const
type PermissionKind = (typeof PERMISSIONS)[number]
type QuestionKind = (typeof QUESTIONS)[number]
type FormKind = (typeof FORMS)[number]
function permissionKind(value: string | undefined): PermissionKind | undefined {
const next = (value || "edit").toLowerCase()
return PERMISSIONS.find((item) => item === next)
}
function questionKind(value: string | undefined): QuestionKind | undefined {
const next = (value || "multi").toLowerCase()
return QUESTIONS.find((item) => item === next)
function formKind(value: string | undefined): FormKind | undefined {
const next = (value || "question").toLowerCase()
return FORMS.find((item) => item === next)
}
const SAMPLE_MARKDOWN = [
@ -111,12 +113,14 @@ type Ref = {
part: string
call: string
tool: string
input: Record<string, unknown>
input: Record<string, JsonValue>
start: number
}
type Ask = {
type FormRequest = {
ref: Ref
kind: FormKind
request: MiniFormRequest
}
type Perm = {
@ -124,7 +128,7 @@ type Perm = {
done: {
title: string
output: string
metadata?: Record<string, unknown>
metadata?: Record<string, JsonValue>
}
}
@ -132,7 +136,7 @@ type Permit = {
ref: Ref
permission: string
patterns: string[]
metadata?: PermissionV2Request["metadata"]
metadata?: MiniPermissionRequest["metadata"]
always: string[]
done: Perm["done"]
}
@ -145,9 +149,9 @@ type State = {
part: number
call: number
perm: number
ask: number
form: number
perms: Map<string, Perm>
asks: Map<string, Ask>
forms: Map<string, FormRequest>
started: Set<string>
}
@ -173,7 +177,7 @@ function clearSubagent(footer: FooterApi): void {
tabs: [],
details: {},
permissions: [],
questions: [],
forms: [],
},
})
}
@ -182,13 +186,10 @@ function showSubagent(
state: State,
input: {
sessionID: string
partID: string
callID: string
label: string
description: string
status: "running" | "completed" | "cancelled" | "error"
title?: string
toolCalls?: number
commits: StreamCommit[]
},
) {
@ -198,24 +199,20 @@ function showSubagent(
tabs: [
{
sessionID: input.sessionID,
partID: input.partID,
callID: input.callID,
label: input.label,
description: input.description,
status: input.status,
title: input.title,
toolCalls: input.toolCalls,
lastUpdatedAt: Date.now(),
},
],
details: {
[input.sessionID]: {
sessionID: input.sessionID,
commits: input.commits,
},
},
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)]
}
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
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(
{ footer: state.footer },
{
commits,
footer: view
? {
view: "action" in view ? { type: "permission", request: view } : { type: "question", request: view },
patch: { status: "action" in view ? "awaiting permission" : "awaiting answer" },
view,
patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
}
: undefined,
},
@ -295,7 +292,9 @@ async function emitText(state: State, body: string, signal?: AbortSignal): Promi
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)
}
}
@ -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 {
msg: open(state),
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)
present(
state,
[
toolCommit(
{
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
callID: ref.call,
tool: ref.tool,
state: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
},
"start",
),
],
)
const part = {
type: "tool" as const,
id: ref.call,
name: ref.tool,
state: { status: "running" as const, input: ref.input, structured, content: [] },
time: { created: ref.start, ran: ref.start },
}
present(state, [toolCommit(part, ref.msg, "start")])
return part
}
function askPermission(state: State, item: Permit): void {
startTool(state, item.ref)
const tool = startTool(state, item.ref)
const id = take(state, "perm", "perm")
state.perms.set(id, {
@ -367,13 +359,17 @@ function askPermission(state: State, item: Permit): void {
})
present(state, [], {
id,
sessionID: state.id,
action: item.permission,
resources: item.patterns,
metadata: item.metadata ?? {},
save: item.always,
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
type: "permission",
request: {
id,
sessionID: state.id,
action: item.permission,
resources: item.patterns,
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: {
title: string
output: string
metadata?: Record<string, unknown>
metadata?: Record<string, JsonValue>
},
): void {
if (!state.started.has(ref.part)) startTool(state, ref)
const part: MiniToolPart = {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
callID: ref.call,
tool: ref.tool,
const part: SessionMessageAssistantTool = {
type: "tool",
id: ref.call,
name: ref.tool,
state: {
status: "completed",
input: ref.input,
output: output.output,
title: output.title,
metadata: output.metadata ?? {},
time: { start: ref.start, end: Date.now() },
content: output.output ? [{ type: "text", text: output.output }] : [],
structured: output.metadata ?? {},
},
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 {
if (!state.started.has(ref.part)) startTool(state, ref)
present(
state,
[
toolCommit(
{
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
callID: ref.call,
tool: ref.tool,
state: {
status: "error",
input: ref.input,
error,
metadata: {},
time: { start: ref.start, end: Date.now() },
},
present(state, [
toolCommit(
{
type: "tool",
id: ref.call,
name: ref.tool,
state: {
status: "error",
input: ref.input,
error: { type: "unknown", message: error },
structured: {},
content: [],
},
"final",
),
],
)
time: { created: ref.start, ran: ref.start, completed: Date.now() },
},
ref.msg,
"final",
),
])
}
function emitError(state: State, text: string): void {
@ -447,7 +437,7 @@ async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
title: "git status",
output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
metadata: {
exitCode: 0,
exit: 0,
},
})
}
@ -455,7 +445,7 @@ async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
function emitWrite(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "write", {
filePath: file,
path: file,
content: "export const demo = 42\n",
})
doneTool(state, ref, {
@ -468,13 +458,19 @@ function emitWrite(state: State): void {
function emitEdit(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "edit", {
filePath: file,
path: file,
})
doneTool(state, ref, {
title: "edit",
output: "",
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: {
files: [
{
type: "update",
filePath: file,
relativePath: "src/demo-format.ts",
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
status: "modified",
file,
patch: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
deletions: 1,
},
{
type: "add",
filePath: path.join(process.cwd(), "README-demo.md"),
relativePath: "README-demo.md",
diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
status: "added",
file: path.join(process.cwd(), "README-demo.md"),
patch: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
deletions: 0,
},
],
@ -509,46 +503,41 @@ function emitPatch(state: State): void {
}
function emitTask(state: State): void {
const ref = make(state, "task", {
const ref = make(state, "subagent", {
description: "Scan run/* for reducer touchpoints",
subagent_type: "explore",
agent: "explore",
})
doneTool(state, ref, {
title: "Reducer touchpoints found",
output: "",
metadata: {
toolcalls: 4,
sessionId: "sub_demo_1",
sessionID: "sub_demo_1",
status: "completed",
output: "",
},
})
const part = {
id: "sub_demo_tool_1",
type: "tool",
sessionID: "sub_demo_1",
messageID: "sub_demo_msg_tool",
callID: "sub_demo_call_1",
tool: "read",
id: "sub_demo_call_1",
name: "read",
state: {
status: "running",
input: {
filePath: "packages/tui/src/mini/stream.ts",
path: "packages/tui/src/mini/stream.ts",
offset: 1,
limit: 200,
},
time: {
start: Date.now(),
},
structured: {},
content: [],
},
} satisfies MiniToolPart
time: { created: Date.now(), ran: Date.now() },
} satisfies SessionMessageAssistantTool
showSubagent(state, {
sessionID: "sub_demo_1",
partID: ref.part,
callID: ref.call,
label: "Explore",
description: "Scan run/* for reducer touchpoints",
status: "completed",
title: "Reducer touchpoints found",
toolCalls: 4,
commits: [
{
kind: "user",
@ -597,6 +586,7 @@ function emitQuestionTool(state: State): void {
{ label: "Code", description: "Show code block" },
],
multiple: false,
custom: false,
},
{
header: "Extras",
@ -639,7 +629,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
title: "git status --short",
output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
metadata: {
exitCode: 0,
exit: 0,
},
},
})
@ -649,7 +639,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
if (kind === "read") {
const target = path.join(root, "package.json")
const ref = make(state, "read", {
filePath: target,
path: target,
offset: 1,
limit: 80,
})
@ -667,22 +657,23 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
return
}
if (kind === "task") {
const ref = make(state, "task", {
if (kind === "subagent") {
const ref = make(state, "subagent", {
description: "Inspect footer spacing across direct-mode prompts",
subagent_type: "explore",
agent: "explore",
})
askPermission(state, {
ref,
permission: "task",
permission: "subagent",
patterns: ["explore"],
always: ["*"],
done: {
title: "Footer spacing checked",
output: "",
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 target = path.join(dir, "README.md")
const ref = make(state, "read", {
filePath: target,
path: target,
offset: 1,
limit: 40,
})
@ -716,9 +707,9 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
}
if (kind === "doom") {
const ref = make(state, "task", {
const ref = make(state, "subagent", {
description: "Retry the formatter after repeated failures",
subagent_type: "general",
agent: "general",
})
askPermission(state, {
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 ref = make(state, "edit", {
filePath: file,
filepath: file,
diff,
path: file,
})
askPermission(state, {
ref,
@ -749,96 +738,98 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
title: "edit",
output: "",
metadata: {
diff,
files: [{ file, status: "modified", patch: diff }],
},
},
})
}
function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
const questions = (() => {
if (kind === "single") {
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 [
function demoForm(kind: FormKind): { title: string; fields: MiniFormRequest["fields"]; questions?: JsonValue[] } {
if (kind === "question") {
const questions: JsonValue[] = [
{
header: "Layout",
question: "Which footer view should stay active while testing?",
question: "Which footer view should be the reference for spacing checks?",
options: [
{ label: "Prompt", description: "Return to prompt" },
{ label: "Question", description: "Keep question open" },
{ label: "Form", description: "Inspect the canonical Form footer" },
{ label: "Prompt", description: "Return to the normal composer" },
],
multiple: false,
custom: true,
},
{
header: "Rows",
header: "Checks",
question: "Pick formatting previews",
options: [
{ label: "Diff", description: "Emit edit diff" },
{ label: "Task", description: "Emit task card" },
{ label: "Diff", description: "Emit an edit diff" },
{ label: "Subagent", description: "Emit a subagent card" },
],
multiple: 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 })
startTool(state, ref)
const id = take(state, "ask", "ask")
state.asks.set(id, { ref })
present(state, [], {
id,
sessionID: state.id,
questions,
tool: { messageID: ref.msg, callID: ref.call },
function emitForm(state: State, kind: FormKind = "question"): void {
const form = demoForm(kind)
const ref = make(state, kind === "question" ? "question" : "mcp_demo", {
...(form.questions ? { questions: form.questions } : { form: kind }),
})
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> {
@ -882,7 +873,7 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
return true
}
if (kind === "task") {
if (kind === "subagent") {
emitTask(state)
return true
}
@ -921,11 +912,12 @@ function intro(state: State): void {
[
"Demo slash commands enabled for interactive mode.",
`- /permission [kind] (${PERMISSIONS.join(", ")})`,
`- /question [kind] (${QUESTIONS.join(", ")})`,
`- /form [kind] (${FORMS.join(", ")})`,
`- /fmt <kind> (${KINDS.join(", ")})`,
"Examples:",
"- /permission shell",
"- /question custom",
"- /form question",
"- /form external",
"- /fmt markdown",
"- /fmt table",
"- /fmt text your custom text",
@ -942,9 +934,9 @@ export function createRunDemo(input: Input) {
part: 0,
call: 0,
perm: 0,
ask: 0,
form: 0,
perms: new Map(),
asks: new Map(),
forms: new Map(),
started: new Set(),
}
@ -975,14 +967,14 @@ export function createRunDemo(input: Input) {
return true
}
if (cmd === "/question") {
const kind = questionKind(list[1])
if (cmd === "/form") {
const kind = formKind(list[1])
if (!kind) {
note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`)
note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`)
return true
}
emitQuestion(state, kind)
emitForm(state, kind)
return true
}
@ -1024,33 +1016,41 @@ export function createRunDemo(input: Input) {
return true
}
const questionReply = (input: QuestionReply): boolean => {
const ask = state.asks.get(input.requestID)
if (!ask || !input.answers) {
return false
}
state.asks.delete(input.requestID)
const formReply = (input: FormReply): boolean => {
const form = state.forms.get(input.formID)
if (!form || input.sessionID !== form.request.sessionID) return false
state.forms.delete(input.formID)
clearBlocker(state)
doneTool(state, ask.ref, {
title: "question",
output: "",
metadata: {
answers: input.answers,
},
if (form.kind === "question") {
doneTool(state, form.ref, {
title: "question",
output: "",
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
}
const questionReject = (input: QuestionReject): boolean => {
const ask = state.asks.get(input.requestID)
if (!ask) {
return false
}
state.asks.delete(input.requestID)
const formCancel = (input: FormCancel): boolean => {
const form = state.forms.get(input.formID)
if (!form || input.sessionID !== form.request.sessionID) return false
state.forms.delete(input.formID)
clearBlocker(state)
failTool(state, ask.ref, "question rejected")
failTool(state, form.ref, "form cancelled")
return true
}
@ -1058,7 +1058,7 @@ export function createRunDemo(input: Input) {
start,
prompt,
permission,
questionReply,
questionReject,
formReply,
formCancel,
}
}

View file

@ -6,11 +6,11 @@ export type EntryFlags = {
trailingNewline: boolean
}
export const RUN_ENTRY_NONE: RunEntryBody = {
const RUN_ENTRY_NONE: RunEntryBody = {
type: "none",
}
export function cleanRunText(text: string): string {
function cleanRunText(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}

View file

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

View file

@ -14,7 +14,6 @@
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import type { PermissionV2Request } from "@opencode-ai/client/promise"
import {
createPermissionBodyState,
permissionAlwaysLines,
@ -32,7 +31,7 @@ import {
import { footerWidthPolicy } from "./footer.width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { PermissionReply, RunDiffStyle } from "./types"
import type { MiniPermissionRequest, PermissionReply } from "./types"
function buttons(
list: PermissionOption[],
@ -130,15 +129,15 @@ export function RejectField(props: {
}
export function RunPermissionBody(props: {
request: PermissionV2Request
request: MiniPermissionRequest
directory?: () => string
theme: RunFooterTheme
block: RunBlockTheme
diffStyle?: RunDiffStyle
onReply: (input: PermissionReply) => void | Promise<void>
}) {
const dims = useTerminalDimensions()
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
const info = createMemo(() => permissionInfo(props.request))
const [state, setState] = createSignal(createPermissionBodyState(props.request))
const info = createMemo(() => permissionInfo(props.request, props.directory?.()))
const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const opts = createMemo(() =>
@ -163,7 +162,7 @@ export function RunPermissionBody(props: {
return
}
setState(createPermissionBodyState(id))
setState(createPermissionBodyState(props.request))
})
const shift = (dir: -1 | 1) => {
@ -361,15 +360,42 @@ export function RunPermissionBody(props: {
<Show
when={info().diff}
fallback={
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
<For each={info().lines}>
{(line) => (
<text fg={props.theme.text} wrapMode="word">
{line}
</text>
)}
</For>
</box>
<Show
when={info().patch}
fallback={
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
<For each={info().lines}>
{(line) => (
<text fg={props.theme.text} wrapMode="word">
{line}
</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
@ -392,7 +418,7 @@ export function RunPermissionBody(props: {
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
/>
</Show>
<Show when={!info().diff && info().lines.length === 0}>
<Show when={!info().diff && !info().patch && info().lines.length === 0}>
<box paddingLeft={1}>
<text fg={props.theme.muted}>No diff provided</text>
</box>

View file

@ -23,6 +23,7 @@ import {
isNewCommand,
movePromptHistory,
pushPromptHistory,
slashHead,
} from "./prompt.shared"
import { Keymap } from "../context/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
@ -57,7 +58,7 @@ type PromptOption = Auto | SlashOption
type MenuMode = false | "mention" | "slash"
type PromptInput = {
directory: string
directory: Accessor<string>
findFiles: (query: string) => Promise<string[]>
agents: Accessor<RunAgent[]>
references: Accessor<RunReference[]>
@ -95,7 +96,6 @@ export type PromptState = {
openEditor: (input?: { value?: string }) => Promise<void>
onKeyDown: (event: KeyEvent) => void
onContentChange: () => void
replaceDraft: (text: string) => void
replacePrompt: (prompt: RunPrompt) => void
bind: (area?: TextareaRenderable) => void
}
@ -140,23 +140,6 @@ function extractLineRange(input: string) {
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) {
const head = slashHead(text.slice(0, cursor))
if (!head || head.end !== cursor) {
@ -379,7 +362,7 @@ export function createPromptState(input: PromptInput): PromptState {
const next = extractLineRange(value)
const list = await input.findFiles(next.base)
return list.map((item): Auto => {
const url = pathToFileURL(path.resolve(input.directory, item))
const url = pathToFileURL(path.resolve(input.directory(), item))
let filename = item
if (next.line && !item.endsWith("/")) {
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
@ -634,21 +617,6 @@ export function createPromptState(input: PromptInput): PromptState {
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 = () => {
if (!area || area.isDestroyed) {
return
@ -1296,7 +1264,6 @@ export function createPromptState(input: PromptInput): PromptState {
refresh()
scheduleRows()
},
replaceDraft,
replacePrompt: restore,
bind,
}

View file

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

View file

@ -5,7 +5,7 @@ import { registerOpencodeSpinner } from "../component/register-spinner"
import { Show, createMemo, indexArray } from "solid-js"
import { SPINNER_FRAMES } from "../component/spinner-frames"
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"
registerOpencodeSpinner()
@ -51,8 +51,6 @@ export function RunFooterSubagentBody(props: {
index: () => number
total: () => number
detail: () => FooterSubagentDetail | undefined
width: () => number
diffStyle?: RunDiffStyle
onCycle: (dir: -1 | 1) => void
onClose: () => void
// Formatted interrupt shortcut from the registered keymap binding; the
@ -63,7 +61,6 @@ export function RunFooterSubagentBody(props: {
const footer = createMemo(() => theme().footer)
const tab = createMemo(() => props.tab())
const commits = createMemo(() => props.detail()?.commits ?? [])
const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
const scrollbar = createMemo(() => ({
trackOptions: {
backgroundColor: footer().surface,
@ -89,7 +86,7 @@ export function RunFooterSubagentBody(props: {
const rows = indexArray(commits, (commit, index) => (
<box flexDirection="column" gap={0} flexShrink={0}>
{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>
))
let scroll: ScrollBoxRenderable | undefined

View file

@ -10,7 +10,7 @@
// rebuilding the session model.
//
// 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.
//
// Lifecycle:
@ -24,11 +24,12 @@
// 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
// 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 { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
@ -45,12 +46,11 @@ import type {
FooterState,
FooterSubagentState,
FooterView,
FormCancel,
FormReply,
PermissionReply,
QuestionReject,
QuestionReply,
RunAgent,
RunCommand,
RunDiffStyle,
RunInput,
RunPrompt,
RunProvider,
@ -67,11 +67,10 @@ type CycleResult = {
}
type RunFooterOptions = {
directory: string
directory: () => string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
references: RunReference[]
commands?: RunCommand[]
wrote?: boolean
sessionID: () => string | undefined
agentLabel: string
@ -82,25 +81,22 @@ type RunFooterOptions = {
history?: RunPrompt[]
theme: RunTheme
tuiConfig: RunTuiConfig
diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void>
onCycleVariant?: () => CycleResult | void
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onExit?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
subscribeThemeSignal: (listener: () => void) => () => void
treeSitterClient?: TreeSitterClient
}
const PERMISSION_ROWS = 12
const QUESTION_ROWS = 14
const FORM_ROWS = 14
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
const SKILL_ROWS = RUN_COMMAND_PANEL_ROWS
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
@ -114,7 +110,7 @@ function createEmptySubagentState(): FooterSubagentState {
tabs: [],
details: {},
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") {
return {
phase: "idle",
@ -205,7 +194,6 @@ export class RunFooter implements FooterApi {
private setHistory: Setter<RunPrompt[]>
private promptRoute: FooterPromptRoute = { type: "composer" }
private subagentMenuRows = SUBAGENT_ROWS
private autocomplete = false
private interruptTimeout: NodeJS.Timeout | undefined
private exitTimeout: NodeJS.Timeout | undefined
private noticeTimeout: NodeJS.Timeout | undefined
@ -221,10 +209,7 @@ export class RunFooter implements FooterApi {
private createScrollback(wrote: boolean): RunScrollbackStream {
return new RunScrollbackStream(this.renderer, this.theme(), {
diffStyle: this.options.diffStyle,
wrote,
sessionID: this.options.sessionID,
treeSitterClient: this.options.treeSitterClient,
onThemeRelease: (theme) => {
void this.renderer
.idle()
@ -243,7 +228,6 @@ export class RunFooter implements FooterApi {
status: "",
queue: 0,
model: options.modelLabel,
duration: "",
usage: "",
first: options.first,
interrupt: 0,
@ -260,7 +244,7 @@ export class RunFooter implements FooterApi {
const [references, setReferences] = createSignal(options.references)
this.references = references
this.setReferences = setReferences
const [commands, setCommands] = createSignal<RunCommand[] | undefined>(options.commands)
const [commands, setCommands] = createSignal<RunCommand[] | undefined>()
this.commands = commands
this.setCommands = setCommands
const [providers, setProviders] = createSignal<RunProvider[] | undefined>()
@ -285,7 +269,7 @@ export class RunFooter implements FooterApi {
setSubagent("tabs", reconcile(next.tabs, { key: "sessionID" }))
setSubagent("details", reconcile(next.details))
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[]>([])
this.queuedPrompts = queuedPrompts
@ -323,14 +307,12 @@ export class RunFooter implements FooterApi {
variants: footer.variants,
currentVariant: footer.currentVariant,
theme: footer.theme,
diffStyle: options.diffStyle,
tuiConfig: options.tuiConfig,
history: footer.history,
agent: options.agentLabel,
onSubmit: footer.handlePrompt,
onPermissionReply: footer.handlePermissionReply,
onQuestionReply: footer.handleQuestionReply,
onQuestionReject: footer.handleQuestionReject,
onFormReply: footer.handleFormReply,
onFormCancel: footer.handleFormCancel,
onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
@ -398,6 +380,11 @@ export class RunFooter implements FooterApi {
return
}
if (next.type === "agent") {
this.options.agentLabel = Locale.titlecase(next.agent ?? "build")
return
}
if (next.type === "model") {
this.setCurrentModel(next.selection)
}
@ -502,7 +489,6 @@ export class RunFooter implements FooterApi {
status: typeof next.status === "string" ? next.status : prev.status,
queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
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,
first: typeof next.first === "boolean" ? next.first : prev.first,
interrupt:
@ -552,19 +538,9 @@ export class RunFooter implements FooterApi {
}
const last = this.queue.at(-1)
if (
last &&
last.phase === "progress" &&
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)
}
const merged = last ? coalesceProgressCommit(last, commit) : undefined
if (merged) this.queue[this.queue.length - 1] = merged
else this.queue.push(commit)
if (this.pending) {
return
@ -704,15 +680,15 @@ export class RunFooter implements FooterApi {
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.
private applyHeight(): void {
const type = this.view().type
const height =
type === "permission"
? this.base + PERMISSION_ROWS
: type === "question"
? this.base + QUESTION_ROWS
: type === "form"
? this.base + FORM_ROWS
: this.promptRoute.type === "command"
? 1 + COMMAND_ROWS
: 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.autocomplete = next.autocomplete
this.subagentMenuRows = next.subagentRows
if (this.view().type === "prompt") {
this.applyHeight()
@ -788,20 +763,14 @@ export class RunFooter implements FooterApi {
await this.options.onPermissionReply(input)
}
private handleQuestionReply = async (input: QuestionReply): Promise<void> => {
if (this.isClosed) {
return
}
await this.options.onQuestionReply(input)
private handleFormReply = async (input: FormReply): Promise<void> => {
if (this.isClosed) return
await this.options.onFormReply(input)
}
private handleQuestionReject = async (input: QuestionReject): Promise<void> => {
if (this.isClosed) {
return
}
await this.options.onQuestionReject(input)
private handleFormCancel = async (input: FormCancel): Promise<void> => {
if (this.isClosed) return
await this.options.onFormCancel(input)
}
private handleCycle = (): void => {
@ -1014,12 +983,11 @@ export class RunFooter implements FooterApi {
this.clearExitTimer()
this.patch({ exit: 0, status: "exiting" })
this.close()
this.options.onExit?.()
return true
}
private handlePalette = (): void => {
void resolveRunTheme(this.renderer).then((theme) => {
void resolveRunTheme(this.renderer, this.options.tuiConfig.theme).then((theme) => {
if (this.isGone) {
theme.block.syntax?.destroy()
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 }
}

View file

@ -25,7 +25,8 @@ import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
import { RunFooterSubagentBody } from "./footer.subagent"
import { RunPromptBody, createPromptState } from "./footer.prompt"
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 { Keymap } from "../context/keymap"
@ -35,12 +36,11 @@ import type {
FooterState,
FooterSubagentState,
FooterView,
FormCancel,
FormReply,
PermissionReply,
QuestionReject,
QuestionReply,
RunAgent,
RunCommand,
RunDiffStyle,
RunInput,
RunPrompt,
RunProvider,
@ -67,7 +67,7 @@ const EMPTY_BORDER = {
}
type RunFooterViewProps = {
directory: string
directory: () => string
findFiles: (query: string) => Promise<string[]>
agents: () => RunAgent[]
references: () => RunReference[]
@ -81,14 +81,12 @@ type RunFooterViewProps = {
subagent?: () => FooterSubagentState
queuedPrompts?: () => FooterQueuedPrompt[]
theme: () => RunTheme
diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig
history?: () => RunPrompt[]
agent: string
onSubmit: (input: RunPrompt) => boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void>
onCycle: () => void
onInterrupt: () => boolean
onBackground?: () => void
@ -100,15 +98,13 @@ type RunFooterViewProps = {
onModelSelect: (model: NonNullable<RunInput["model"]>) => void
onVariantSelect: (variant: string | undefined) => 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
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
onQueuedRemove: (messageID: string) => Promise<boolean>
}
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
export function RunFooterView(props: RunFooterViewProps) {
const term = useTerminalDimensions()
const width = createMemo(() => term().width)
@ -120,7 +116,7 @@ export function RunFooterView(props: RunFooterViewProps) {
tabs: [],
details: {},
permissions: [],
questions: [],
forms: [],
}
)
})
@ -139,7 +135,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const panel = createMemo(
() =>
active().type === "permission" ||
active().type === "question" ||
active().type === "form" ||
selectingQueued() ||
selectingSubagent() ||
commanding() ||
@ -165,7 +161,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background))
const model = createMemo(() => {
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 current = route()
@ -215,9 +211,24 @@ export function RunFooterView(props: RunFooterViewProps) {
const view = active()
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()
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(() => {
if (active().type !== "prompt") {
@ -371,10 +382,8 @@ export function RunFooterView(props: RunFooterViewProps) {
}
return {
model: model().model,
model: model(),
variant: props.currentVariant(),
provider: undefined,
// Prefer without provider, but keep it on the shared width policy if we add it back.
}
})
const statusColor = createMemo(() => {
@ -571,7 +580,6 @@ export function RunFooterView(props: RunFooterViewProps) {
createEffect(() => {
props.onLayout({
route: route(),
autocomplete: menu(),
subagentRows: subagentMenuRows(),
})
})
@ -736,19 +744,36 @@ export function RunFooterView(props: RunFooterViewProps) {
<Match when={active().type === "permission"}>
<RunPermissionBody
request={permission()!.request}
directory={props.directory}
theme={theme()}
block={block()}
diffStyle={props.diffStyle}
onReply={props.onPermissionReply}
/>
</Match>
<Match when={active().type === "question"}>
<RunQuestionBody
request={question()!.request}
theme={theme()}
onReply={props.onQuestionReply}
onReject={props.onQuestionReject}
/>
<Match when={active().type === "form"}>
<For each={form() ? [form()!] : []}>
{(value) => (
<RunFormBody
request={value.request}
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>
</Switch>
</box>
@ -824,9 +849,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
{info().model}
<Show when={info().provider}>
{(provider) => <span style={{ fg: theme().muted }}> {provider()}</span>}
</Show>
<Show when={info().variant}>
{(variant) => (
<>
@ -889,8 +911,6 @@ export function RunFooterView(props: RunFooterViewProps) {
index={selectedIndex}
total={() => tabs().length}
detail={detail}
width={width}
diffStyle={props.diffStyle}
onCycle={cycleTab}
onClose={closeTab}
interrupt={() => subagentInterruptShortcut() || undefined}

View file

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

View file

@ -13,8 +13,7 @@
//
// permissionInfo() extracts display info (icon, title, lines, diff) from
// the request, delegating to tool.ts for tool-specific formatting.
import type { PermissionV2Request } from "@opencode-ai/client/promise"
import type { PermissionReply } from "./types"
import type { MiniPermissionRequest, PermissionReply } from "./types"
import { toolPath, toolPermissionInfo } from "./tool"
type Dict = Record<string, unknown>
@ -24,6 +23,7 @@ export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cance
export type PermissionBodyState = {
requestID: string
sessionID: string
stage: PermissionStage
selected: PermissionOption
message: string
@ -35,6 +35,7 @@ export type PermissionInfo = {
title: string
lines: string[]
diff?: string
patch?: string
file?: string
}
@ -55,21 +56,26 @@ function text(v: unknown): string {
return typeof v === "string" ? v : ""
}
function data(request: PermissionV2Request): Dict {
const meta = dict(request.metadata)
return {
...meta,
...dict(meta.input),
function data(request: MiniPermissionRequest): { input: Dict; metadata: Dict } {
const state = request.tool?.state
const metadata = {
...(state && state.status !== "streaming" ? dict(state.structured) : {}),
...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")
}
export function createPermissionBodyState(requestID: string): PermissionBodyState {
export function createPermissionBodyState(
request: Pick<MiniPermissionRequest, "id" | "sessionID">,
): PermissionBodyState {
return {
requestID,
requestID: request.id,
sessionID: request.sessionID,
stage: "permission",
selected: "once",
message: "",
@ -89,10 +95,10 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
return []
}
export function permissionInfo(request: PermissionV2Request): PermissionInfo {
export function permissionInfo(request: MiniPermissionRequest, directory?: string): PermissionInfo {
const pats = patterns(request)
const input = data(request)
const info = toolPermissionInfo(request.action, input, dict(request.metadata), pats)
const source = data(request)
const info = toolPermissionInfo(request.action, source.input, source.metadata, pats, directory)
if (info) {
return info
}
@ -103,7 +109,7 @@ export function permissionInfo(request: PermissionV2Request): PermissionInfo {
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
return {
icon: "←",
title: `Access external directory ${toolPath(dir, { home: true })}`,
title: `Access external directory ${toolPath(dir, { home: true, directory })}`,
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 ?? []
if (save.length === 1 && save[0] === "*") {
return [`This will allow ${request.action} until OpenCode is restarted.`]
}
return [
"This will allow the following patterns until OpenCode is restarted.",
...save.map((item) => `- ${item}`),
]
return ["This will allow the following patterns until OpenCode is restarted.", ...save.map((item) => `- ${item}`)]
}
export function permissionLabel(option: PermissionOption): string {
@ -143,8 +146,14 @@ export function permissionLabel(option: PermissionOption): string {
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 {
sessionID,
requestID,
reply,
...(message && message.trim() ? { message: message.trim() } : {}),
@ -203,7 +212,7 @@ export function permissionRun(state: PermissionBodyState, requestID: string, opt
return {
state,
reply: permissionReply(requestID, "once"),
reply: permissionReply(state.sessionID, requestID, "once"),
}
}
@ -223,7 +232,7 @@ export function permissionRun(state: PermissionBodyState, requestID: string, opt
return {
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 permissionReply(requestID, "reject", state.message)
return permissionReply(state.sessionID, requestID, "reject", state.message)
}
export function permissionCancel(state: PermissionBodyState): PermissionBodyState {

View file

@ -1,4 +1,5 @@
import type { RunPromptPart } from "./types"
import { slashHead } from "./prompt.shared"
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
@ -57,29 +58,6 @@ export function realignEditorPromptParts(content: string, parts: RunPromptPart[]
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) {
if (part.type === "agent") {
return part.source?.value

View file

@ -53,6 +53,14 @@ export function isNewCommand(input: string): boolean {
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 {
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
const next: RunPrompt[] = []

View file

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

View file

@ -1,20 +1,18 @@
// Boot-time resolution for direct interactive mode.
//
// These functions run concurrently at startup to gather everything the runtime
// needs before the first frame: TUI keymap config, diff display style,
// model variant list with context limits, and session history for the prompt
// needs before the first frame: TUI keymap config, model catalog, and session history for the prompt
// history ring. All are async because they read config or hit the SDK, but
// none block each other.
import { resolve } from "../config/v1"
import type { LocationRef } from "@opencode-ai/client/promise"
import { resolve } from "../config"
import { loadRunProviders } from "./catalog.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"
export type ModelInfo = {
providers: RunProvider[]
variants: string[]
limits: Record<string, number>
}
export type SessionInfo = {
@ -27,8 +25,6 @@ export type SessionInfo = {
function emptyModelInfo(): ModelInfo {
return {
providers: [],
variants: [],
limits: {},
}
}
@ -41,47 +37,24 @@ function emptySessionInfo(): SessionInfo {
}
function defaultRunTuiConfig(platform: NodeJS.Platform): RunTuiConfig {
return {
...resolve({}, { terminalSuspend: platform !== "win32" }),
diff_style: "auto",
}
return resolve({}, { terminalSuspend: platform !== "win32" })
}
async function loadModelInfo(
sdk: RunInput["sdk"],
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,
}
async function loadModelInfo(sdk: RunInput["sdk"], location: LocationRef, signal?: AbortSignal): Promise<ModelInfo> {
return { providers: await loadRunProviders(sdk, location, signal) }
}
// Fetches available variants and context limits for every provider/model pair.
// Fetches available providers and variants.
export async function resolveModelInfo(
sdk: RunInput["sdk"],
directory: string,
model: RunInput["model"],
location: LocationRef,
signal?: AbortSignal,
): 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"]) {
return loadModelInfo(sdk, directory, model)
export function resolveModelInfoStrict(sdk: RunInput["sdk"], location: LocationRef, signal?: AbortSignal) {
return loadModelInfo(sdk, location, signal)
}
// 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"],
sessionID: string,
model: RunInput["model"],
signal?: AbortSignal,
): Promise<SessionInfo> {
return resolveCurrentSession(sdk, sessionID)
return resolveCurrentSession(sdk, sessionID, signal)
.then((session) => ({
first: session.first,
history: sessionHistory(session),
@ -109,10 +83,3 @@ export async function resolveRunTuiConfig(
.then((value) => value ?? 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")
}

View file

@ -16,10 +16,10 @@ import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
import type {
FooterApi,
FormCancel,
FormReply,
MiniHost,
PermissionReply,
QuestionReject,
QuestionReply,
RunAgent,
RunInput,
RunPrompt,
@ -49,7 +49,7 @@ type FooterLabels = {
export type LifecycleInput = {
host: MiniHost
directory: string
getDirectory: () => string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
references: RunReference[]
@ -63,8 +63,8 @@ export type LifecycleInput = {
variant: string | undefined
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void>
onCycleVariant?: () => CycleResult | void
onModelSelect?: (model: NonNullable<RunInput["model"]>) => 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",
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)
const state: SplashState = {
entry: false,
@ -199,7 +200,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
...meta,
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.directory, input.host.paths.home),
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
}),
)
await renderer.idle().catch(() => {})
@ -210,7 +211,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
let detachSigintListener: (() => void) | undefined
const footer = new RunFooter(renderer, {
directory: input.directory,
directory: input.getDirectory,
findFiles: input.findFiles,
agents: input.agents,
references: input.references,
@ -223,10 +224,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
theme,
wrote,
tuiConfig,
diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply,
onQuestionReject: input.onQuestionReject,
onFormReply: input.onFormReply,
onFormCancel: input.onFormCancel,
onCycleVariant: input.onCycleVariant,
onModelSelect: input.onModelSelect,
onVariantSelect: input.onVariantSelect,
@ -243,7 +243,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
try {
return await input.host.editor.open({
value,
cwd: input.directory,
cwd: input.getDirectory(),
renderer,
stdin: input.host.terminal.stdin,
})
@ -369,10 +369,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
}),
theme: footer.currentTheme().splash,
showSession: splash.showSession,
detail: directoryLabel(input.directory, input.host.paths.home),
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
}),
)
renderer.requestRender()
await renderer.idle().catch(() => {})
},
close,
}

View file

@ -8,7 +8,6 @@
// and tracks per-turn wall-clock duration for the footer status line.
//
// 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 { Locale } from "../util/locale"
import { isExitCommand, isNewCommand } from "./prompt.shared"
@ -287,7 +286,6 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
) {
const queued: FooterQueuedPrompt = {
messageID: SessionMessage.ID.create(),
partID: "prt_" + ascending(),
prompt,
}
state.queued = [...state.queued, queued]

View file

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

View file

@ -1,61 +1,44 @@
// Top-level orchestrator for `opencode mini`.
//
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
// and prompt queue together into a single session loop. Two entry points:
//
// runInteractiveMode -- used when an SDK client already exists (attach mode)
// runInteractiveDeferredMode -- paints before resolving its session
//
// Both delegate to runInteractiveRuntime, which:
// and prompt queue together into a single session loop. The frontend paints
// before resolving its Session, then:
// 1. resolves TUI config, model info, and session history,
// 2. creates the split-footer lifecycle (renderer + RunFooter),
// 3. starts the stream transport (SDK event subscription), lazily for fresh
// local sessions,
// 4. runs the prompt queue until the footer closes.
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 { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
import type {
LocalReplayAnchor,
LocalReplayRow,
MiniHost,
RunInput,
RunPrompt,
RunProvider,
RunTuiConfig,
StreamCommit,
} from "./types"
import type { LocalReplayRow, MiniHost, RunInput, RunPrompt, RunProvider, RunTuiConfig, StreamCommit } from "./types"
/** @internal Exported for testing */
export { pickVariant, resolveVariant } from "./variant.shared"
/** @internal Exported for testing */
export { runPromptQueue } from "./runtime.queue"
type BootContext = Pick<
RunInput,
"sdk" | "directory" | "sessionID" | "sessionTitle" | "resume" | "agent" | "model" | "variant"
>
type BootContext = Pick<RunInput, "sdk" | "agent" | "model" | "variant"> & {
location: LocationRef
}
type CreateSessionInput = {
location: LocationRef
agent: string | undefined
model: RunInput["model"]
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 = {
host: MiniHost
boot: () => Promise<BootContext>
afterPaint?: (ctx: BootContext) => Promise<void> | void
resolveSession?: (ctx: BootContext) => Promise<ResolvedSession>
createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession>
resolveSession: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
createSession?: CreateSession
reconnect?: Reconnect
files: RunInput["files"]
initialInput?: string
thinking: boolean
thinking?: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
@ -65,16 +48,16 @@ type RunRuntimeInput = {
export type RunDeferredInput = {
host: MiniHost
sdk: RunInput["sdk"]
reconnect?: Reconnect
directory: string
resolveAgent: () => Promise<string | undefined>
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string; resume?: boolean } | undefined>
target: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
createSession?: CreateSession
agent: RunInput["agent"]
model: RunInput["model"]
variant: RunInput["variant"]
files: RunInput["files"]
initialInput?: string
thinking: boolean
thinking?: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
@ -99,44 +82,30 @@ type StreamState = {
type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]>
type ResolvedSession = {
sdk?: RunInput["sdk"]
sessionID: string
sessionTitle?: string
location: RunInput["location"]
model: RunInput["model"]
variant: string | undefined
agent?: string | undefined
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 = {
sdk: RunInput["sdk"]
shown: boolean
aborting: boolean
model: RunInput["model"]
providers: RunProvider[]
variants: string[]
limits: Record<string, number>
activeVariant: string | undefined
sessionID: string
history: RunPrompt[]
localRows: LocalReplayRow[]
sessionTitle?: string
agent: string | undefined
location: LocationRef
switching?: Promise<void>
demo?: RunDemo
selectSubagent?: (sessionID: string | undefined) => void
@ -144,12 +113,10 @@ type RuntimeState = {
stream?: Promise<StreamState>
}
function hasSession(input: RunRuntimeInput, state: RuntimeState) {
return !input.resolveSession || !!state.sessionID
}
function eagerStream(input: RunRuntimeInput, ctx: BootContext) {
return ctx.resume === true || !input.resolveSession || !!input.demo
type ClientAttempt = {
sdk: RunInput["sdk"]
generation: number
signal: AbortSignal
}
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 ?? {})
}
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 LOCAL_REPLAY_ROW_LIMIT = 100
async function resolveExitTitle(
ctx: BootContext,
input: RunRuntimeInput,
state: RuntimeState,
): Promise<string | undefined> {
if (!state.shown || !hasSession(input, state)) {
return undefined
}
return ctx.sdk.session
.get({ sessionID: state.sessionID })
.then((session) => session.title)
.catch(() => undefined)
function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefined> {
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)
},
() => {
signal.removeEventListener("abort", abort)
resolve(undefined)
},
)
})
}
// 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 tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform)
const ctx = await input.boot()
const sessionTask =
ctx.resume === true
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
: Promise.resolve({
first: true,
history: [],
model: undefined,
variant: undefined,
})
const savedTask = input.host.preferences.resolveVariant(ctx.model)
const [session, savedVariant] = await Promise.all([sessionTask, savedTask])
const runtimeController = new AbortController()
const session = {
first: true,
history: [] as RunPrompt[],
model: undefined as RunInput["model"],
variant: undefined as string | undefined,
}
const savedVariant = await input.host.preferences.resolveVariant(ctx.model)
const state: RuntimeState = {
sdk: ctx.sdk,
shown: !session.first,
aborting: false,
model: ctx.model ?? session.model,
providers: [],
variants: [],
limits: {},
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
sessionID: ctx.sessionID,
sessionID: "",
history: [...session.history],
localRows: [],
sessionTitle: ctx.sessionTitle,
agent: ctx.agent,
location: ctx.location,
}
const loadModel = async () => {
if (state.model) {
return {
model: state.model,
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 settleForm = async (sessionID: string, formID: string) => {
if (!state.stream) return
const stream = await state.stream
stream.handle.settleForm?.(sessionID, formID)
}
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
host: input.host,
directory: ctx.directory,
getDirectory: () => state.location.directory,
findFiles: (query) =>
ctx.sdk.file
.find({ query, type: "file", location: { directory: ctx.directory } })
state.sdk.file
.find({
query,
type: "file",
location: { directory: state.location.directory, workspace: state.location.workspaceID },
})
.then((result) => result.data.map((file) => file.path))
.catch(() => []),
agents: [],
@ -276,25 +232,25 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
log?.write("send.permission.reply", next)
await ctx.sdk.permission.reply({ sessionID: state.sessionID, ...next })
await state.sdk.permission.reply(next)
},
onQuestionReply: async (next) => {
if (state.demo?.questionReply(next)) {
return
onFormReply: async (next) => {
if (state.demo?.formReply(next)) return
try {
await state.sdk.form.reply(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
await ctx.sdk.question.reply({
sessionID: state.sessionID,
requestID: next.requestID,
answers: next.answers ?? [],
})
await settleForm(next.sessionID, next.formID)
},
onQuestionReject: async (next) => {
if (state.demo?.questionReject(next)) {
return
onFormCancel: async (next) => {
if (state.demo?.formCancel(next)) return
try {
await state.sdk.form.cancel(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
await ctx.sdk.question.reject({ sessionID: state.sessionID, ...next })
await settleForm(next.sessionID, next.formID)
},
onCycleVariant: () => {
if (!state.model || state.variants.length === 0) {
@ -325,7 +281,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return
}
state.activeVariant = resolveVariant(ctx.variant, undefined, saved, state.variants)
state.activeVariant = resolveVariant(undefined, undefined, saved, state.variants)
})
state.switching = switching
await switching
@ -368,7 +324,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
},
onInterrupt: () => {
if (!hasSession(input, state) || state.aborting) {
if (!state.sessionID || state.aborting) {
return false
}
@ -376,7 +332,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
void (
state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: ctx.sdk.session.interrupt({ sessionID: state.sessionID })
: state.sdk.session.interrupt({ sessionID: state.sessionID })
)
.catch(() => {})
.finally(() => {
@ -385,16 +341,16 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return true
},
onBackground: () => {
if (!hasSession(input, state)) {
if (!state.sessionID) {
return
}
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) => {
log?.write("send.subagent.interrupt", { sessionID })
void ctx.sdk.session.interrupt({ sessionID }).catch(() => {})
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
},
onSubagentSelect: (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 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 = () => {
if (!input.resolveSession || state.sessionID) {
if (state.sessionID) {
return Promise.resolve()
}
@ -414,39 +403,130 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return state.session
}
state.session = input.resolveSession(ctx).then(async (next) => {
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent
if (!next.resume) return
const resumed = await resolveSessionInfo(ctx.sdk, next.sessionID, ctx.model)
session.first = resumed.first
session.history = resumed.history
session.model = resumed.model
session.variant = resumed.variant
state.shown = !resumed.first
state.history = [...resumed.history]
state.model = ctx.model ?? resumed.model
const resumedSavedVariant = state.model ? await input.host.preferences.resolveVariant(state.model) : undefined
state.activeVariant = resolveVariant(ctx.variant, resumed.variant, resumedSavedVariant, [])
session.variant = state.activeVariant
footer.event({ type: "history", history: resumed.history })
footer.event({ type: "first", first: resumed.first })
const task = input
.resolveSession(state.sdk, runtimeController.signal)
.then(async (next) => {
if (next.sdk) updateClient(next.sdk)
if (footer.isClosed || runtimeController.signal.aborted) return
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent
state.location = next.location
state.model = next.model
state.activeVariant = next.variant
footer.event({ type: "agent", agent: state.agent })
if (!next.resume) return
const resumed = await resolveSessionInfo(state.sdk, next.sessionID, next.model, runtimeController.signal)
if (footer.isClosed || runtimeController.signal.aborted) return
session.first = resumed.first
session.history = resumed.history
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 () => {
if (footer.isClosed) return
await ensureSession()
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)
if (
last &&
!after &&
!last.after &&
(commit.kind === "assistant" || commit.kind === "reasoning") &&
last.commit.kind === commit.kind &&
last.commit.source === commit.source &&
@ -457,17 +537,18 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.localRows = [...state.localRows.slice(0, -1), { commit }]
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: {
agents: Awaited<ReturnType<typeof loadRunAgents>>
references: Awaited<ReturnType<typeof loadRunReferences>>
commands: Awaited<ReturnType<typeof loadRunCommands>>
}) => {
if (footer.isClosed) {
return
}
const applyCatalog = (
catalog: {
agents: Awaited<ReturnType<typeof loadRunAgents>>
references: Awaited<ReturnType<typeof loadRunReferences>>
commands: Awaited<ReturnType<typeof loadRunCommands>>
},
attempt: ClientAttempt,
) => {
if (!currentClient(attempt)) return
footer.event({
type: "catalog",
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([
loadRunAgents(ctx.sdk, ctx.directory),
loadRunReferences(ctx.sdk, ctx.directory),
loadRunCommands(ctx.sdk, ctx.directory),
loadRunAgents(attempt.sdk, state.location, attempt.signal),
loadRunReferences(attempt.sdk, state.location, attempt.signal),
loadRunCommands(attempt.sdk, state.location, attempt.signal),
])
return { agents, references, commands }
}
const loadCatalog = async () => {
applyCatalog(
await Promise.all([
loadRunAgents(ctx.sdk, ctx.directory).catch(() => []),
loadRunReferences(ctx.sdk, ctx.directory).catch(() => []),
loadRunCommands(ctx.sdk, ctx.directory).catch(() => []),
const loadCatalog = async (attempt: ClientAttempt) => {
const catalog = await abortable(
Promise.all([
loadRunAgents(attempt.sdk, state.location, attempt.signal).catch(() => []),
loadRunReferences(attempt.sdk, state.location, attempt.signal).catch(() => []),
loadRunCommands(attempt.sdk, state.location, attempt.signal).catch(() => []),
]).then(([agents, references, commands]) => ({ agents, references, commands })),
attempt.signal,
)
if (catalog) applyCatalog(catalog, attempt)
}
const applyModelInfo = (
function applyModelInfo(
info: Awaited<ReturnType<typeof resolveModelInfo>>,
current: string | undefined,
attempt: ClientAttempt,
boot = false,
saved = savedVariant,
) => {
) {
if (!currentClient(attempt)) return
state.providers = info.providers
state.variants = variantsFor(state.providers, state.model)
state.limits = info.limits
state.activeVariant = boot
? resolveVariant(ctx.variant, current, saved, state.variants)
: current && !state.variants.includes(current)
@ -520,30 +604,62 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
}
let catalogRefresh: Promise<void> | undefined
let catalogRefreshQueued = false
const requestCatalogRefresh = () => {
catalogRefreshQueued = true
if (catalogRefresh || footer.isClosed) return
catalogRefresh = (async () => {
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)
let catalogRefresh:
| {
attempt: ClientAttempt
source: AbortSignal | undefined
queued: boolean
task: Promise<void>
}
})().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
if (catalogRefreshQueued) requestCatalogRefresh()
})
void catalogRefresh.catch(() => {})
if (refresh.queued && currentClient(attempt)) void requestCatalogRefresh(refresh.source)
}
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
if (input.host.startup.showTiming) {
@ -563,7 +679,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return createRunDemo({
footer,
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
const loadStreamTransport = () => {
if (streamTask) return streamTask
@ -615,15 +716,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
const handle = await mod.createSessionTransport({
sdk: ctx.sdk,
sdk: state.sdk,
reconnect: input.reconnect,
onClient: updateClient,
readTextFile: input.host.files.readText,
directory: ctx.directory,
location: state.location,
sessionID: state.sessionID,
thinking: input.thinking,
thinking,
replay: input.replay,
replayLimit: input.replayLimit,
limits: () => state.limits,
providers: () => state.providers,
footer,
onCommit: rememberLocal,
trace: log,
@ -714,11 +815,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
? async () => {
try {
await state.switching?.catch(() => {})
const created = await createSession(ctx, {
agent: state.agent,
model: state.model,
variant: state.activeVariant,
})
const created = await createSession(
state.sdk,
{
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 state.stream?.then((item) => item.handle.close()).catch(() => {})
state.stream = undefined
@ -728,6 +835,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.sessionID = created.sessionID
state.sessionTitle = created.sessionTitle
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.localRows = []
includeFiles = true
@ -741,7 +852,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
tabs: [],
details: {},
permissions: [],
questions: [],
forms: [],
},
})
footer.event({ type: "stream.view", view: { type: "prompt" } })
@ -749,7 +860,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
type: "stream.patch",
patch: {
phase: "idle",
duration: "",
usage: "",
first: true,
},
@ -788,7 +898,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
await state.switching?.catch(() => {})
let outputAnchor: LocalReplayAnchor | undefined
try {
const next = await ensureStream()
await next.handle.runPromptTurn({
@ -798,9 +907,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
prompt,
files: input.files,
includeFiles,
onVisibleOutput: (anchor) => {
outputAnchor = anchor
},
signal,
})
if (prompt.messageID) {
@ -826,7 +932,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
source: "system",
messageID: prompt.messageID,
} as const
rememberLocal(commit, outputAnchor)
rememberLocal(commit)
footer.append(commit)
}
},
@ -834,20 +940,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
try {
const eager = eagerStream(input, ctx)
if (eager) {
if (input.demo) {
await firstPaint
if (footer.isClosed) return
if (input.replay && state.shown) {
// Replay commits immutable scrollback rows, so wait for provider names
// before bootstrapping existing session history.
await modelTask
}
await ensureStream()
}
if (!eager && input.resolveSession) {
} else {
void firstPaint
.then(() => {
if (footer.isClosed) {
@ -869,11 +966,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
await state.stream?.then((item) => item.handle.close()).catch(() => {})
}
} finally {
const title = await resolveExitTitle(ctx, input, state)
runtimeController.abort()
offRuntimeClose()
await shell.close({
showExit: state.shown && hasSession(input, state),
sessionTitle: title,
showExit: state.shown && !!state.sessionID,
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
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.
export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise<void> {
const sdk = input.sdk
let session: Promise<ResolvedSession> | undefined
return runInteractiveRuntime(
{
@ -896,33 +993,13 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
resolveSession: () => {
if (session) {
return session
}
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),
reconnect: input.reconnect,
resolveSession: input.target,
createSession: input.createSession,
boot: async () => {
return {
sdk,
directory: input.directory,
sessionID: "",
sessionTitle: undefined,
resume: false,
location: { directory: input.directory },
agent: input.agent,
model: input.model,
variant: input.variant,
@ -932,34 +1009,3 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, 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,
)
}

View file

@ -10,7 +10,7 @@ export function entrySyntax(theme: RunTheme): SyntaxStyle {
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")
}

View file

@ -18,7 +18,7 @@ import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { turnSummaryCommit } from "./turn-summary"
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
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" }>
@ -86,8 +86,6 @@ export class RunScrollbackStream {
private tail: StreamCommit | undefined
private rendered: StreamCommit | undefined
private active: ActiveEntry | undefined
private diffStyle: RunDiffStyle | undefined
private sessionID?: () => string | undefined
private treeSitterClient: TreeSitterClient | undefined
private wrote: boolean
private pendingThemes: RunTheme[] = []
@ -97,14 +95,10 @@ export class RunScrollbackStream {
private theme: RunTheme,
options: {
wrote?: boolean
diffStyle?: RunDiffStyle
sessionID?: () => string | undefined
treeSitterClient?: TreeSitterClient
onThemeRelease?: (theme: RunTheme) => void
} = {},
) {
this.diffStyle = options.diffStyle
this.sessionID = options.sessionID
this.treeSitterClient = options.treeSitterClient
this.wrote = options.wrote ?? false
this.onThemeRelease = options.onThemeRelease
@ -395,9 +389,6 @@ export class RunScrollbackStream {
commit,
body: staticBody(commit, body, spaced),
theme: this.theme,
opts: {
diffStyle: this.diffStyle,
},
}),
)
this.markRendered(commit)

View file

@ -12,11 +12,12 @@ export function entryGroupKey(commit: StreamCommit): string | undefined {
return undefined
}
const part = `${commit.messageID ?? ""}\u0000${commit.partID}`
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 {
@ -47,14 +48,6 @@ export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody
return "inline"
}
if (commit.kind === "reasoning") {
return "block"
}
if (commit.kind === "error") {
return "block"
}
return "block"
}
@ -79,7 +72,6 @@ export function RunEntryContent(props: {
body?: RunEntryBody
theme?: RunTheme
opts?: ScrollbackOptions
width?: number
}) {
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const body = createMemo(() => props.body ?? entryBody(props.commit))
@ -263,13 +255,12 @@ export function entryWriter(input: {
opts?: ScrollbackOptions
}): ScrollbackWriter {
return createScrollbackWriter(
(ctx) => (
() => (
<RunEntryContent
commit={input.commit}
body={input.body}
theme={input.theme}
opts={{ ...input.opts, suppressBackgrounds: true }}
width={ctx.width}
/>
),
entryFlags(input.commit),

View file

@ -1,17 +1,13 @@
import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
import type { FooterView } from "./types"
import type { FooterView, MiniFormRequest, MiniPermissionRequest } from "./types"
export function pickBlockerView(input: {
permission?: PermissionV2Request
question?: QuestionV2Request
}): FooterView {
export function pickBlockerView(input: { permission?: MiniPermissionRequest; form?: MiniFormRequest }): FooterView {
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" }
}
export function blockerStatus(view: FooterView) {
if (view.type === "permission") return "awaiting permission"
if (view.type === "question") return "awaiting answer"
if (view.type === "form") return "awaiting form"
return ""
}

View file

@ -62,11 +62,12 @@ export function createSession(messages: SessionMessages): RunSession {
export async function resolveCurrentSession(
sdk: RunInput["sdk"],
sessionID: string,
signal?: AbortSignal,
limit = LIMIT,
): Promise<RunSession> {
const [response, session] = await Promise.all([
sdk.message.list({ sessionID, limit, order: "desc" }),
sdk.session.get({ sessionID }),
sdk.message.list({ sessionID, limit, order: "desc" }, ...requestOptions(signal)),
sdk.session.get({ sessionID }, ...requestOptions(signal)),
])
const current = createSession(response.data.toReversed())
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[] {
return session.turns
.map((turn) => turn.prompt)

View file

@ -21,8 +21,8 @@ import { Locale } from "../util/locale"
import { go } from "../logo"
import type { RunSplashTheme } from "./theme"
export const SPLASH_TITLE_LIMIT = 50
export const SPLASH_TITLE_FALLBACK = "Untitled session"
const SPLASH_TITLE_LIMIT = 50
const SPLASH_TITLE_FALLBACK = "Untitled session"
type SplashInput = {
title: string | undefined

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -71,18 +71,15 @@ function traceCommit(commit: StreamCommit) {
part: commit.part
? {
id: commit.part.id,
sessionID: commit.part.sessionID,
messageID: commit.part.messageID,
callID: commit.part.callID,
tool: commit.part.tool,
tool: commit.part.name,
state: {
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),
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,
}
@ -106,37 +103,30 @@ export function traceSubagentState(state: FooterSubagentState) {
action: item.action,
resources: item.resources,
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
? {
keys: Object.keys(item.metadata),
input: summarize(item.metadata.input),
}
: undefined,
})),
questions: state.questions.map((item) => ({
forms: state.forms.map((item) => ({
id: item.id,
sessionID: item.sessionID,
questions: item.questions.map((question) => ({
header: question.header,
question: question.question,
options: question.options.length,
multiple: question.multiple,
})),
title: item.title,
fields: item.fields.map((field) => ({ key: field.key, type: field.type })),
location: item.location,
})),
}
}
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.
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
for (const commit of out.commits) {

View file

@ -7,7 +7,7 @@
// palette if detection fails.
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
import type { EntryKind } from "./types"
import type { EntryKind, RunTuiConfig } from "./types"
type Tone = {
body: ColorInput
@ -20,7 +20,6 @@ export type RunSplashTheme = {
left: ColorInput
right: ColorInput
leftShadow: ColorInput
rightShadow: ColorInput
}
export type RunFooterTheme = {
@ -28,7 +27,6 @@ export type RunFooterTheme = {
selected: ColorInput
selectedText: ColorInput
warning: ColorInput
success: ColorInput
error: ColorInput
muted: ColorInput
text: ColorInput
@ -42,12 +40,9 @@ export type RunFooterTheme = {
}
export type RunBlockTheme = {
highlight: ColorInput
warning: ColorInput
text: ColorInput
muted: ColorInput
syntax?: SyntaxStyle
diffAdded: ColorInput
diffRemoved: ColorInput
diffAddedBg: ColorInput
diffRemovedBg: ColorInput
@ -460,7 +455,6 @@ function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
left,
right,
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,
selectedText: footerTheme.selectedListItemText,
warning: footerTheme.warning,
success: footerTheme.success,
error: footerTheme.error,
muted: footerTheme.textMuted,
text: footerTheme.text,
@ -525,12 +518,9 @@ function map(
},
splash,
block: {
highlight: scrollbackTheme.primary,
warning: scrollbackTheme.warning,
text: scrollbackTheme.text,
muted: scrollbackTheme.textMuted,
syntax,
diffAdded: scrollbackTheme.diffAdded,
diffRemoved: scrollbackTheme.diffRemoved,
diffAddedBg: transparent,
diffRemovedBg: transparent,
@ -572,7 +562,6 @@ export const RUN_THEME_FALLBACK: RunTheme = {
selected: seed.text,
selectedText: seed.panel,
warning: seed.warning,
success: seed.success,
error: seed.error,
muted: seed.muted,
text: seed.text,
@ -596,14 +585,10 @@ export const RUN_THEME_FALLBACK: RunTheme = {
left: fallbackSplashLeft,
right: fallbackSplashRight,
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: {
highlight: seed.highlight,
warning: seed.warning,
text: seed.text,
muted: seed.muted,
diffAdded: seed.success,
diffRemoved: seed.error,
diffAddedBg: alpha(seed.success, 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 {
const colors = await renderer.getPalette({
size: 256,
@ -628,19 +613,21 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
// Palette-only terminal reloads can leave renderer.themeMode stale, but
// ANSI slot zero is not the terminal background when OSC 11 is absent.
const pick = colors.defaultBackground
? mode(RGBA.fromHex(colors.defaultBackground))
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
const indexed = indexedPalette(colors, 256)
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
const pick =
config?.mode === "dark" || config?.mode === "light"
? config.mode
: colors.defaultBackground
? mode(RGBA.fromHex(colors.defaultBackground))
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
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 = {
...scrollbackTheme,
_hasSelectedListItemText: true,
}
const syntax = generateSyntax(syntaxTheme)
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), generateSyntax(syntaxTheme))
} catch {
return RUN_THEME_FALLBACK
}

View file

@ -1,6 +1,6 @@
// 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:
//
// view → visibility policy for progress/final scrollback entries and
@ -15,9 +15,10 @@
import os from "os"
import path from "path"
import stripAnsi from "strip-ansi"
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { LANGUAGE_EXTENSIONS } from "../util/filetype"
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"
@ -32,10 +33,9 @@ export type ToolPhase = "start" | "progress" | "final"
export type ToolDict = Record<string, unknown>
type PatchFile = {
type?: string
relativePath?: string
filePath?: string
movePath?: string
status?: string
file?: string
from?: string
patch?: string
deletions?: number
}
@ -43,11 +43,9 @@ type PatchFile = {
type ToolInput = ToolDict & {
path?: string
pattern?: string
filePath?: string
filepath?: string
url?: string
query?: string
subagent_type?: string
agent?: string
description?: string
name?: string
operation?: string
@ -71,6 +69,7 @@ type ToolMetadata = ToolDict & {
}
export type ToolFrame = {
directory?: string
raw: string
name: string
input: ToolDict
@ -78,6 +77,11 @@ export type ToolFrame = {
state: ToolDict
status: string
error: string
output: string
time: {
start?: number
end?: number
}
}
export type ToolInline = {
@ -93,6 +97,7 @@ export type ToolPermissionInfo = {
title: string
lines: string[]
diff?: string
patch?: string
file?: string
}
@ -103,12 +108,14 @@ export type ToolProps = {
}
type ToolPermissionProps = {
directory?: string
input: ToolInput
metadata: ToolMetadata
patterns: string[]
}
type ToolPermissionCtx = {
directory?: string
input: ToolDict
meta: ToolDict
patterns: string[]
@ -121,7 +128,7 @@ type ToolName =
| "edit"
| "patch"
| "batch"
| "task"
| "subagent"
| "question"
| "read"
| "glob"
@ -163,6 +170,7 @@ function props(frame: ToolFrame): ToolProps {
function permission(ctx: ToolPermissionCtx): ToolPermissionProps {
return {
directory: ctx.directory,
input: ctx.input,
metadata: ctx.meta,
patterns: ctx.patterns,
@ -181,10 +189,87 @@ function text(v: unknown): 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.
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")
}
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 {
if (typeof v !== "number" || !Number.isFinite(v)) {
return undefined
@ -217,10 +302,9 @@ function info(data: ToolDict, skip: string[] = []): string {
return `[${list.map(([key, val]) => `${key}=${String(val)}`).join(", ")}]`
}
function span(state: ToolDict): string {
const time = dict(state.time)
const start = num(time.start)
const end = num(time.end)
function span(frame: ToolFrame): string {
const start = frame.time.start
const end = frame.time.end
if (start === undefined || end === undefined || end <= start) {
return ""
}
@ -268,7 +352,7 @@ function fallbackFinal(ctx: ToolFrame): string {
return ctx.raw.trim()
}
const time = span(ctx.state)
const time = span(ctx)
if (!time) {
return `${ctx.name} completed`
}
@ -276,12 +360,12 @@ function fallbackFinal(ctx: ToolFrame): string {
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) {
return ""
}
const cwd = process.cwd()
const cwd = opts.directory ?? process.cwd()
const home = os.homedir()
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input)
const rel = path.relative(cwd, abs)
@ -301,8 +385,12 @@ export function toolPath(input?: string, opts: { home?: boolean } = {}): string
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 {
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 {
icon: "⚙",
@ -317,7 +405,7 @@ function count(n: number, label: string): string {
function runGlob(p: ToolProps): ToolInline {
const root = p.input.path ?? ""
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 description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
return {
@ -330,7 +418,7 @@ function runGlob(p: ToolProps): ToolInline {
function runGrep(p: ToolProps): ToolInline {
const root = p.input.path ?? ""
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 description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
return {
@ -344,13 +432,13 @@ function runList(p: ToolProps): ToolInline {
const dir = text(dict(p.input).path)
return {
icon: "→",
title: dir ? `List ${toolPath(dir)}` : "List",
title: dir ? `List ${displayPath(p, dir)}` : "List",
}
}
function runRead(p: ToolProps): ToolInline {
const file = toolPath(p.input.filePath)
const description = info(p.frame.input, ["filePath"]) || undefined
const file = displayPath(p, p.input.path)
const description = info(p.frame.input, ["path"]) || undefined
return {
icon: "→",
title: `Read ${file}`,
@ -361,9 +449,9 @@ function runRead(p: ToolProps): ToolInline {
function runWrite(p: ToolProps): ToolInline {
return {
icon: "←",
title: `Write ${toolPath(p.input.filePath)}`,
title: `Write ${displayPath(p, p.input.path)}`,
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 {
const file = list<PatchFile>(p.metadata.files)[0]
return {
icon: "←",
title: `Edit ${toolPath(p.input.filePath)}`,
title: `Edit ${displayPath(p, p.input.path)}`,
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 {
const kind = Locale.titlecase(p.input.subagent_type || "unknown")
const kind = Locale.titlecase(p.input.agent || "unknown")
const desc = p.input.description
const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓"
return {
icon,
title: desc || `${kind} Task`,
title: desc || `${kind} Subagent`,
description: desc ? `${kind} Agent` : undefined,
}
}
@ -436,9 +525,9 @@ function runQuestion(p: ToolProps): ToolInline {
function runInvalid(p: ToolProps): ToolInline {
return {
icon: "✗",
title: text(p.frame.state.title) || "Invalid Tool",
title: "Invalid Tool",
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
return {
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",
body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
body: p.frame.status === "completed" ? p.frame.output : undefined,
}
}
function lspTitle(
input: {
operation?: string
filePath?: string
path?: string
line?: number
character?: number
},
opts: { home?: boolean } = {},
opts: { home?: boolean; directory?: string } = {},
): string {
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 char = typeof input.character === "number" ? input.character : undefined
const pos = line !== undefined && char !== undefined ? `:${line}:${char}` : ""
@ -476,37 +565,35 @@ function lspTitle(
function runLsp(p: ToolProps): ToolInline {
return {
icon: "→",
title: text(p.frame.state.title) || lspTitle(p.input),
title: lspTitle(p.input, { directory: p.frame.directory }),
}
}
function runPlanExit(p: ToolProps): ToolInline {
return {
icon: "→",
title: text(p.frame.state.title) || "Switching to build agent",
title: "Switching to build agent",
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 {
const rel = file.relativePath
const from = file.filePath
if (file.type === "add") {
return `# Created ${rel || toolPath(from)}`
function patchTitle(file: PatchFile, directory?: string): string {
if (file.status === "added") {
return `# Created ${toolPath(file.file, { directory })}`
}
if (file.type === "delete") {
return `# Deleted ${rel || toolPath(from)}`
if (file.status === "deleted") {
return `# Deleted ${toolPath(file.file, { directory })}`
}
if (file.type === "move") {
return `# Moved ${toolPath(from)} -> ${rel || toolPath(file.movePath)}`
if (file.status === "moved") {
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 {
const file = p.input.filePath || ""
const file = p.input.path || ""
const content = p.input.content || ""
if (!file && !content) {
return undefined
@ -514,15 +601,16 @@ function snapWrite(p: ToolProps): ToolSnapshot | undefined {
return {
kind: "code",
title: `# Wrote ${toolPath(file)}`,
title: `# Wrote ${displayPath(p, file)}`,
content,
file,
}
}
function snapEdit(p: ToolProps): ToolSnapshot | undefined {
const file = p.input.filePath || ""
const diff = p.metadata.diff || ""
const item = list<PatchFile>(p.metadata.files)[0]
const file = item?.file || p.input.path || ""
const diff = item?.patch || p.metadata.diff || ""
if (!file || !diff.trim()) {
return undefined
}
@ -531,7 +619,7 @@ function snapEdit(p: ToolProps): ToolSnapshot | undefined {
kind: "diff",
items: [
{
title: `# Edited ${toolPath(file)}`,
title: `# Edited ${displayPath(p, file)}`,
diff,
file,
},
@ -555,10 +643,10 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefined {
return []
}
const name = file.movePath || file.filePath || file.relativePath
const name = file.file
return [
{
title: patchTitle(file),
title: patchTitle(file, p.frame.directory),
diff,
file: name,
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
}
@ -577,14 +665,13 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefined {
}
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 title = text(p.frame.state.title)
const rows = [desc || title].filter((item): item is string => Boolean(item))
const rows = [desc].filter((item): item is string => Boolean(item))
return {
kind: "task",
title: `# ${kind} Task`,
title: `# ${kind} Subagent`,
rows,
tail: "",
}
@ -610,7 +697,7 @@ function snapQuestion(p: ToolProps): ToolSnapshot {
function scrollBashStart(p: ToolProps): string {
const cmd = p.input.command ?? ""
const wd = p.input.workdir ?? ""
const formatted = wd && wd !== "." ? toolPath(wd) : ""
const formatted = wd && wd !== "." ? displayPath(p, wd) : ""
const dir = formatted === "." ? "" : formatted
if (cmd && !dir) {
return `$ ${cmd}`
@ -636,7 +723,7 @@ function scrollBashProgress(p: ToolProps): string {
}
const wdRaw = (p.input.workdir ?? "").trim()
const wd = wdRaw ? toolPath(wdRaw) : ""
const wd = wdRaw ? displayPath(p, wdRaw) : ""
const lines = out.split("\n")
const first = (lines[0] || "").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 time = span(p.frame.state)
const time = span(p.frame)
if (code === undefined) {
if (!time) {
return "shell completed"
@ -675,8 +762,8 @@ function scrollShellFinal(p: ToolProps): string {
}
function scrollReadStart(p: ToolProps): string {
const file = toolPath(p.input.filePath)
const extra = info(p.frame.input, ["filePath"])
const file = displayPath(p, p.input.path)
const extra = info(p.frame.input, ["path"])
const tail = extra ? ` ${extra}` : ""
return `→ Read ${file}${tail}`.trim()
}
@ -693,24 +780,19 @@ function scrollPatchStart(_: ToolProps): string {
return ""
}
function patchLine(file: PatchFile): string {
const type = file.type
const rel = file.relativePath
const from = file.filePath
if (type === "add") {
return `+ Created ${rel || toolPath(from)}`
function patchLine(file: PatchFile, directory?: string): string {
if (file.status === "added") {
return `+ Created ${toolPath(file.file, { directory })}`
}
if (type === "delete") {
return `- Deleted ${rel || toolPath(from)}`
if (file.status === "deleted") {
return `- Deleted ${toolPath(file.file, { directory })}`
}
if (file.status === "moved") {
return `→ Moved ${toolPath(file.from, { directory })} → ${toolPath(file.file, { directory })}`
}
if (type === "move") {
return `→ Moved ${toolPath(from)}${rel || toolPath(file.movePath)}`
}
return `~ Patched ${rel || toolPath(from)}`
return `~ Patched ${toolPath(file.file, { directory })}`
}
function scrollPatchFinal(p: ToolProps): string {
@ -720,7 +802,7 @@ function scrollPatchFinal(p: ToolProps): string {
const files = list<PatchFile>(p.frame.meta.files)
if (files.length === 0) {
const time = span(p.frame.state)
const time = span(p.frame)
if (!time) {
return "patch"
}
@ -728,9 +810,9 @@ function scrollPatchFinal(p: ToolProps): string {
return `patch · ${time}`
}
const show_updates = !files.some((file) => file?.type && file.type !== "update")
const shown = files.filter((file) => show_updates || file.type !== "update")
const rows = shown.slice(0, 6).map(patchLine)
const showModified = !files.some((file) => file?.status && file.status !== "modified")
const shown = files.filter((file) => showModified || file.status !== "modified")
const rows = shown.slice(0, 6).map((file) => patchLine(file, p.frame.directory))
if (shown.length > 6) {
rows.push(`... and ${shown.length - 6} more`)
}
@ -739,7 +821,7 @@ function scrollPatchFinal(p: ToolProps): string {
return rows.join("\n")
}
return patchLine(files[0]!)
return patchLine(files[0]!, p.frame.directory)
}
function scrollTaskStart(_: ToolProps): string {
@ -769,13 +851,13 @@ function scrollTaskFinal(p: ToolProps): string {
return fail(p.frame)
}
const kind = Locale.titlecase(p.input.subagent_type || "general")
const row = p.input.description || text(p.frame.state.title)
const kind = Locale.titlecase(p.input.agent || "general")
const row = p.input.description
if (!row) {
return `# ${kind} Task`
return `# ${kind} Subagent`
}
return `# ${kind} Task\n${row}`
return `# ${kind} Subagent\n${row}`
}
function scrollQuestionStart(_: ToolProps): string {
@ -785,7 +867,7 @@ function scrollQuestionStart(_: ToolProps): string {
function scrollQuestionFinal(p: ToolProps): string {
const q = p.input.questions ?? []
const a = p.metadata.answers ?? []
const time = span(p.frame.state)
const time = span(p.frame)
if (q.length === 0) {
if (!time) {
return "0 questions"
@ -810,7 +892,7 @@ function scrollQuestionFinal(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 {
@ -825,7 +907,7 @@ function scrollGlobStart(p: ToolProps): string {
return head
}
return `${head} in ${toolPath(dir)}`
return `${head} in ${displayPath(p, dir)}`
}
function scrollGlobFinal(p: ToolProps): string {
@ -840,7 +922,7 @@ function scrollGrepStart(p: ToolProps): string {
return head
}
return `${head} in ${toolPath(dir)}`
return `${head} in ${displayPath(p, dir)}`
}
function scrollListStart(p: ToolProps): string {
@ -849,7 +931,7 @@ function scrollListStart(p: ToolProps): string {
return "→ List"
}
return `→ List ${toolPath(dir)}`
return `→ List ${displayPath(p, dir)}`
}
function scrollWebfetchStart(p: ToolProps): string {
@ -872,23 +954,24 @@ function scrollWebSearchStart(p: ToolProps): string {
}
function permEdit(p: ToolPermissionProps): ToolPermissionInfo {
const input = p.input as { filePath?: string; filepath?: string; diff?: string }
const file = input.filePath || input.filepath || p.patterns[0] || ""
const file = p.input.path || p.patterns[0] || ""
const diff = (list<PatchFile>(p.metadata.files)[0]?.patch ?? text(p.metadata.diff)) || undefined
return {
icon: "→",
title: `Edit ${toolPath(file, { home: true })}`,
title: `Edit ${toolPath(file, { home: true, directory: p.directory })}`,
lines: [],
diff: p.metadata.diff ?? input.diff,
diff,
patch: diff ? undefined : text(p.input.patchText) || undefined,
file,
}
}
function permRead(p: ToolPermissionProps): ToolPermissionInfo {
const file = p.input.filePath || p.patterns[0] || ""
const file = p.input.path || p.patterns[0] || ""
return {
icon: "→",
title: `Read ${toolPath(file, { home: true })}`,
lines: file ? [`Path: ${toolPath(file, { home: true })}`] : [],
title: `Read ${toolPath(file, { home: true, directory: p.directory })}`,
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] || ""
return {
icon: "→",
title: `List ${toolPath(dir, { home: true })}`,
lines: dir ? [`Path: ${toolPath(dir, { home: true })}`] : [],
title: `List ${toolPath(dir, { home: true, directory: p.directory })}`,
lines: dir ? [`Path: ${toolPath(dir, { home: true, directory: p.directory })}`] : [],
}
}
@ -929,11 +1012,11 @@ function permBash(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
return {
icon: "#",
title: `${Locale.titlecase(type)} Task`,
title: `${Locale.titlecase(type)} Subagent`,
lines: desc ? [`${desc}`] : [],
}
}
@ -958,16 +1041,16 @@ function permWebSearch(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 char = typeof p.input.character === "number" ? p.input.character : undefined
const pos = line !== undefined && char !== undefined ? `${line}:${char}` : undefined
return {
icon: "→",
title: lspTitle(p.input, { home: true }),
title: lspTitle(p.input, { home: true, directory: p.directory }),
lines: [
...(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}`] : []),
],
}
@ -1045,7 +1128,7 @@ const TOOL_RULES = {
start: () => "",
},
},
task: {
subagent: {
view: {
output: false,
final: true,
@ -1184,29 +1267,52 @@ function rule(name?: string): AnyToolRule | undefined {
return TOOL_RULES[name]
}
function frame(part: MiniToolPart): ToolFrame {
const state = dict(part.state)
function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame {
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 {
raw: "",
name: part.tool,
input: dict(state.input),
meta: "metadata" in part.state ? dict(part.state.metadata) : {},
state,
status: text(state.status),
error: text(state.error),
directory,
raw: output,
name: tool.name,
input: normalizeInput(tool.name, tool.state.input),
meta: normalizeStructured(tool.name, tool.state.structured),
state: dict(tool.state),
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 {
const state = dict(commit.part?.state)
const current = commit.part ? frame(commit.part, commit.directory) : undefined
return {
directory: commit.directory,
raw,
name: commit.tool || commit.part?.tool || "tool",
input: dict(state.input),
meta: commit.part?.state && "metadata" in commit.part.state ? dict(commit.part.state.metadata) : {},
state,
status: commit.toolState ?? text(state.status),
error: (commit.toolError ?? "").trim(),
name: canonicalToolName(commit.tool || current?.name || "tool"),
input: current?.input ?? {},
meta: current?.meta ?? {},
state: current?.state ?? {},
status: commit.toolState ?? current?.status ?? "",
error: (commit.toolError ?? current?.error ?? "").trim(),
output: current?.output ?? raw,
time: current?.time ?? {},
}
}
@ -1215,13 +1321,13 @@ function runShell(p: ToolProps): ToolInline {
icon: "$",
title: p.input.command || "",
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 {
return (
rule(name)?.view ?? {
rule(name ? canonicalToolName(name) : undefined)?.view ?? {
output: true,
final: true,
}
@ -1234,12 +1340,12 @@ export function toolStructuredFinal(commit: StreamCommit): boolean {
commit.kind === "tool" &&
commit.phase === "final" &&
state === "completed" &&
Boolean(toolView(commit.tool ?? commit.part?.tool).snap)
Boolean(toolView(commit.tool ?? commit.part?.name).snap)
)
}
export function toolInlineInfo(part: MiniToolPart): ToolInline {
const ctx = frame(part)
export function toolInlineInfo(part: SessionMessageAssistantTool, directory?: string): ToolInline {
const ctx = frame(part, directory)
const draw = rule(ctx.name)?.run
try {
if (draw) {
@ -1284,14 +1390,23 @@ export function toolPermissionInfo(
input: ToolDict,
meta: ToolDict,
patterns: string[],
directory?: string,
): ToolPermissionInfo | undefined {
const draw = rule(name)?.permission
const normalized = canonicalToolName(name)
const draw = rule(normalized)?.permission
if (!draw) {
return undefined
}
try {
return draw(permission({ input, meta, patterns }))
return draw(
permission({
directory,
input: normalizeInput(normalized, input),
meta: normalizeStructured(normalized, meta),
patterns,
}),
)
} catch {
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 {
const body = stripAnsi(raw).replace(/^\n+/, "").replace(/\n+$/, "")
if (!body) {
@ -1379,13 +1511,13 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody |
const ctx = toolFrame(commit, raw)
const view = toolView(ctx.name)
if (ctx.name === "task") {
if (ctx.name === "subagent") {
if (commit.phase === "start") {
return undefined
}
if (commit.phase === "final" && ctx.status === "completed") {
const result = taskResult(text(ctx.state.output))
const result = taskResult(ctx.output)
if (result) {
return markdownBody(result)
}
@ -1412,6 +1544,10 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody |
if (toolStructuredFinal(commit)) {
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))

View file

@ -2,7 +2,7 @@
//
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
// 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.
//
// Data flow through the system:
@ -12,12 +12,16 @@
// → footer.ts queues commits and patches the footer view
// → OpenTUI split-footer renderer writes to terminal
import type {
FormAnswer,
FormInfo,
OpenCodeClient,
LocationGetOutput,
LocationRef,
PermissionV2Request,
QuestionV2Request,
ReferenceListOutput,
SessionMessageAssistantTool,
} from "@opencode-ai/client/promise"
import type { TuiConfig } from "../config/v1"
import type { Config } from "../config"
import type { CliRenderer } from "@opentui/core"
export type RunFilePart = {
@ -47,63 +51,25 @@ export type RunCommand = {
name: string
description?: string
source?: string
template?: string
hints?: unknown[]
agent?: string
model?: {
[key: string]: unknown
}
subtask?: boolean
}
export type RunProviderModel = {
id: string
providerID: string
api?: {
[key: string]: unknown
}
name?: string
capabilities?: {
[key: string]: unknown
}
cost?: {
input: number
output?: number
cache?: {
read: number
write: number
}
}
limit?: {
context: number
input?: number
output?: number
}
status?: string
options?: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
release_date?: string
variants?: Record<string, unknown>
}
export type RunProvider = {
id: string
name: string
source?: string
env?: string[]
options?: {
[key: string]: unknown
}
models: Record<string, RunProviderModel>
}
export type RunPrompt = {
messageID?: string
partID?: string
text: string
parts: RunPromptPart[]
mode?: "shell"
@ -117,14 +83,12 @@ export type RunPrompt = {
export type FooterQueuedPrompt = {
messageID: string
partID: string
prompt: RunPrompt
}
export type RunAgent = {
id: string
name: string
description?: string
mode: "subagent" | "primary" | "all"
hidden: boolean
}
@ -133,25 +97,17 @@ export type RunReference = ReferenceListOutput["data"][number]
export type RunInput = {
sdk: OpenCodeClient
directory: string
sessionID: string
sessionTitle?: string
resume?: boolean
replay?: boolean
replayLimit?: number
location: LocationGetOutput
agent: string | undefined
model: PromptModel | undefined
variant: string | undefined
files: RunFilePart[]
initialInput?: string
thinking: boolean
demo?: boolean
}
export type MiniHost = {
terminal: {
stdin: NodeJS.ReadStream
cleanup(): void
}
platform: NodeJS.Platform
stdout: {
@ -170,8 +126,6 @@ export type MiniHost = {
}
paths: {
home: string
state: string
log: string
}
signals: {
sigint: {
@ -186,9 +140,6 @@ export type MiniHost = {
now(): number
}
diagnostics: {
pid: number
cwd: string
argv: readonly string[]
trace?: {
write(type: string, data?: unknown): void
}
@ -212,7 +163,6 @@ export type FooterState = {
status: string
queue: number
model: string
duration: string
usage: string
first: boolean
interrupt: number
@ -222,8 +172,6 @@ export type FooterState = {
// A partial update to FooterState. The footer merges this onto the current state.
export type FooterPatch = Partial<FooterState>
export type RunDiffStyle = "auto" | "stacked"
export type TurnSummary = {
agent: string
model: string
@ -231,7 +179,6 @@ export type TurnSummary = {
}
export type ScrollbackOptions = {
diffStyle?: RunDiffStyle
suppressBackgrounds?: boolean
}
@ -295,6 +242,8 @@ export type MiniToolState =
time: { start: number; end: number }
}
// Retained only for the noninteractive run JSON/V1 compatibility boundary.
// Interactive Mini commits carry SessionMessageAssistantTool directly.
export type MiniToolPart = {
id: string
sessionID: string
@ -305,6 +254,14 @@ export type MiniToolPart = {
state: MiniToolState
}
export type MiniPermissionRequest = PermissionV2Request & {
tool?: SessionMessageAssistantTool
}
export type MiniFormRequest = FormInfo & {
location?: LocationRef
}
export type EntryLayout = "inline" | "block"
export type RunEntryBody =
@ -320,8 +277,8 @@ export type RunEntryBody =
// "prompt".
export type FooterView =
| { type: "prompt" }
| { type: "permission"; request: PermissionV2Request }
| { type: "question"; request: QuestionV2Request }
| { type: "permission"; request: MiniPermissionRequest }
| { type: "form"; request: MiniFormRequest }
export type FooterPromptRoute =
| { type: "composer" }
@ -335,27 +292,23 @@ export type FooterPromptRoute =
export type FooterSubagentTab = {
sessionID: string
partID: string
callID: string
label: string
description: string
status: "running" | "completed" | "cancelled" | "error"
background?: boolean
title?: string
toolCalls?: number
lastUpdatedAt: number
}
export type FooterSubagentDetail = {
sessionID: string
commits: StreamCommit[]
}
export type FooterSubagentState = {
tabs: FooterSubagentTab[]
details: Record<string, FooterSubagentDetail>
permissions: PermissionV2Request[]
questions: QuestionV2Request[]
permissions: MiniPermissionRequest[]
forms: MiniFormRequest[]
}
// 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"
history: RunPrompt[]
}
| {
type: "agent"
agent: string | undefined
}
| {
type: "catalog"
agents: RunAgent[]
@ -409,9 +366,6 @@ export type FooterEvent =
type: "turn.send"
queue: number
}
| {
type: "turn.wait"
}
| {
type: "turn.idle"
queue: number
@ -433,16 +387,22 @@ export type FooterEvent =
state: FooterSubagentState
}
export type PermissionReply = Omit<Parameters<OpenCodeClient["permission"]["reply"]>[0], "sessionID">
export type PermissionReply = Parameters<OpenCodeClient["permission"]["reply"]>[0]
export type QuestionReply = {
requestID: string
answers: string[][]
export type FormReply = {
sessionID: 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"
// appends content (coalesced in the footer queue), "final" closes it.
@ -465,29 +425,18 @@ export type StreamCommit = {
messageID?: string
partID?: string
tool?: string
part?: MiniToolPart
directory?: string
part?: SessionMessageAssistantTool
interrupted?: boolean
toolState?: StreamToolState
toolError?: string
shell?: {
callID: string
command: string
}
}
export type LocalReplayAnchor = {
kind: EntryKind
text: string
phase: StreamPhase
messageID?: string
partID?: string
toolState?: StreamToolState
visible?: string
}
export type LocalReplayRow = {
commit: StreamCommit
after?: LocalReplayAnchor
}
// The public contract between the stream transport / prompt queue and

View file

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