feat(opencode): add interactive split-footer mode to run (#23557)

This commit is contained in:
Simon Klee
2026-05-08 12:17:14 +02:00
committed by GitHub
parent 15784aa036
commit 7f2b5ee8c2
60 changed files with 21850 additions and 347 deletions
+471 -319
View File
@@ -1,3 +1,16 @@
// CLI entry point for `opencode run`.
//
// Handles three modes:
// 1. Non-interactive (default): sends a single prompt, streams events to
// stdout, and exits when the session goes idle.
// 2. Interactive local (`--interactive`): boots the split-footer direct mode
// with an in-process server (no external HTTP).
// 3. Interactive attach (`--interactive --attach`): connects to a running
// opencode server and runs interactive mode against it.
//
// Also supports `--command` for slash-command execution, `--format json` for
// raw event streaming, `--continue` / `--session` for session resumption,
// and `--fork` for forking before continuing.
import type { Argv } from "yargs"
import path from "path"
import { pathToFileURL } from "url"
@@ -9,38 +22,39 @@ import { ServerAuth } from "@/server/auth"
import { EOL } from "os"
import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "@/provider/provider"
import { Agent } from "../../agent/agent"
import { Permission } from "../../permission"
import { Tool } from "@/tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { ShellTool } from "../../tool/shell"
import { ShellID } from "../../tool/shell/id"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "@/util/locale"
import { Agent } from "@/agent/agent"
import { Permission } from "@/permission"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
type ToolProps<T> = {
input: Tool.InferParameters<T>
metadata: Tool.InferMetadata<T>
part: ToolPart
const runtimeTask = import("./run/runtime")
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
function pick(value: string | undefined): ModelInput | undefined {
if (!value) return undefined
const [providerID, ...rest] = value.split("/")
return {
providerID,
modelID: rest.join("/"),
} as ModelInput
}
function props<T>(part: ToolPart): ToolProps<T> {
const state = part.state
return {
input: state.input as Tool.InferParameters<T>,
metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
part,
function resolveRunInput(value?: string, piped?: string): string | undefined {
if (!value) {
return piped
}
if (!piped) {
return value
}
return value + "\n" + piped
}
type FilePart = {
type: "file"
url: string
filename: string
mime: string
}
type Inline = {
@@ -49,6 +63,12 @@ type Inline = {
description?: string
}
type SessionInfo = {
id: string
title?: string
directory?: string
}
function inline(info: Inline) {
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
@@ -62,145 +82,40 @@ function block(info: Inline, output?: string) {
UI.empty()
}
function fallback(part: ToolPart) {
const state = part.state
const input = "input" in state ? state.input : undefined
const title =
("title" in state && state.title ? state.title : undefined) ||
(input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
inline({
icon: "⚙",
title: `${part.tool} ${title}`,
})
async function tool(part: ToolPart) {
try {
const { toolInlineInfo } = await import("./run/tool")
const next = toolInlineInfo(part)
if (next.mode === "block") {
block(next, next.body)
return
}
inline(next)
} catch {
inline({
icon: "\u2699",
title: part.tool,
})
}
}
function glob(info: ToolProps<typeof GlobTool>) {
const root = info.input.path ?? ""
const title = `Glob "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.count
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function grep(info: ToolProps<typeof GrepTool>) {
const root = info.input.path ?? ""
const title = `Grep "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.matches
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function read(info: ToolProps<typeof ReadTool>) {
const file = normalizePath(info.input.filePath)
const pairs = Object.entries(info.input).filter(([key, value]) => {
if (key === "filePath") return false
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
})
const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined
inline({
icon: "→",
title: `Read ${file}`,
...(description && { description }),
})
}
function write(info: ToolProps<typeof WriteTool>) {
block(
{
icon: "←",
title: `Write ${normalizePath(info.input.filePath)}`,
},
info.part.state.status === "completed" ? info.part.state.output : undefined,
)
}
function webfetch(info: ToolProps<typeof WebFetchTool>) {
inline({
icon: "%",
title: `WebFetch ${info.input.url}`,
})
}
function edit(info: ToolProps<typeof EditTool>) {
const title = normalizePath(info.input.filePath)
const diff = info.metadata.diff
block(
{
icon: "←",
title: `Edit ${title}`,
},
diff,
)
}
function websearch(info: ToolProps<typeof WebSearchTool>) {
inline({
icon: "◈",
title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`,
})
}
function task(info: ToolProps<typeof TaskTool>) {
const input = info.part.state.input
const status = info.part.state.status
const subagent =
typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown"
const agent = Locale.titlecase(subagent)
const desc =
typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined
const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓"
const name = desc ?? `${agent} Task`
inline({
icon,
title: name,
description: desc ? `${agent} Agent` : undefined,
})
}
function skill(info: ToolProps<typeof SkillTool>) {
inline({
icon: "→",
title: `Skill "${info.input.name}"`,
})
}
function shell(info: ToolProps<typeof ShellTool>) {
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
block(
{
icon: "$",
title: `${info.input.command}`,
},
output,
)
}
function todo(info: ToolProps<typeof TodoWriteTool>) {
block(
{
icon: "#",
title: "Todos",
},
info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"),
)
}
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
return input
async function toolError(part: ToolPart) {
try {
const { toolInlineInfo } = await import("./run/tool")
const next = toolInlineInfo(part)
inline({
icon: "✗",
title: `${next.title} failed`,
...(next.description && { description: next.description }),
})
return
} catch {
inline({
icon: "✗",
title: `${part.tool} failed`,
})
}
}
export const RunCommand = effectCmd({
@@ -296,38 +211,98 @@ export const RunCommand = effectCmd({
.option("thinking", {
type: "boolean",
describe: "show thinking blocks",
})
.option("interactive", {
alias: ["i"],
type: "boolean",
describe: "run in direct interactive split-footer mode",
default: false,
})
.option("dangerously-skip-permissions", {
type: "boolean",
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
default: false,
})
.option("demo", {
type: "boolean",
default: false,
describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately",
}),
handler: Effect.fn("Cli.run")(function* (args) {
const agentSvc = yield* Agent.Service
yield* Effect.promise(async () => {
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
const die = (message: string): never => {
UI.error(message)
process.exit(1)
}
const dieInteractive = (error: unknown): never => {
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) {
die(error.message)
}
throw error
}
let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
const directory = (() => {
if (!args.dir) return undefined
if (args.attach) return args.dir
if (args.interactive && args.command) {
die("--interactive cannot be used with --command")
}
if (args.demo && !args.interactive) {
die("--demo requires --interactive")
}
if (args.interactive && args.format === "json") {
die("--interactive cannot be used with --format json")
}
if (args.interactive && !process.stdout.isTTY) {
die("--interactive requires a TTY stdout")
}
if (args.interactive) {
try {
process.chdir(args.dir)
resolveInteractiveStdin().cleanup?.()
} catch (error) {
dieInteractive(error)
}
}
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const directory = (() => {
if (!args.dir) return args.attach ? undefined : root
if (args.attach) return args.dir
try {
process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))
return process.cwd()
} catch {
UI.error("Failed to change directory to " + args.dir)
process.exit(1)
}
})()
const attachHeaders = args.attach
? ServerAuth.headers({ password: args.password, username: args.username })
: undefined
const attachSDK = (dir?: string) => {
return createOpencodeClient({
baseUrl: args.attach!,
directory: dir,
headers: attachHeaders,
})
}
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
const files: FilePart[] = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath)
const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath)
if (!(await Filesystem.exists(resolvedPath))) {
UI.error(`File not found: ${filePath}`)
process.exit(1)
@@ -344,9 +319,11 @@ export const RunCommand = effectCmd({
}
}
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
message = resolveRunInput(message, piped) ?? ""
const initialInput = resolveRunInput(rawMessage, piped)
if (message.trim().length === 0 && !args.command) {
if (message.trim().length === 0 && !args.command && !args.interactive) {
UI.error("You must provide a message or a command")
process.exit(1)
}
@@ -356,23 +333,25 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const rules: Permission.Ruleset = [
{
permission: "question",
action: "deny",
pattern: "*",
},
{
permission: "plan_enter",
action: "deny",
pattern: "*",
},
{
permission: "plan_exit",
action: "deny",
pattern: "*",
},
]
const rules: Permission.Ruleset = args.interactive
? []
: [
{
permission: "question",
action: "deny",
pattern: "*",
},
{
permission: "plan_enter",
action: "deny",
pattern: "*",
},
{
permission: "plan_exit",
action: "deny",
pattern: "*",
},
]
function title() {
if (args.title === undefined) return
@@ -380,19 +359,83 @@ export const RunCommand = effectCmd({
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient) {
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
if (args.session) {
const current = await sdk.session
.get({
sessionID: args.session,
})
.catch(() => undefined)
if (baseID && args.fork) {
const forked = await sdk.session.fork({ sessionID: baseID })
return forked.data?.id
if (!current?.data) {
UI.error("Session not found")
process.exit(1)
}
if (args.fork) {
const forked = await sdk.session.fork({
sessionID: args.session,
})
const id = forked.data?.id
if (!id) {
return
}
return {
id,
title: forked.data?.title ?? current.data.title,
directory: forked.data?.directory ?? current.data.directory,
}
}
return {
id: current.data.id,
title: current.data.title,
directory: current.data.directory,
}
}
if (baseID) return baseID
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
if (base && args.fork) {
const forked = await sdk.session.fork({
sessionID: base.id,
})
const id = forked.data?.id
if (!id) {
return
}
return {
id,
title: forked.data?.title ?? base.title,
directory: forked.data?.directory ?? base.directory,
}
}
if (base) {
return {
id: base.id,
title: base.title,
directory: base.directory,
}
}
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
const result = await sdk.session.create({
title: name,
permission: rules,
})
const id = result.data?.id
if (!id) {
return
}
return {
id,
title: result.data?.title ?? name,
directory: result.data?.directory,
}
}
async function share(sdk: OpencodeClient, sessionID: string) {
@@ -410,43 +453,159 @@ export const RunCommand = effectCmd({
}
}
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
try {
if (part.tool === ShellID.ToolID) return shell(props<typeof ShellTool>(part))
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part))
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
return fallback(part)
} catch {
return fallback(part)
}
async function createFreshSession(
sdk: OpencodeClient,
input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined },
): Promise<SessionInfo> {
const result = await sdk.session.create({
title: args.title !== undefined && args.title !== "" ? args.title : undefined,
agent: input.agent,
model: input.model
? {
providerID: input.model.providerID,
id: input.model.modelID,
variant: input.variant,
}
: undefined,
permission: rules,
})
const id = result.data?.id
if (!id) {
throw new Error("Failed to create session")
}
void share(sdk, id).catch(() => {})
return {
id,
title: result.data?.title,
}
}
async function current(sdk: OpencodeClient): Promise<string> {
if (!args.attach) {
return directory ?? root
}
const next = await sdk.path
.get()
.then((x) => x.data?.directory)
.catch(() => undefined)
if (next) {
return next
}
UI.error("Failed to resolve remote directory")
process.exit(1)
}
async function localAgent() {
if (!args.agent) return undefined
const name = args.agent
const entry = await Effect.runPromise(agentSvc.get(name))
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" not found. Falling back to default agent`,
)
return undefined
}
if (entry.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return name
}
async function attachAgent(sdk: OpencodeClient) {
if (!args.agent) return undefined
const name = args.agent
const modes = await sdk.app
.agents(undefined, { throwOnError: true })
.then((x) => x.data ?? [])
.catch(() => undefined)
if (!modes) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`failed to list agents from ${args.attach}. Falling back to default agent`,
)
return undefined
}
const agent = modes.find((a) => a.name === name)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" not found. Falling back to default agent`,
)
return undefined
}
if (agent.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return name
}
async function pickAgent(sdk: OpencodeClient) {
if (!args.agent) return undefined
if (args.attach) {
return attachAgent(sdk)
}
return localAgent()
}
async function execute(sdk: OpencodeClient) {
const sess = await session(sdk)
if (!sess?.id) {
UI.error("Session not found")
process.exit(1)
}
const sessionID = sess.id
function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
process.stdout.write(
JSON.stringify({
type,
timestamp: Date.now(),
sessionID,
...data,
}) + EOL,
)
return true
}
return false
}
const events = await sdk.event.subscribe()
let error: string | undefined
async function loop() {
// Consume one subscribed event stream for the active session and mirror it
// to stdout/UI. `client` is passed explicitly because attach mode may
// rebind the SDK to the session's directory after the subscription is
// created, and replies issued from inside the loop must use that client.
async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
const toggles = new Map<string, boolean>()
let error: string | undefined
for await (const event of events.stream) {
if (
event.type === "message.updated" &&
event.properties.sessionID === sessionID &&
event.properties.info.role === "assistant" &&
args.format !== "json" &&
toggles.get("start") !== true
@@ -464,16 +623,10 @@ export const RunCommand = effectCmd({
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
if (emit("tool_use", { part })) continue
if (part.state.status === "completed") {
tool(part)
await tool(part)
continue
}
inline({
icon: "✗",
title:
part.tool === "websearch"
? `${webSearchProviderLabel(props<typeof WebSearchTool>(part).metadata.provider)} failed`
: `${part.tool} failed`,
})
await toolError(part)
UI.error(part.state.error)
}
@@ -484,7 +637,7 @@ export const RunCommand = effectCmd({
args.format !== "json"
) {
if (toggles.get(part.id) === true) continue
task(props<typeof TaskTool>(part))
await tool(part)
toggles.set(part.id, true)
}
@@ -509,7 +662,7 @@ export const RunCommand = effectCmd({
UI.empty()
}
if (part.type === "reasoning" && part.time?.end && args.thinking) {
if (part.type === "reasoning" && part.time?.end && thinking) {
if (emit("reasoning", { part })) continue
const text = part.text.trim()
if (!text) continue
@@ -549,7 +702,7 @@ export const RunCommand = effectCmd({
if (permission.sessionID !== sessionID) continue
if (args["dangerously-skip-permissions"]) {
await sdk.permission.reply({
await client.permission.reply({
requestID: permission.id,
reply: "once",
})
@@ -559,7 +712,7 @@ export const RunCommand = effectCmd({
UI.Style.TEXT_NORMAL +
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
await sdk.permission.reply({
await client.permission.reply({
requestID: permission.id,
reply: "reject",
})
@@ -567,114 +720,113 @@ export const RunCommand = effectCmd({
}
}
}
const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
const client = args.attach ? attachSDK(cwd) : sdk
// Validate agent if specified
const agent = await (async () => {
if (!args.agent) return undefined
const name = args.agent
const agent = await pickAgent(client)
// When attaching, validate against the running server instead of local Instance state.
if (args.attach) {
const modes = await sdk.app
.agents(undefined, { throwOnError: true })
.then((x) => x.data ?? [])
.catch(() => undefined)
await share(client, sessionID)
if (!modes) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`failed to list agents from ${args.attach}. Falling back to default agent`,
)
return undefined
}
const agent = modes.find((a) => a.name === name)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" not found. Falling back to default agent`,
)
return undefined
}
if (agent.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return name
}
const entry = await Effect.runPromise(agentSvc.get(name))
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" not found. Falling back to default agent`,
)
return undefined
}
if (entry.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return name
})()
const sessionID = await session(sdk)
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
await share(sdk, sessionID)
loop().catch((e) => {
console.error(e)
process.exit(1)
})
if (args.command) {
await sdk.session.command({
sessionID,
agent,
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
if (!args.interactive) {
const events = await client.event.subscribe()
loop(client, events).catch((e) => {
console.error(e)
process.exit(1)
})
} else {
const model = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({
if (args.command) {
await client.session.command({
sessionID,
agent,
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
})
return
}
const model = pick(args.model)
await client.session.prompt({
sessionID,
agent,
model,
variant: args.variant,
parts: [...files, { type: "text", text: message }],
})
return
}
const model = pick(args.model)
const { runInteractiveMode } = await runtimeTask
try {
await runInteractiveMode({
sdk: client,
directory: cwd,
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session || args.continue) && !args.fork,
agent,
model,
variant: args.variant,
files,
initialInput,
createSession: createFreshSession,
thinking,
demo: args.demo,
})
} catch (error) {
dieInteractive(error)
}
return
}
if (args.interactive && !args.attach && !args.session && !args.continue) {
const model = pick(args.model)
const { runInteractiveLocalMode } = await runtimeTask
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("@/server/server")
const request = new Request(input, init)
return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch
try {
return await runInteractiveLocalMode({
directory: directory ?? root,
fetch: fetchFn,
resolveAgent: localAgent,
session,
share,
createSession: createFreshSession,
agent: args.agent,
model,
variant: args.variant,
files,
initialInput,
thinking,
demo: args.demo,
})
} catch (error) {
dieInteractive(error)
}
}
if (args.attach) {
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
const sdk = attachSDK(directory)
return await execute(sdk)
}
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { Server } = await import("@/server/server")
const request = new Request(input, init)
return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: fetchFn,
directory,
})
await execute(sdk)
})
}),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
import { toolEntryBody } from "./tool"
import type { RunEntryBody, StreamCommit } from "./types"
export type EntryFlags = {
startOnNewLine: boolean
trailingNewline: boolean
}
export const RUN_ENTRY_NONE: RunEntryBody = {
type: "none",
}
export function cleanRunText(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}
function textBody(content: string): RunEntryBody {
if (!content) {
return RUN_ENTRY_NONE
}
return {
type: "text",
content,
}
}
function codeBody(content: string, filetype?: string): RunEntryBody {
if (!content) {
return RUN_ENTRY_NONE
}
return {
type: "code",
content,
filetype,
}
}
function markdownBody(content: string): RunEntryBody {
if (!content) {
return RUN_ENTRY_NONE
}
return {
type: "markdown",
content,
}
}
function userBody(raw: string): RunEntryBody {
if (!raw.trim()) {
return RUN_ENTRY_NONE
}
const lead = raw.match(/^\n+/)?.[0] ?? ""
const body = lead ? raw.slice(lead.length) : raw
return textBody(`${lead} ${body}`)
}
function reasoningBody(raw: string): RunEntryBody {
const clean = raw.replace(/\[REDACTED\]/g, "")
if (!clean) {
return RUN_ENTRY_NONE
}
const lead = clean.match(/^\n+/)?.[0] ?? ""
const body = lead ? clean.slice(lead.length) : clean
const mark = "Thinking:"
if (body.startsWith(mark)) {
return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown")
}
return codeBody(clean, "markdown")
}
function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
return textBody(phase === "progress" ? raw : raw.trim())
}
export function entryFlags(commit: StreamCommit): EntryFlags {
if (commit.kind === "user") {
return {
startOnNewLine: true,
trailingNewline: false,
}
}
if (commit.kind === "tool") {
if (commit.phase === "progress") {
return {
startOnNewLine: false,
trailingNewline: false,
}
}
return {
startOnNewLine: true,
trailingNewline: true,
}
}
if (commit.kind === "assistant" || commit.kind === "reasoning") {
if (commit.phase === "progress") {
return {
startOnNewLine: false,
trailingNewline: false,
}
}
return {
startOnNewLine: true,
trailingNewline: true,
}
}
if (commit.kind === "error") {
return {
startOnNewLine: true,
trailingNewline: false,
}
}
return {
startOnNewLine: true,
trailingNewline: true,
}
}
export function entryDone(commit: StreamCommit): boolean {
if (commit.kind === "assistant" || commit.kind === "reasoning") {
return commit.phase === "final"
}
if (commit.kind === "tool") {
return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed")
}
return true
}
export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean {
if (commit.phase !== "progress") {
return false
}
if (body.type === "none") {
return false
}
if (commit.kind === "tool") {
return commit.toolState !== "completed"
}
return commit.kind === "assistant" || commit.kind === "reasoning"
}
export function entryBody(commit: StreamCommit): RunEntryBody {
const raw = cleanRunText(commit.text)
if (commit.kind === "user") {
return userBody(raw)
}
if (commit.kind === "tool") {
return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE
}
if (commit.kind === "assistant") {
if (commit.phase === "start") {
return RUN_ENTRY_NONE
}
if (commit.phase === "final") {
return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
}
return markdownBody(raw)
}
if (commit.kind === "reasoning") {
if (commit.phase === "start") {
return RUN_ENTRY_NONE
}
if (commit.phase === "final") {
return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE
}
return reasoningBody(raw)
}
return systemBody(raw, commit.phase)
}
@@ -0,0 +1,647 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core"
import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { formatBindings } from "./keymap.shared"
import type { RunFooterTheme } from "./theme"
import type { FooterKeybinds, RunCommand, RunInput, RunProvider } from "./types"
type PanelEntry = RunFooterMenuItem & {
category: string
keywords?: string
}
type CommandEntry =
| (PanelEntry & { action: "model" })
| (PanelEntry & { action: "variant.cycle" })
| (PanelEntry & { action: "variant.list" })
| (PanelEntry & { action: "slash"; name: string })
| (PanelEntry & { action: "exit" })
type ModelEntry = PanelEntry & {
providerID: string
modelID: string
providerName: string
current: boolean
}
type VariantEntry = PanelEntry & {
variant: string | undefined
current: boolean
}
type MenuState = ReturnType<typeof createFooterMenuState>
const PANEL_PAD = 2
const PANEL_LIST_ROWS = 10
export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + 6
const PANEL_PAGE = PANEL_LIST_ROWS - 1
const PANEL_BORDER = {
topLeft: "",
bottomLeft: "",
vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
const PANEL_BOTTOM_BORDER = {
...PANEL_BORDER,
vertical: "╹",
}
const HALF_BLOCK_BORDER = {
topLeft: "",
bottomLeft: "",
vertical: "",
topRight: "",
bottomRight: "",
horizontal: "▀",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
function countLabel(count: number, total: number, query: string) {
if (!query.trim()) {
return `${total}`
}
return `${count}/${total}`
}
function categoryRank(category: string) {
if (category === "Project Commands") {
return 0
}
if (category === "MCP Commands") {
return 1
}
return 2
}
function handleKey(input: {
event: KeyEvent
menu: MenuState
field: () => InputRenderable | undefined
setQuery: (value: string) => void
select: () => void
close: () => void
}) {
const name = input.event.name.toLowerCase()
const ctrl = input.event.ctrl && !input.event.meta && !input.event.shift && !input.event.super
if (name === "escape" || (ctrl && name === "c")) {
input.event.preventDefault()
input.close()
return
}
if (name === "up" || (ctrl && name === "p")) {
input.event.preventDefault()
input.menu.move(-1)
return
}
if (name === "down" || (ctrl && name === "n")) {
input.event.preventDefault()
input.menu.move(1)
return
}
if (name === "pageup") {
input.event.preventDefault()
input.menu.reveal(input.menu.selected() - PANEL_PAGE)
return
}
if (name === "pagedown") {
input.event.preventDefault()
input.menu.reveal(input.menu.selected() + PANEL_PAGE)
return
}
if (name === "home") {
input.event.preventDefault()
input.menu.reveal(0)
return
}
if (name === "end") {
input.event.preventDefault()
input.menu.reveal(Number.POSITIVE_INFINITY)
return
}
if (name === "return") {
input.event.preventDefault()
input.select()
return
}
if (ctrl && name === "u") {
input.event.preventDefault()
input.setQuery("")
input.field()?.setText("")
}
}
function match<T extends PanelEntry>(query: string, entries: T[]) {
const text = query.trim()
if (!text) {
return entries
}
return fuzzysort
.go(text, entries, { keys: ["display", "category", "description", "keywords"] })
.map((item) => item.obj)
}
function PanelShell(props: {
id: string
title: string
countVisible?: boolean
query: string
count: number
total: number
placeholder: string
theme: Accessor<RunFooterTheme>
inputRef: (input: InputRenderable) => void
onQuery: (query: string) => void
children: JSX.Element
}) {
return (
<box
id={props.id}
width="100%"
flexDirection="column"
backgroundColor="transparent"
flexShrink={0}
>
<box
width="100%"
flexDirection="column"
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BORDER}
flexShrink={0}
>
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexDirection="row"
gap={1}
flexShrink={0}
backgroundColor={props.theme().surface}
>
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
{props.title}
</text>
{props.countVisible !== false ? (
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
{countLabel(props.count, props.total, props.query)}
</text>
) : null}
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexShrink={0}
backgroundColor={props.theme().surface}
>
<input
width="100%"
focusedBackgroundColor={props.theme().surface}
focusedTextColor={props.theme().text}
placeholder={props.placeholder}
placeholderColor={props.theme().muted}
cursorColor={props.theme().highlight}
onInput={props.onQuery}
ref={(input) => {
props.inputRef(input)
input.traits = { status: "FILTER" }
queueMicrotask(() => {
if (!input.isDestroyed) {
input.focus()
}
})
}}
/>
</box>
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={props.theme().surface}>
{props.children}
</box>
</box>
<box
id={`${props.id}-bottom`}
width="100%"
height={1}
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BOTTOM_BORDER}
flexShrink={0}
>
<box
width="100%"
height={1}
border={["bottom"]}
borderColor={props.theme().surface}
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
/>
</box>
</box>
)
}
export function RunCommandMenuBody(props: {
theme: Accessor<RunFooterTheme>
commands: Accessor<RunCommand[] | undefined>
variants: Accessor<string[]>
keybinds: FooterKeybinds
onClose: () => void
onModel: () => void
onVariant: () => void
onVariantCycle: () => void
onCommand: (name: string) => void
onNew: () => void
onExit: () => void
}) {
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
const entries = createMemo<CommandEntry[]>(() => {
const builtins = ["new"]
return [
{
action: "model",
category: "Suggested",
display: "Switch model",
},
{
action: "variant.cycle",
category: "Suggested",
display: "Variant cycle",
footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader),
keywords: "variant cycle",
},
...(props.variants().length > 0
? [
{
action: "variant.list" as const,
category: "Suggested",
display: "Switch model variant",
keywords: `variant variants ${props.variants().join(" ")}`,
},
]
: []),
{
action: "slash",
category: "Session",
name: "new",
display: "New session",
footer: "/new",
keywords: "new session clear",
},
...(props.commands() ?? [])
.filter((item) => item.source !== "skill" && !builtins.includes(item.name))
.map(
(item) =>
({
action: "slash",
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
name: item.name,
display: item.name,
footer: `/${item.name}`,
keywords:
item.source === "mcp"
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
: `/${item.name} ${item.name} ${item.description ?? ""}`,
}) satisfies CommandEntry,
)
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)),
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
]
})
const items = createMemo<CommandEntry[]>(() => match(query(), entries()))
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
const pick = (item: CommandEntry) => {
if (item.action === "model") {
props.onModel()
return
}
if (item.action === "variant.cycle") {
props.onVariantCycle()
return
}
if (item.action === "variant.list") {
props.onVariant()
return
}
if (item.action === "exit") {
props.onExit()
return
}
if (item.name === "new") {
props.onNew()
return
}
props.onCommand(item.name)
}
const select = () => {
const item = items()[menu.selected()]
if (!item) {
return
}
pick(item)
}
createEffect(() => {
query()
menu.reset()
})
useKeyboard((event) => {
if (event.defaultPrevented) {
return
}
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
})
return (
<PanelShell
id="run-direct-footer-command-panel"
title="Commands"
countVisible={false}
query={query()}
count={items().length}
total={entries().length}
placeholder="Search"
theme={props.theme}
inputRef={(input) => {
field = input
}}
onQuery={setQuery}
>
<RunFooterMenu
id="run-direct-footer-command-list"
theme={props.theme}
items={items}
selected={menu.selected}
offset={menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty="No results found"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!query().trim()}
/>
</PanelShell>
)
}
export function RunVariantSelectBody(props: {
theme: Accessor<RunFooterTheme>
variants: Accessor<string[]>
current: Accessor<string | undefined>
onClose: () => void
onSelect: (variant: string | undefined) => void
}) {
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
const entries = createMemo<VariantEntry[]>(() => [
{
category: "",
display: "Default",
description: props.current() === undefined ? "current" : undefined,
keywords: "default",
variant: undefined,
current: props.current() === undefined,
},
...props.variants().map((variant) => ({
category: "",
display: variant,
description: props.current() === variant ? "current" : undefined,
keywords: variant,
variant,
current: props.current() === variant,
})),
])
const items = createMemo<VariantEntry[]>(() => match(query(), entries()))
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
const pick = (item: VariantEntry) => {
props.onSelect(item.variant)
}
const select = () => {
const item = items()[menu.selected()]
if (!item) {
return
}
pick(item)
}
createEffect(() => {
query()
menu.reset()
})
createEffect(() => {
if (query().trim()) {
return
}
const index = items().findIndex((item) => item.current)
if (index !== -1) {
menu.reveal(index)
}
})
useKeyboard((event) => {
if (event.defaultPrevented) {
return
}
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
})
return (
<PanelShell
id="run-direct-footer-variant-panel"
title="Select variant"
query={query()}
count={items().length}
total={entries().length}
placeholder="Search"
theme={props.theme}
inputRef={(input) => {
field = input
}}
onQuery={setQuery}
>
<RunFooterMenu
id="run-direct-footer-variant-list"
theme={props.theme}
items={items}
selected={menu.selected}
offset={menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty="No results found"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
/>
</PanelShell>
)
}
export function RunModelSelectBody(props: {
theme: Accessor<RunFooterTheme>
providers: Accessor<RunProvider[] | undefined>
current: Accessor<RunInput["model"]>
onClose: () => void
onSelect: (model: NonNullable<RunInput["model"]>) => void
}) {
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
const entries = createMemo<ModelEntry[]>(() =>
(props.providers() ?? [])
.flatMap((provider) =>
Object.entries(provider.models)
.filter(([, model]) => model.status !== "deprecated")
.map(([modelID, model]) => {
const title = model.name ?? modelID
const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID
const footer = current
? "current"
: model.cost?.input === 0 && provider.id === "opencode"
? "Free"
: title !== modelID
? modelID
: undefined
return {
providerID: provider.id,
modelID,
providerName: provider.name,
category: provider.name,
display: title,
footer,
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
current,
}
}),
)
.sort((a, b) => {
const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode")
if (provider !== 0) {
return provider
}
const name = a.providerName.localeCompare(b.providerName)
if (name !== 0) {
return name
}
return a.display.localeCompare(b.display)
}),
)
const items = createMemo<ModelEntry[]>(() => match(query(), entries()))
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
const pick = (item: ModelEntry) => {
props.onSelect({ providerID: item.providerID, modelID: item.modelID })
}
const select = () => {
const item = items()[menu.selected()]
if (!item) {
return
}
pick(item)
}
createEffect(() => {
query()
menu.reset()
})
createEffect(() => {
if (query().trim()) {
return
}
const index = items().findIndex((item) => item.current)
if (index !== -1) {
menu.reveal(index)
}
})
useKeyboard((event) => {
if (event.defaultPrevented) {
return
}
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
})
return (
<PanelShell
id="run-direct-footer-model-panel"
title="Select model"
query={query()}
count={items().length}
total={entries().length}
placeholder="Search"
theme={props.theme}
inputRef={(input) => {
field = input
}}
onQuery={setQuery}
>
<RunFooterMenu
id="run-direct-footer-model-list"
theme={props.theme}
items={items}
selected={menu.selected}
offset={menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty={props.providers() ? "No results found" : "Models loading"}
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!query().trim()}
/>
</PanelShell>
)
}
@@ -0,0 +1,290 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes } from "@opentui/core"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { transparent, type RunFooterTheme } from "./theme"
export const FOOTER_MENU_ROWS = 8
export type RunFooterMenuItem = {
display: string
description?: string
category?: string
footer?: string
}
type RunFooterMenuRow =
| { type: "header"; label: string }
| { type: "item"; item: RunFooterMenuItem; index: number }
| { type: "spacer" }
function maxOffset(count: number, limit: number) {
return Math.max(0, count - limit)
}
function previewMargin(limit: number) {
return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2)))
}
function revealOffset(value: number, input: { count: number; limit: number; selected: number }) {
const max = maxOffset(input.count, input.limit)
if (input.selected < value) {
return Math.min(max, input.selected)
}
if (input.selected >= value + input.limit) {
return Math.min(max, input.selected - input.limit + 1)
}
return Math.min(max, value)
}
function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) {
const max = maxOffset(input.count, input.limit)
const margin = previewMargin(input.limit)
if (input.dir < 0 && input.selected < value + margin) {
return Math.max(0, Math.min(max, input.selected - margin))
}
if (input.dir > 0 && input.selected > value + input.limit - margin - 1) {
return Math.min(max, input.selected - input.limit + margin + 1)
}
return Math.min(max, value)
}
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
const [selected, setSelected] = createSignal(0)
const [offset, setOffset] = createSignal(0)
const limit = () => input.limit ?? FOOTER_MENU_ROWS
const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count())))
const reveal = (index: number) => {
const count = input.count()
if (count === 0) {
setSelected(0)
setOffset(0)
return
}
const next = Math.max(0, Math.min(count - 1, index))
setSelected(next)
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next }))
}
const reset = () => {
setSelected(0)
setOffset(0)
}
createEffect(() => {
const count = input.count()
if (count === 0) {
reset()
return
}
if (selected() >= count) {
setSelected(count - 1)
}
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() }))
})
const move = (dir: -1 | 1) => {
const count = input.count()
if (count === 0) {
reset()
return
}
const next = Math.max(0, Math.min(count - 1, selected() + dir))
setSelected(next)
setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir }))
}
return {
selected,
offset,
rows,
reveal,
reset,
move,
}
}
export function RunFooterMenu(props: {
id?: string
theme: Accessor<RunFooterTheme>
items: Accessor<RunFooterMenuItem[]>
selected: Accessor<number>
offset: Accessor<number>
rows: Accessor<number>
limit?: number
empty?: string
border?: boolean
paddingLeft?: number
paddingRight?: number
grouped?: boolean
}) {
const limit = () => props.limit ?? FOOTER_MENU_ROWS
const border = () => props.border ?? true
const [groupOffset, setGroupOffset] = createSignal(0)
let previous = -1
const groupedRows = createMemo<RunFooterMenuRow[]>(() => {
const all: RunFooterMenuRow[] = []
let category = ""
props.items().forEach((item, index) => {
if (item.category && item.category !== category) {
if (all.length > 0) {
all.push({ type: "spacer" })
}
category = item.category
all.push({ type: "header", label: item.category })
}
all.push({ type: "item", item, index })
})
return all
})
createEffect(() => {
if (!props.grouped) {
return
}
const all = groupedRows()
const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected())
if (all.length === 0 || selected === -1) {
setGroupOffset(0)
previous = props.selected()
return
}
const dir =
props.selected() === previous + 1 ? 1
: props.selected() === previous - 1 ? -1
: undefined
setGroupOffset((value) =>
dir
? moveOffset(value, { count: all.length, limit: limit(), selected, dir })
: revealOffset(value, { count: all.length, limit: limit(), selected }),
)
previous = props.selected()
})
const rows = createMemo<RunFooterMenuRow[]>(() => {
if (!props.grouped) {
return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({
type: "item",
item,
index: index + props.offset(),
}))
}
const all = groupedRows()
const start = Math.max(0, Math.min(groupOffset(), all.length - limit()))
return all.slice(start, start + limit())
})
const descriptionColumn = createMemo(() => {
const width = Math.max(0, ...props.items().filter((item) => item.description).map((item) => Bun.stringWidth(item.display)))
return width === 0 ? 0 : width + 2
})
const descriptionPad = (item: RunFooterMenuItem) => {
if (!item.description) {
return ""
}
return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display)))
}
return (
<box
id={props.id ?? "run-direct-footer-menu"}
width="100%"
height={props.rows()}
backgroundColor={transparent}
flexDirection="column"
>
{rows().length === 0 ? (
<box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
{border() ? (
<text fg={props.theme().border} wrapMode="none">
</text>
) : undefined}
<box
flexGrow={1}
flexShrink={1}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
backgroundColor={props.theme().surface}
>
<text fg={props.theme().muted} wrapMode="none" truncate>
{props.empty ?? "No matching items"}
</text>
</box>
</box>
) : (
rows().map((row) => {
if (row.type === "spacer") {
return <box height={1} flexShrink={0} />
}
if (row.type === "header") {
return (
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
<text fg={props.theme().highlight} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{row.label}
</text>
</box>
)
}
const active = () => row.index === props.selected()
const inset = () => (active() ? 1 : 0)
return (
<box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
{border() ? (
<text fg={active() ? props.theme().highlight : props.theme().border} wrapMode="none">
</text>
) : undefined}
<box
flexGrow={1}
flexShrink={1}
paddingLeft={inset()}
paddingRight={inset()}
backgroundColor={props.theme().surface}
>
<box
flexGrow={1}
flexShrink={1}
paddingLeft={Math.max(0, (props.paddingLeft ?? 1) - inset())}
paddingRight={Math.max(0, (props.paddingRight ?? 0) - inset())}
backgroundColor={active() ? props.theme().highlight : props.theme().surface}
>
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
<text fg={active() ? props.theme().surface : props.theme().text} wrapMode="none" truncate flexGrow={1}>
{row.item.display}
{row.item.description ? (
<span style={{ fg: active() ? props.theme().surface : props.theme().muted }}>
{descriptionPad(row.item)}
{row.item.description}
</span>
) : undefined}
</text>
{row.item.footer ? (
<text fg={active() ? props.theme().surface : props.theme().muted} wrapMode="none" truncate flexShrink={0}>
{row.item.footer}
</text>
) : undefined}
</box>
</box>
</box>
</box>
)
})
)}
</box>
)
}
@@ -0,0 +1,478 @@
// Permission UI body for the direct-mode footer.
//
// Renders inside the footer when the reducer pushes a FooterView of type
// "permission". Uses a three-stage state machine (permission.shared.ts):
//
// permission → shows the request with Allow once / Always / Reject buttons
// always → confirmation step before granting permanent access
// reject → text field for the rejection message
//
// Keyboard: left/right to select, enter to confirm, esc to reject.
// The diff view (when available) uses the same diff component as scrollback
// tool snapshots.
/** @jsxImportSource @opentui/solid */
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 { PermissionRequest } from "@opencode-ai/sdk/v2"
import {
createPermissionBodyState,
permissionAlwaysLines,
permissionCancel,
permissionEscape,
permissionHover,
permissionInfo,
permissionLabel,
permissionOptions,
permissionReject,
permissionRun,
permissionShift,
type PermissionOption,
} from "./permission.shared"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { PermissionReply, RunDiffStyle } from "./types"
function buttons(
list: PermissionOption[],
selected: PermissionOption,
theme: RunFooterTheme,
disabled: boolean,
onHover: (option: PermissionOption) => void,
onSelect: (option: PermissionOption) => void,
) {
return (
<box flexDirection="row" gap={1} flexShrink={0}>
<For each={list}>
{(option) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === selected ? theme.highlight : transparent}
onMouseOver={() => {
if (!disabled) onHover(option)
}}
onMouseUp={() => {
if (!disabled) onSelect(option)
}}
>
<text fg={option === selected ? theme.surface : theme.muted}>{permissionLabel(option)}</text>
</box>
)}
</For>
</box>
)
}
function RejectField(props: {
theme: RunFooterTheme
text: string
disabled: boolean
onChange: (text: string) => void
onConfirm: () => void
onCancel: () => void
}) {
let area: TextareaRenderable | undefined
createEffect(() => {
if (!area || area.isDestroyed) {
return
}
if (area.plainText !== props.text) {
area.setText(props.text)
area.cursorOffset = props.text.length
}
queueMicrotask(() => {
if (!area || area.isDestroyed || props.disabled) {
return
}
area.focus()
})
})
return (
<textarea
id="run-direct-footer-permission-reject"
width="100%"
minHeight={1}
maxHeight={3}
wrapMode="word"
placeholder="Tell OpenCode what to do differently"
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={!props.disabled}
onContentChange={() => {
if (!area || area.isDestroyed) {
return
}
props.onChange(area.plainText)
}}
onKeyDown={(event) => {
if (event.name === "escape") {
event.preventDefault()
props.onCancel()
return
}
if (event.name === "return" && !event.meta && !event.ctrl && !event.shift) {
event.preventDefault()
props.onConfirm()
}
}}
ref={(item) => {
area = item
}}
/>
)
}
export function RunPermissionBody(props: {
request: PermissionRequest
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 ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => dims().width < 80)
const opts = createMemo(() => permissionOptions(state().stage))
const busy = createMemo(() => state().submitting)
const title = createMemo(() => {
if (state().stage === "always") {
return "Always allow"
}
if (state().stage === "reject") {
return "Reject permission"
}
return "Permission required"
})
createEffect(() => {
const id = props.request.id
if (state().requestID === id) {
return
}
setState(createPermissionBodyState(id))
})
const shift = (dir: -1 | 1) => {
setState((prev) => permissionShift(prev, dir))
}
const submit = async (next: PermissionReply) => {
setState((prev) => ({
...prev,
submitting: true,
}))
try {
await props.onReply(next)
} catch {
setState((prev) => ({
...prev,
submitting: false,
}))
}
}
const run = (option: PermissionOption) => {
const cur = state()
const next = permissionRun(cur, props.request.id, option)
if (next.state !== cur) {
setState(next.state)
}
if (!next.reply) {
return
}
void submit(next.reply)
}
const reject = () => {
const next = permissionReject(state(), props.request.id)
if (!next) {
return
}
void submit(next)
}
const cancelReject = () => {
setState((prev) => permissionCancel(prev))
}
useKeyboard((event) => {
const cur = state()
if (cur.stage === "reject") {
return
}
if (cur.submitting) {
if (["left", "right", "h", "l", "tab", "return", "escape"].includes(event.name)) {
event.preventDefault()
}
return
}
if (event.name === "tab") {
shift(event.shift ? -1 : 1)
event.preventDefault()
return
}
if (event.name === "left" || event.name === "h") {
shift(-1)
event.preventDefault()
return
}
if (event.name === "right" || event.name === "l") {
shift(1)
event.preventDefault()
return
}
if (event.name === "return") {
run(state().selected)
event.preventDefault()
return
}
if (event.name !== "escape") {
return
}
setState((prev) => permissionEscape(prev))
event.preventDefault()
})
return (
<box id="run-direct-footer-permission-body" width="100%" height="100%" flexDirection="column">
<box
id="run-direct-footer-permission-head"
flexDirection="column"
gap={1}
paddingLeft={1}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
flexShrink={0}
>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}></text>
<text fg={props.theme.text}>{title()}</text>
</box>
<Switch>
<Match when={state().stage === "permission"}>
<box flexDirection="row" gap={1} paddingLeft={2}>
<text fg={props.theme.muted} flexShrink={0}>
{info().icon}
</text>
<text fg={props.theme.text} wrapMode="word">
{info().title}
</text>
</box>
</Match>
<Match when={state().stage === "reject"}>
<box paddingLeft={1}>
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
</box>
</Match>
</Switch>
</box>
<Show
when={state().stage !== "reject"}
fallback={
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
<box
id="run-direct-footer-permission-reject-bar"
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
backgroundColor={props.theme.line}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
gap={1}
>
<box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
<RejectField
theme={props.theme}
text={state().message}
disabled={busy()}
onChange={(text) => {
setState((prev) => ({
...prev,
message: text,
}))
}}
onConfirm={reject}
onCancel={cancelReject}
/>
</box>
<Show
when={!busy()}
fallback={
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
Waiting for permission event...
</text>
}
>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={props.theme.text}>
enter <span style={{ fg: props.theme.muted }}>confirm</span>
</text>
<text fg={props.theme.text}>
esc <span style={{ fg: props.theme.muted }}>cancel</span>
</text>
</box>
</Show>
</box>
</box>
}
>
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
<Switch>
<Match when={state().stage === "permission"}>
<scrollbox
width="100%"
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: props.theme.surface,
foregroundColor: props.theme.line,
},
}}
>
<box width="100%" flexDirection="column" gap={1}>
<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>
}
>
<diff
diff={info().diff!}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={props.theme.text}
addedBg={props.block.diffAddedBg}
removedBg={props.block.diffRemovedBg}
contextBg={props.block.diffContextBg}
addedSignColor={props.block.diffHighlightAdded}
removedSignColor={props.block.diffHighlightRemoved}
lineNumberFg={props.block.diffLineNumber}
lineNumberBg={props.block.diffContextBg}
addedLineNumberBg={props.block.diffAddedLineNumberBg}
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
/>
</Show>
<Show when={!info().diff && info().lines.length === 0}>
<box paddingLeft={1}>
<text fg={props.theme.muted}>No diff provided</text>
</box>
</Show>
</box>
</scrollbox>
</Match>
<Match when={true}>
<scrollbox
width="100%"
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: props.theme.surface,
foregroundColor: props.theme.line,
},
}}
>
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
<For each={permissionAlwaysLines(props.request)}>
{(line) => (
<text fg={props.theme.text} wrapMode="word">
{line}
</text>
)}
</For>
</box>
</scrollbox>
</Match>
</Switch>
</box>
<box
id="run-direct-footer-permission-actions"
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
backgroundColor={props.theme.pane}
gap={1}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
>
{buttons(
opts(),
state().selected,
props.theme,
busy(),
(option) => {
setState((prev) => permissionHover(prev, option))
},
run,
)}
<Show
when={!busy()}
fallback={
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
Waiting for permission event...
</text>
}
>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={props.theme.text}>
{"⇆"} <span style={{ fg: props.theme.muted }}>select</span>
</text>
<text fg={props.theme.text}>
enter <span style={{ fg: props.theme.muted }}>confirm</span>
</text>
<text fg={props.theme.text}>
esc <span style={{ fg: props.theme.muted }}>{state().stage === "always" ? "cancel" : "reject"}</span>
</text>
</box>
</Show>
</box>
</Show>
</box>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,582 @@
// 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 { QuestionRequest } from "@opencode-ai/sdk/v2"
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 type { RunFooterTheme } from "./theme"
import type { QuestionReject, QuestionReply } from "./types"
export function RunQuestionBody(props: {
request: QuestionRequest
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(() => dims().width < 80)
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
}
if (event.name === "return" && !event.shift && !event.ctrl && !event.meta) {
saveCustom()
event.preventDefault()
}
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 id="run-direct-footer-question-body" width="100%" height="100%" flexDirection="column">
<box
id="run-direct-footer-question-panel"
flexDirection="column"
gap={1}
paddingLeft={1}
paddingRight={3}
paddingTop={1}
flexGrow={1}
flexShrink={1}
backgroundColor={props.theme.surface}
>
<Show when={!single()}>
<box id="run-direct-footer-question-tabs" 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
id={`run-direct-footer-question-tab-${index()}`}
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
id="run-direct-footer-question-tab-confirm"
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
id={`run-direct-footer-question-option-${index()}`}
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
id="run-direct-footer-question-option-custom"
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
id="run-direct-footer-question-custom"
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()}
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
id="run-direct-footer-question-actions"
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>
)
}
@@ -0,0 +1,192 @@
/** @jsxImportSource @opentui/solid */
import type { ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import "opentui-spinner/solid"
import { createMemo, indexArray, mapArray } from "solid-js"
import { SPINNER_FRAMES } from "../tui/component/spinner"
import { RunEntryContent, separatorRows } from "./scrollback.writer"
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
import type { RunFooterTheme, RunTheme } from "./theme"
export const SUBAGENT_TAB_ROWS = 2
export const SUBAGENT_INSPECTOR_ROWS = 8
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
if (status === "completed") {
return theme.highlight
}
if (status === "error") {
return theme.error
}
return theme.highlight
}
function statusIcon(status: FooterSubagentTab["status"]) {
if (status === "completed") {
return "●"
}
if (status === "error") {
return "◍"
}
return "◔"
}
function tabText(tab: FooterSubagentTab, slot: string, count: number, width: number) {
const perTab = Math.max(
1,
Math.floor((width - 4 - Math.max(0, count - 1) * 3) / Math.max(1, count)),
)
if (count >= 8 || perTab < 12) {
return `[${slot}]`
}
const prefix = `[${slot}]`
if (count >= 5 || perTab < 24) {
return prefix
}
const label = tab.description || tab.title || tab.label
return `${prefix} ${label}`
}
export function RunFooterSubagentTabs(props: {
tabs: FooterSubagentTab[]
selected?: string
theme: RunFooterTheme
width: number
}) {
const items = mapArray(
() => props.tabs,
(tab, index) => {
const active = () => props.selected === tab.sessionID
const slot = () => String(index() + 1)
return (
<box paddingRight={1}>
<box flexDirection="row" gap={1} width="100%">
{tab.status === "running" ? (
<box flexShrink={0}>
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(props.theme, tab.status)} />
</box>
) : (
<text fg={statusColor(props.theme, tab.status)} wrapMode="none" truncate flexShrink={0}>
{statusIcon(tab.status)}
</text>
)}
<text fg={active() ? props.theme.text : props.theme.muted} wrapMode="none" truncate>
{tabText(tab, slot(), props.tabs.length, props.width)}
</text>
</box>
</box>
)
},
)
return (
<box
id="run-direct-footer-subagent-tabs"
width="100%"
height={SUBAGENT_TAB_ROWS}
paddingLeft={1}
paddingRight={2}
paddingBottom={1}
flexDirection="row"
flexShrink={0}
>
<box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>{items()}</box>
</box>
)
}
export function RunFooterSubagentBody(props: {
active: () => boolean
theme: () => RunTheme
detail: () => FooterSubagentDetail | undefined
width: () => number
diffStyle?: RunDiffStyle
onCycle: (dir: -1 | 1) => void
onClose: () => void
}) {
const theme = createMemo(() => props.theme())
const footer = createMemo(() => theme().footer)
const commits = createMemo(() => props.detail()?.commits ?? [])
const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
const scrollbar = createMemo(() => ({
trackOptions: {
backgroundColor: footer().surface,
foregroundColor: footer().line,
},
}))
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()} />
</box>
))
let scroll: ScrollBoxRenderable | undefined
useKeyboard((event) => {
if (!props.active()) {
return
}
if (event.name === "escape") {
event.preventDefault()
props.onClose()
return
}
if (event.name === "tab" && !event.shift) {
event.preventDefault()
props.onCycle(1)
return
}
if (event.name === "up" || event.name === "k") {
event.preventDefault()
scroll?.scrollBy(-1)
return
}
if (event.name === "down" || event.name === "j") {
event.preventDefault()
scroll?.scrollBy(1)
}
})
return (
<box
id="run-direct-footer-subagent"
width="100%"
height="100%"
flexDirection="column"
backgroundColor={footer().surface}
>
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
<scrollbox
width="100%"
height="100%"
stickyScroll={true}
stickyStart="bottom"
verticalScrollbarOptions={scrollbar()}
ref={(item) => {
scroll = item
}}
>
<box width="100%" flexDirection="column" gap={0}>
{commits().length > 0 ? (
rows()
) : (
<text fg={footer().muted} wrapMode="word">
No subagent activity yet
</text>
)}
</box>
</scrollbox>
</box>
</box>
)
}
+893
View File
@@ -0,0 +1,893 @@
// RunFooter -- the mutable control surface for direct interactive mode.
//
// In the split-footer architecture, scrollback is immutable (append-only)
// and the footer is the only region that can repaint. RunFooter owns both
// sides of that boundary:
//
// Scrollback: append() queues StreamCommit entries and flush() drains them
// through retained scrollback surfaces. Commits coalesce in a microtask
// queue so direct-mode transcript updates still preserve ordering without
// rebuilding the session model.
//
// Footer: event() updates the SolidJS signal-backed FooterState, which
// drives the reactive footer view (prompt, status, permission, question).
// present() swaps the active footer view and resizes the footer region.
//
// Lifecycle:
// - close() flushes pending commits and notifies listeners (the prompt
// queue uses this to know when to stop).
// - destroy() does the same plus tears down event listeners and clears
// internal state.
// - The renderer's DESTROY event triggers destroy() so the footer
// doesn't outlive the renderer.
//
// 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 { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { withRunSpan } from "./otel"
import { RUN_COMMAND_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { printableBinding } from "./prompt.shared"
import { RunFooterView } from "./footer.view"
import { RunScrollbackStream } from "./scrollback.surface"
import type { RunTheme } from "./theme"
import type {
FooterApi,
FooterEvent,
FooterKeybinds,
FooterPatch,
FooterPromptRoute,
FooterState,
FooterSubagentState,
FooterView,
PermissionReply,
QuestionReject,
QuestionReply,
RunAgent,
RunCommand,
RunDiffStyle,
RunInput,
RunPrompt,
RunProvider,
RunResource,
StreamCommit,
} from "./types"
type CycleResult = {
modelLabel?: string
status?: string
variant?: string | undefined
variants?: string[]
}
type RunFooterOptions = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
commands?: RunCommand[]
wrote?: boolean
sessionID: () => string | undefined
agentLabel: string
modelLabel: string
model: RunInput["model"]
variant: string | undefined
first: boolean
history?: RunPrompt[]
theme: RunTheme
keybinds: FooterKeybinds
diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => 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
onExit?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
treeSitterClient?: TreeSitterClient
}
const PERMISSION_ROWS = 12
const QUESTION_ROWS = 14
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
const AUTOCOMPLETE_COMPACT_ROWS = 2
function createEmptySubagentState(): FooterSubagentState {
return {
tabs: [],
details: {},
permissions: [],
questions: [],
}
}
function eventPatch(next: FooterEvent): FooterPatch | undefined {
if (next.type === "queue") {
return { queue: next.queue }
}
if (next.type === "first") {
return { first: next.first }
}
if (next.type === "model") {
return { model: next.model }
}
if (next.type === "turn.send") {
return {
phase: "running",
status: "sending prompt",
queue: next.queue,
}
}
if (next.type === "turn.wait") {
return {
phase: "running",
status: "waiting for assistant",
}
}
if (next.type === "turn.idle") {
return {
phase: "idle",
status: "",
queue: next.queue,
}
}
if (next.type === "turn.duration") {
return { duration: next.duration }
}
if (next.type === "stream.patch") {
return next.patch
}
return undefined
}
export class RunFooter implements FooterApi {
private closed = false
private destroyed = false
private prompts = new Set<(input: RunPrompt) => void>()
private closes = new Set<() => void>()
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
private queue: StreamCommit[] = []
private pending = false
private flushing: Promise<void> = Promise.resolve()
// Fixed portion of footer height above the textarea.
private base: number
private rows = TEXTAREA_MIN_ROWS
private agents: Accessor<RunAgent[]>
private setAgents: Setter<RunAgent[]>
private resources: Accessor<RunResource[]>
private setResources: Setter<RunResource[]>
private commands: Accessor<RunCommand[] | undefined>
private setCommands: Setter<RunCommand[] | undefined>
private providers: Accessor<RunProvider[] | undefined>
private setProviders: Setter<RunProvider[] | undefined>
private currentModel: Accessor<RunInput["model"]>
private setCurrentModel: Setter<RunInput["model"]>
private variants: Accessor<string[]>
private setVariants: Setter<string[]>
private currentVariant: Accessor<string | undefined>
private setCurrentVariant: Setter<string | undefined>
private state: Accessor<FooterState>
private setState: Setter<FooterState>
private view: Accessor<FooterView>
private setView: Setter<FooterView>
private subagent: Accessor<FooterSubagentState>
private setSubagent: (next: FooterSubagentState) => void
private promptRoute: FooterPromptRoute = { type: "composer" }
private tabsVisible = false
private autocomplete = false
private interruptTimeout: NodeJS.Timeout | undefined
private exitTimeout: NodeJS.Timeout | undefined
private interruptHint: string
private requestExitHandler: (() => boolean) | undefined
private scrollback: RunScrollbackStream
constructor(
private renderer: CliRenderer,
private options: RunFooterOptions,
) {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: options.modelLabel,
duration: "",
usage: "",
first: options.first,
interrupt: 0,
exit: 0,
})
this.state = state
this.setState = setState
const [view, setView] = createSignal<FooterView>({ type: "prompt" })
this.view = view
this.setView = setView
const [agents, setAgents] = createSignal(options.agents)
this.agents = agents
this.setAgents = setAgents
const [resources, setResources] = createSignal(options.resources)
this.resources = resources
this.setResources = setResources
const [commands, setCommands] = createSignal<RunCommand[] | undefined>(options.commands)
this.commands = commands
this.setCommands = setCommands
const [providers, setProviders] = createSignal<RunProvider[] | undefined>()
this.providers = providers
this.setProviders = setProviders
const [currentModel, setCurrentModel] = createSignal<RunInput["model"]>(options.model)
this.currentModel = currentModel
this.setCurrentModel = setCurrentModel
const [variants, setVariants] = createSignal<string[]>([])
this.variants = variants
this.setVariants = setVariants
const [currentVariant, setCurrentVariant] = createSignal(options.variant)
this.currentVariant = currentVariant
this.setCurrentVariant = setCurrentVariant
const [subagent, setSubagent] = createStore<FooterSubagentState>(createEmptySubagentState())
this.subagent = () => subagent
this.setSubagent = (next) => {
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" }))
}
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
diffStyle: options.diffStyle,
wrote: options.wrote,
sessionID: options.sessionID,
treeSitterClient: options.treeSitterClient,
})
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
void render(
() =>
createComponent(RunFooterView, {
directory: options.directory,
state: this.state,
view: this.view,
subagent: this.subagent,
findFiles: options.findFiles,
agents: this.agents,
resources: this.resources,
commands: this.commands,
providers: this.providers,
currentModel: this.currentModel,
variants: this.variants,
currentVariant: this.currentVariant,
theme: options.theme,
diffStyle: options.diffStyle,
keybinds: options.keybinds,
history: options.history,
agent: options.agentLabel,
onSubmit: this.handlePrompt,
onPermissionReply: this.handlePermissionReply,
onQuestionReply: this.handleQuestionReply,
onQuestionReject: this.handleQuestionReject,
onCycle: this.handleCycle,
onInterrupt: this.handleInterrupt,
onInputClear: this.handleInputClear,
onExitRequest: this.handleExit,
onRequestExit: this.setRequestExitHandler,
onExit: () => this.close(),
onModelSelect: this.handleModelSelect,
onVariantSelect: this.handleVariantSelect,
onRows: this.syncRows,
onLayout: this.syncLayout,
onStatus: this.setStatus,
onSubagentSelect: options.onSubagentSelect,
}),
this.renderer,
).catch(() => {
if (!this.isGone) {
this.close()
}
})
}
public get isClosed(): boolean {
return this.closed || this.isGone
}
private get isGone(): boolean {
return this.destroyed || this.renderer.isDestroyed
}
public onPrompt(fn: (input: RunPrompt) => void): () => void {
this.prompts.add(fn)
return () => {
this.prompts.delete(fn)
}
}
public onClose(fn: () => void): () => void {
if (this.isClosed) {
fn()
return () => {}
}
this.closes.add(fn)
return () => {
this.closes.delete(fn)
}
}
public event(next: FooterEvent): void {
if (next.type === "catalog") {
if (this.isGone) {
return
}
this.setAgents(next.agents)
this.setResources(next.resources)
if (next.commands !== undefined) {
this.setCommands(next.commands)
}
return
}
if (next.type === "models") {
if (this.isGone) {
return
}
this.setProviders(next.providers)
return
}
if (next.type === "variants") {
if (this.isGone) {
return
}
this.setVariants(next.variants)
this.setCurrentVariant(next.current)
return
}
const patch = eventPatch(next)
if (patch) {
this.patch(patch)
return
}
if (next.type === "stream.subagent") {
if (this.isGone) {
return
}
this.setSubagent(next.state)
this.applyHeight()
return
}
if (next.type === "stream.view") {
this.present(next.view)
}
}
private patch(next: FooterPatch): void {
if (this.isGone) {
return
}
const prev = this.state()
const state = {
phase: next.phase ?? prev.phase,
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:
typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
? Math.max(0, Math.floor(next.interrupt))
: prev.interrupt,
exit:
typeof next.exit === "number" && Number.isFinite(next.exit) ? Math.max(0, Math.floor(next.exit)) : prev.exit,
}
if (state.phase === "idle") {
state.interrupt = 0
}
this.setState(state)
if (prev.phase === "running" && state.phase === "idle") {
this.flush()
this.completeScrollback()
}
}
private completeScrollback(): void {
const phase = this.state().phase
this.flushing = this.flushing
.then(() =>
withRunSpan(
"RunFooter.completeScrollback",
{
"opencode.footer.phase": phase,
"session.id": this.options.sessionID() || undefined,
},
async () => {
await this.scrollback.complete()
},
),
)
.catch(() => {})
}
private present(view: FooterView): void {
if (this.isGone) {
return
}
this.setView(view)
this.applyHeight()
}
// Queues a scrollback commit. Consecutive progress chunks for the same
// part coalesce by appending text, reducing the number of retained-surface
// updates. Actual flush happens on the next microtask, so a burst of events
// from one reducer pass becomes a single ordered drain.
public append(commit: StreamCommit): void {
if (this.isGone) {
return
}
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)
}
if (this.pending) {
return
}
this.pending = true
queueMicrotask(() => {
this.pending = false
this.flush()
})
}
public idle(): Promise<void> {
if (this.isGone) {
return Promise.resolve()
}
this.flush()
if (this.state().phase === "idle") {
this.completeScrollback()
}
return this.flushing.then(async () => {
if (this.isGone) {
return
}
if (this.queue.length > 0) {
return this.idle()
}
await this.renderer.idle().catch(() => {})
})
}
public close(): void {
if (this.closed) {
return
}
this.flush()
this.notifyClose()
}
public requestExit(): boolean {
return this.requestExitHandler?.() ?? this.handleExit()
}
public destroy(): void {
this.handleDestroy()
}
private notifyClose(): void {
if (this.closed) {
return
}
this.closed = true
for (const fn of [...this.closes]) {
fn()
}
}
private setStatus = (status: string): void => {
this.patch({ status })
}
private setRequestExitHandler = (fn?: () => boolean): void => {
this.requestExitHandler = fn
}
private handleInputClear = (): void => {
this.clearInterruptTimer()
this.clearExitTimer()
if (this.state().interrupt === 0 && this.state().exit === 0) {
return
}
this.patch({ interrupt: 0, exit: 0 })
}
// Resizes the footer to fit the current view. Permission and question views
// get fixed extra rows; the prompt view scales with textarea line count.
private applyHeight(): void {
const type = this.view().type
const tabs = this.tabsVisible ? SUBAGENT_TAB_ROWS : 0
const compact = this.promptRoute.type === "composer" && this.autocomplete ? AUTOCOMPLETE_COMPACT_ROWS : 0
const base = this.base + tabs - compact
const height =
type === "permission"
? this.base + PERMISSION_ROWS
: type === "question"
? this.base + QUESTION_ROWS
: this.promptRoute.type === "command"
? 1 + tabs + COMMAND_ROWS
: this.promptRoute.type === "model"
? 1 + tabs + MODEL_ROWS
: this.promptRoute.type === "variant"
? 1 + tabs + VARIANT_ROWS
: this.promptRoute.type === "subagent"
? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
}
}
private syncRows = (value: number): void => {
if (this.isGone) {
return
}
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
if (rows === this.rows) {
return
}
this.rows = rows
if (this.view().type === "prompt") {
this.applyHeight()
}
}
private syncLayout = (next: { route: FooterPromptRoute; tabs: boolean; autocomplete: boolean }): void => {
this.promptRoute = next.route
this.tabsVisible = next.tabs
this.autocomplete = next.autocomplete
if (this.view().type === "prompt") {
this.applyHeight()
}
}
private handlePrompt = (input: RunPrompt): boolean => {
if (this.isClosed) {
return false
}
if (this.state().first) {
this.patch({ first: false })
}
if (this.prompts.size === 0) {
this.patch({ status: "input queue unavailable" })
return false
}
for (const fn of [...this.prompts]) {
fn(input)
}
return true
}
private handlePermissionReply = async (input: PermissionReply): Promise<void> => {
if (this.isClosed) {
return
}
await this.options.onPermissionReply(input)
}
private handleQuestionReply = async (input: QuestionReply): Promise<void> => {
if (this.isClosed) {
return
}
await this.options.onQuestionReply(input)
}
private handleQuestionReject = async (input: QuestionReject): Promise<void> => {
if (this.isClosed) {
return
}
await this.options.onQuestionReject(input)
}
private handleCycle = (): void => {
const result = this.options.onCycleVariant?.()
if (!result) {
this.patch({ status: "no variants available" })
return
}
const patch: FooterPatch = {
status: result.status ?? "variant updated",
}
if ("variants" in result) {
this.setVariants(result.variants ?? [])
}
if ("variant" in result) {
this.setCurrentVariant(result.variant)
}
if (result.modelLabel) {
patch.model = result.modelLabel
}
this.patch(patch)
}
private handleModelSelect = (model: NonNullable<RunInput["model"]>): void => {
if (this.isClosed) {
return
}
this.setCurrentModel(model)
void Promise.resolve()
.then(() => this.options.onModelSelect?.(model))
.then((result) => {
const current = this.currentModel()
if (
!result ||
this.isClosed ||
!current ||
current.providerID !== model.providerID ||
current.modelID !== model.modelID
) {
return
}
if ("variants" in result) {
this.setVariants(result.variants ?? [])
}
if ("variant" in result) {
this.setCurrentVariant(result.variant)
}
const patch: FooterPatch = {}
if (result.modelLabel) {
patch.model = result.modelLabel
}
if (result.status) {
patch.status = result.status
}
if (patch.model || patch.status) {
this.patch(patch)
}
})
.catch(() => {})
}
private handleVariantSelect = (variant: string | undefined): void => {
if (this.isClosed) {
return
}
const model = this.currentModel()
void Promise.resolve()
.then(() => this.options.onVariantSelect?.(variant))
.then((result) => {
const current = this.currentModel()
if (
!result ||
this.isClosed ||
(model && (!current || current.providerID !== model.providerID || current.modelID !== model.modelID))
) {
return
}
if ("variants" in result) {
this.setVariants(result.variants ?? [])
}
if ("variant" in result) {
this.setCurrentVariant(result.variant)
}
const patch: FooterPatch = {}
if (result.modelLabel) {
patch.model = result.modelLabel
}
if (result.status) {
patch.status = result.status
}
if (patch.model || patch.status) {
this.patch(patch)
}
})
.catch(() => {})
}
private clearInterruptTimer(): void {
if (!this.interruptTimeout) {
return
}
clearTimeout(this.interruptTimeout)
this.interruptTimeout = undefined
}
private armInterruptTimer(): void {
this.clearInterruptTimer()
this.interruptTimeout = setTimeout(() => {
this.interruptTimeout = undefined
if (this.isGone || this.state().phase !== "running") {
return
}
this.patch({ interrupt: 0 })
}, 5000)
}
private clearExitTimer(): void {
if (!this.exitTimeout) {
return
}
clearTimeout(this.exitTimeout)
this.exitTimeout = undefined
}
private armExitTimer(): void {
this.clearExitTimer()
this.exitTimeout = setTimeout(() => {
this.exitTimeout = undefined
if (this.isGone || this.isClosed) {
return
}
this.patch({ exit: 0 })
}, 5000)
}
// Two-press interrupt: first press shows a hint ("esc again to interrupt"),
// second press within 5 seconds fires onInterrupt. The timer resets the
// counter if the user doesn't follow through.
private handleInterrupt = (): boolean => {
if (this.isClosed || this.state().phase !== "running") {
return false
}
const next = this.state().interrupt + 1
this.patch({ interrupt: next })
if (next < 2) {
this.armInterruptTimer()
this.patch({ status: `${this.interruptHint} again to interrupt` })
return true
}
this.clearInterruptTimer()
this.patch({ interrupt: 0, status: "interrupting" })
this.options.onInterrupt?.()
return true
}
private handleExit = (): boolean => {
if (this.isClosed) {
return true
}
this.clearInterruptTimer()
const next = this.state().exit + 1
this.patch({ exit: next, interrupt: 0 })
if (next < 2) {
this.armExitTimer()
this.patch({ status: "Press Ctrl-c again to exit" })
return true
}
this.clearExitTimer()
this.patch({ exit: 0, status: "exiting" })
this.close()
this.options.onExit?.()
return true
}
private handleDestroy = (): void => {
if (this.destroyed) {
return
}
this.flush()
this.destroyed = true
this.notifyClose()
this.clearInterruptTimer()
this.clearExitTimer()
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
this.prompts.clear()
this.closes.clear()
this.scrollback.destroy()
}
// Drains the commit queue to scrollback. The surface manager owns grouping,
// spacing, and progressive markdown/code settling so direct mode can append
// immutable transcript rows without rewriting history.
private flush(): void {
if (this.isGone || this.queue.length === 0) {
this.queue.length = 0
return
}
const batch = this.queue.splice(0)
const phase = this.state().phase
this.flushing = this.flushing
.then(() =>
withRunSpan(
"RunFooter.flush",
{
"opencode.batch.commits": batch.length,
"opencode.footer.phase": phase,
"session.id": this.options.sessionID() || undefined,
},
async () => {
for (const item of batch) {
await this.scrollback.append(item)
}
},
),
)
.catch(() => {})
}
}
@@ -0,0 +1,719 @@
// Top-level footer layout for direct interactive mode.
//
// Renders the footer region as a vertical stack:
// 1. Spacer row (visual separation from scrollback)
// 2. Composer frame with left-border accent -- swaps between prompt,
// permission, and question bodies via Switch/Match
// 3. Meta row showing agent name and model label in the normal composer view
// 4. Bottom border + status row (spinner, interrupt hint, duration, usage)
//
// All state comes from the parent RunFooter through SolidJS signals.
// The view itself is stateless except for derived memos.
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import "opentui-spinner/solid"
import { createColors, createFrames } from "../tui/ui/spinner"
import { RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "./footer.command"
import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
import { RunPermissionBody } from "./footer.permission"
import { RunQuestionBody } from "./footer.question"
import { printableBinding, promptBindings, promptHit, promptInfo } from "./prompt.shared"
import type {
FooterKeybinds,
FooterPromptRoute,
FooterState,
FooterSubagentState,
FooterView,
PermissionReply,
QuestionReject,
QuestionReply,
RunAgent,
RunCommand,
RunDiffStyle,
RunInput,
RunPrompt,
RunProvider,
RunResource,
} from "./types"
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
const EMPTY_BORDER = {
topLeft: "",
bottomLeft: "",
vertical: "",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
type RunFooterViewProps = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: () => RunAgent[]
resources: () => RunResource[]
commands: () => RunCommand[] | undefined
providers: () => RunProvider[] | undefined
currentModel: () => RunInput["model"]
variants: () => string[]
currentVariant: () => string | undefined
state: () => FooterState
view?: () => FooterView
subagent?: () => FooterSubagentState
theme?: RunTheme
diffStyle?: RunDiffStyle
keybinds: FooterKeybinds
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>
onCycle: () => void
onInterrupt: () => boolean
onInputClear: () => void
onExitRequest?: () => boolean
onRequestExit?: (fn: (() => boolean) | undefined) => void
onExit: () => void
onModelSelect: (model: NonNullable<RunInput["model"]>) => void
onVariantSelect: (variant: string | undefined) => void
onRows: (rows: number) => void
onLayout: (input: { route: FooterPromptRoute; tabs: boolean; autocomplete: boolean }) => void
onStatus: (text: string) => void
onSubagentSelect?: (sessionID: string | undefined) => void
}
function subagentShortcut(event: {
name: string
ctrl?: boolean
meta?: boolean
shift?: boolean
super?: boolean
}): number | undefined {
if (!event.ctrl || event.meta || event.super) {
return undefined
}
if (!/^[0-9]$/.test(event.name)) {
return undefined
}
const slot = Number(event.name)
return slot === 0 ? 9 : slot - 1
}
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
export function RunFooterView(props: RunFooterViewProps) {
const term = useTerminalDimensions()
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
const subagent = createMemo<FooterSubagentState>(() => {
return (
props.subagent?.() ?? {
tabs: [],
details: {},
permissions: [],
questions: [],
}
)
})
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
const panel = createMemo(() => commanding() || modeling() || varianting())
const selected = createMemo(() => {
const current = route()
return current.type === "subagent" ? current.sessionID : undefined
})
const tabs = createMemo(() => subagent().tabs)
const showTabs = createMemo(() => active().type === "prompt" && tabs().length > 0)
const detail = createMemo(() => {
const current = route()
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
})
const command = createMemo(() => printableBinding(props.keybinds.commandList, props.keybinds.leader))
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
const commandKeys = createMemo(() => promptBindings(props.keybinds.commandList, props.keybinds.leader))
const hints = createMemo(() => hintFlags(term().width))
const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0)
const queue = createMemo(() => props.state().queue)
const duration = createMemo(() => props.state().duration)
const usage = createMemo(() => props.state().usage)
const interruptKey = createMemo(() => interrupt() || "/exit")
const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const theme = createMemo(() => runTheme().footer)
const block = createMemo(() => runTheme().block)
const spin = createMemo(() => {
return {
frames: createFrames({
color: theme().highlight,
style: "blocks",
inactiveFactor: 0.6,
minAlpha: 0.3,
}),
color: createColors({
color: theme().highlight,
style: "blocks",
inactiveFactor: 0.6,
minAlpha: 0.3,
}),
}
})
const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
const view = active()
return view.type === "permission" ? view : undefined
})
const question = createMemo<Extract<FooterView, { type: "question" }> | undefined>(() => {
const view = active()
return view.type === "question" ? view : undefined
})
const promptView = createMemo(() => {
if (active().type !== "prompt") {
return active().type
}
const current = route()
return current.type === "composer" ? "prompt" : current.type
})
const openCommand = () => {
setRoute({ type: "command" })
props.onSubagentSelect?.(undefined)
}
const openModel = () => {
setRoute({ type: "model" })
props.onSubagentSelect?.(undefined)
}
const openVariant = () => {
setRoute({ type: "variant" })
props.onSubagentSelect?.(undefined)
}
const closePanel = () => {
setRoute({ type: "composer" })
}
const openTab = (sessionID: string) => {
setRoute({ type: "subagent", sessionID })
props.onSubagentSelect?.(sessionID)
}
const closeTab = () => {
setRoute({ type: "composer" })
props.onSubagentSelect?.(undefined)
}
const toggleTab = (sessionID: string) => {
const current = route()
if (current.type === "subagent" && current.sessionID === sessionID) {
closeTab()
return
}
openTab(sessionID)
}
const cycleTab = (dir: -1 | 1) => {
if (tabs().length === 0) {
return
}
const routeState = route()
const current =
routeState.type === "subagent" ? tabs().findIndex((item) => item.sessionID === routeState.sessionID) : -1
const index = current === -1 ? 0 : (current + dir + tabs().length) % tabs().length
const next = tabs()[index]
if (!next) {
return
}
openTab(next.sessionID)
}
const composer = createPromptState({
directory: props.directory,
findFiles: props.findFiles,
agents: props.agents,
resources: props.resources,
commands: props.commands,
keybinds: props.keybinds,
state: props.state,
view: promptView,
prompt,
width: () => term().width,
theme,
history: props.history,
onSubmit: props.onSubmit,
onCycle: props.onCycle,
onInterrupt: props.onInterrupt,
onInputClear: props.onInputClear,
onExitRequest: props.onExitRequest,
onExit: props.onExit,
onRows: props.onRows,
onStatus: props.onStatus,
})
const menu = createMemo(() => prompt() && composer.visible())
createEffect(() => {
props.onRequestExit?.(composer.requestExit)
})
onCleanup(() => {
props.onRequestExit?.(undefined)
})
useKeyboard((event) => {
if (event.defaultPrevented) {
return
}
if (active().type !== "prompt") {
return
}
if (route().type !== "composer") {
return
}
if (composer.visible()) {
return
}
if (!promptHit(commandKeys(), promptInfo(event))) {
return
}
event.preventDefault()
openCommand()
})
useKeyboard((event) => {
if (active().type !== "prompt") {
return
}
const slot = subagentShortcut(event)
if (slot !== undefined) {
const next = tabs()[slot]
if (!next) {
return
}
event.preventDefault()
toggleTab(next.sessionID)
}
})
createEffect(() => {
const current = route()
if (current.type !== "subagent") {
return
}
if (tabs().some((item) => item.sessionID === current.sessionID)) {
return
}
closeTab()
})
createEffect(() => {
if (active().type === "prompt") {
return
}
const current = route()
if (current.type !== "command" && current.type !== "model" && current.type !== "variant") {
return
}
closePanel()
})
createEffect(() => {
props.onLayout({
route: route(),
tabs: tabs().length > 0,
autocomplete: menu(),
})
})
return (
<box
id="run-direct-footer-shell"
width="100%"
height="100%"
border={false}
backgroundColor="transparent"
flexDirection="column"
gap={0}
padding={0}
>
<box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
<Show when={showTabs()}>
<RunFooterSubagentTabs tabs={tabs()} selected={selected()} theme={theme()} width={term().width} />
</Show>
<Show
when={inspecting()}
fallback={
<box width="100%" flexDirection="column" gap={0}>
<box
id="run-direct-footer-composer-frame"
width="100%"
flexShrink={0}
border={panel() ? false : ["left"]}
borderColor={theme().highlight}
customBorderChars={{
...EMPTY_BORDER,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box
id="run-direct-footer-composer-area"
width="100%"
flexGrow={1}
paddingLeft={0}
paddingRight={0}
paddingTop={0}
flexDirection="column"
backgroundColor={panel() ? "transparent" : theme().surface}
gap={0}
>
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
<Switch>
<Match when={active().type === "prompt" && route().type === "composer"}>
<RunPromptBody
theme={theme}
placeholder={composer.placeholder}
bindings={composer.bindings}
onSubmit={composer.onSubmit}
onKeyDown={composer.onKeyDown}
onContentChange={composer.onContentChange}
bind={composer.bind}
/>
</Match>
<Match when={commanding()}>
<RunCommandMenuBody
theme={theme}
commands={props.commands}
variants={props.variants}
keybinds={props.keybinds}
onClose={closePanel}
onModel={openModel}
onVariant={openVariant}
onVariantCycle={() => {
props.onCycle()
closePanel()
}}
onCommand={(name) => {
composer.submitText(`/${name}`)
closePanel()
}}
onNew={() => {
composer.submitText("/new")
closePanel()
}}
onExit={props.onExit}
/>
</Match>
<Match when={modeling()}>
<RunModelSelectBody
theme={theme}
providers={props.providers}
current={props.currentModel}
onClose={closePanel}
onSelect={(model) => {
props.onModelSelect(model)
closePanel()
}}
/>
</Match>
<Match when={varianting()}>
<RunVariantSelectBody
theme={theme}
variants={props.variants}
current={props.currentVariant}
onClose={closePanel}
onSelect={(variant) => {
props.onVariantSelect(variant)
closePanel()
}}
/>
</Match>
<Match when={active().type === "permission"}>
<RunPermissionBody
request={permission()!.request}
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>
</Switch>
</box>
<Show when={!menu() && !panel()}>
<box
id="run-direct-footer-meta-row"
width="100%"
flexDirection="row"
gap={1}
paddingLeft={2}
flexShrink={0}
paddingTop={1}
>
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
{props.agent}
</text>
<text
id="run-direct-footer-model"
fg={theme().text}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
{props.state().model}
</text>
</box>
</Show>
</box>
</box>
<Show when={!panel()}>
<Show
when={menu()}
fallback={
<box
id="run-direct-footer-line-6"
width="100%"
height={1}
border={["left"]}
borderColor={theme().highlight}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
vertical: "╹",
}}
flexShrink={0}
>
<box
id="run-direct-footer-line-6-fill"
width="100%"
height={1}
border={["bottom"]}
borderColor={theme().surface}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
horizontal: "▀",
}}
/>
</box>
}
>
<box
id="run-direct-footer-menu-transition"
width="100%"
height={1}
border={["left"]}
borderColor={theme().highlight}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
vertical: "┃",
}}
flexShrink={0}
>
<box
id="run-direct-footer-menu-transition-fill"
width="100%"
height={1}
backgroundColor={theme().surface}
/>
</box>
</Show>
<Show
when={menu()}
fallback={
<box
id="run-direct-footer-row"
width="100%"
height={1}
flexDirection="row"
justifyContent="space-between"
gap={1}
flexShrink={0}
>
<Show when={busy() || exiting()}>
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
<Show when={exiting()}>
<text
id="run-direct-footer-hint-exit"
fg={theme().highlight}
wrapMode="none"
truncate
marginLeft={1}
>
Press Ctrl-c again to exit
</text>
</Show>
<Show when={busy() && !exiting()}>
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
<text
id="run-direct-footer-hint-interrupt"
fg={armed() ? theme().highlight : theme().text}
wrapMode="none"
truncate
>
{interruptKey()}{" "}
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
{armed() ? "again to interrupt" : "interrupt"}
</span>
</text>
</Show>
</box>
</Show>
<Show when={!busy() && !exiting() && duration().length > 0}>
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
</text>
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
·
</text>
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
{duration()}
</text>
</box>
</box>
</Show>
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<box
id="run-direct-footer-hint-group"
flexDirection="row"
gap={2}
flexShrink={0}
justifyContent="flex-end"
>
<Show when={queue() > 0}>
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
{queue()} queued
</text>
</Show>
<Show when={usage().length > 0}>
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
{usage()}
</text>
</Show>
<Show when={command().length > 0 && hints().command}>
<text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
{command()} <span style={{ fg: theme().muted }}>commands</span>
</text>
</Show>
</box>
</box>
}
>
<box id="run-direct-footer-complete-shell" width="100%" flexDirection="column" flexShrink={0}>
<RunFooterMenu
id="run-direct-footer-complete"
theme={theme}
items={composer.options}
selected={composer.selected}
offset={composer.offset}
rows={composer.rows}
limit={FOOTER_MENU_ROWS}
paddingLeft={2}
/>
<box
id="run-direct-footer-complete-bottom"
width="100%"
height={1}
border={["left"]}
borderColor={theme().border}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
vertical: "╹",
}}
flexShrink={0}
>
<box
id="run-direct-footer-complete-bottom-fill"
width="100%"
height={1}
border={["bottom"]}
borderColor={theme().surface}
backgroundColor="transparent"
customBorderChars={{
...EMPTY_BORDER,
horizontal: "▀",
}}
/>
</box>
</box>
</Show>
</Show>
</box>
}
>
<box
id="run-direct-footer-subagent-frame"
width="100%"
flexGrow={1}
flexShrink={1}
border={["left"]}
borderColor={theme().highlight}
customBorderChars={{
...EMPTY_BORDER,
vertical: "┃",
}}
>
<RunFooterSubagentBody
active={inspecting}
theme={runTheme}
detail={detail}
width={() => term().width}
diffStyle={props.diffStyle}
onCycle={cycleTab}
onClose={closeTab}
/>
</box>
</Show>
</box>
)
}
@@ -0,0 +1,154 @@
import { KeyEvent } from "@opentui/core"
import { Keymap, type Binding, type KeySequencePart } from "@opentui/keymap"
import { registerDefaultKeys, registerLeader } from "@opentui/keymap/addons"
import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
type ParsedBindingInput = Pick<Binding, "key" | "event">
export type ParsedBinding = {
sequence: KeySequencePart[]
event: "press" | "release"
}
const keyNameAliases = {
delete: "del",
enter: "return",
escape: "esc",
pagedown: "pgdn",
pageup: "pgup",
} as const
const modifierAliases = {
meta: "alt",
} as const
function hostPlatform() {
if (process.platform === "darwin") {
return "macos" as const
}
if (process.platform === "win32") {
return "windows" as const
}
if (process.platform === "linux") {
return "linux" as const
}
return "unknown" as const
}
function createCommandEvent() {
return new KeyEvent({
name: "command",
ctrl: false,
meta: false,
shift: false,
option: false,
sequence: "",
number: false,
raw: "",
eventType: "press",
source: "raw",
})
}
function createParser(leader: string) {
const platform = hostPlatform()
const keymap = new Keymap({
metadata: {
platform,
primaryModifier: platform === "macos" ? "super" : platform === "unknown" ? "unknown" : "ctrl",
modifiers: {
ctrl: "supported",
shift: "supported",
meta: "supported",
super: "unknown",
hyper: "unknown",
},
},
rootTarget: {},
isDestroyed: false,
getFocusedTarget() {
return null
},
getParentTarget(_target) {
return null
},
isTargetDestroyed(_target) {
return false
},
onKeyPress(_listener) {
return () => {}
},
onKeyRelease(_listener) {
return () => {}
},
onFocusChange(_listener) {
return () => {}
},
onTargetDestroy(_target, _listener) {
return () => {}
},
createCommandEvent,
})
const offDefault = registerDefaultKeys(keymap)
const offLeader = registerLeader(keymap, { trigger: leader })
return {
keymap,
dispose() {
offLeader()
offDefault()
},
}
}
function formatOptions(leader: string) {
return {
tokenDisplay: {
leader,
},
keyNameAliases,
modifierAliases,
} as const
}
function splitBinding(binding: ParsedBindingInput) {
if (typeof binding.key !== "string" || !binding.key.includes(",")) {
return [binding]
}
return binding.key
.split(",")
.map((key) => key.trim())
.filter(Boolean)
.map((key) => ({
...binding,
key,
}))
}
export function parseBindings(bindings: readonly ParsedBindingInput[], leader: string): ParsedBinding[] {
const parser = createParser(leader)
try {
return bindings.flatMap((binding) =>
splitBinding(binding).map((item) => ({
sequence: Array.from(parser.keymap.parseKeySequence(item.key)),
event: item.event ?? "press",
})),
)
} finally {
parser.dispose()
}
}
export function formatBinding(bindings: readonly ParsedBindingInput[], leader: string) {
return formatKeySequence(parseBindings(bindings, leader)[0]?.sequence, formatOptions(leader))
}
export function formatBindings(bindings: readonly ParsedBindingInput[], leader: string) {
return formatCommandBindings(parseBindings(bindings, leader), formatOptions(leader))
}
+119
View File
@@ -0,0 +1,119 @@
import { INVALID_SPAN_CONTEXT, context, trace, SpanStatusCode, type Span } from "@opentelemetry/api"
import { Effect, ManagedRuntime } from "effect"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { Observability } from "@opencode-ai/core/effect/observability"
type AttributeValue = string | number | boolean | undefined
export type RunSpanAttributes = Record<string, AttributeValue>
const noop = trace.wrapSpanContext(INVALID_SPAN_CONTEXT)
const tracer = trace.getTracer("opencode.run")
const runtime = ManagedRuntime.make(Observability.layer, { memoMap })
let ready: Promise<void> | undefined
function attributes(input?: RunSpanAttributes): Record<string, string | number | boolean> | undefined {
if (!input) {
return undefined
}
const out = Object.entries(input).flatMap(([key, value]) => (value === undefined ? [] : [[key, value] as const]))
if (out.length === 0) {
return undefined
}
return Object.fromEntries(out)
}
function message(error: unknown) {
if (typeof error === "string") {
return error
}
if (error instanceof Error) {
return error.message || error.name
}
return String(error)
}
function ensure() {
if (!Observability.enabled) {
return Promise.resolve()
}
if (ready) {
return ready
}
ready = runtime.runPromise(Effect.void).then(
() => undefined,
(error) => {
ready = undefined
throw error
},
)
return ready
}
function finish<A>(span: Span, out: Promise<A>) {
return out.then(
(value) => {
span.end()
return value
},
(error) => {
recordRunSpanError(span, error)
span.end()
throw error
},
)
}
export function setRunSpanAttributes(span: Span, input?: RunSpanAttributes): void {
const next = attributes(input)
if (!next) {
return
}
span.setAttributes(next)
}
export function recordRunSpanError(span: Span, error: unknown): void {
const next = message(error)
span.recordException(error instanceof Error ? error : next)
span.setStatus({
code: SpanStatusCode.ERROR,
message: next,
})
}
export function withRunSpan<A>(
name: string,
input: RunSpanAttributes | undefined,
fn: (span: Span) => Promise<A> | A,
): A | Promise<A> {
if (!Observability.enabled) {
return fn(noop)
}
return ensure().then(
() => {
const span = tracer.startSpan(name, {
attributes: attributes(input),
})
return context.with(
trace.setSpan(context.active(), span),
() =>
finish(
span,
new Promise<A>((resolve) => {
resolve(fn(span))
}),
),
)
},
() => fn(noop),
)
}
@@ -0,0 +1,256 @@
// Pure state machine for the permission UI.
//
// Lives outside the JSX component so it can be tested independently. The
// machine has three stages:
//
// permission → initial view with Allow once / Always / Reject options
// always → confirmation step (Confirm / Cancel)
// reject → text input for rejection message
//
// permissionRun() is the main transition: given the current state and the
// selected option, it returns a new state and optionally a PermissionReply
// to send to the SDK. The component calls this on enter/click.
//
// permissionInfo() extracts display info (icon, title, lines, diff) from
// the request, delegating to tool.ts for tool-specific formatting.
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import type { PermissionReply } from "./types"
import { toolPath, toolPermissionInfo } from "./tool"
type Dict = Record<string, unknown>
export type PermissionStage = "permission" | "always" | "reject"
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
export type PermissionBodyState = {
requestID: string
stage: PermissionStage
selected: PermissionOption
message: string
submitting: boolean
}
export type PermissionInfo = {
icon: string
title: string
lines: string[]
diff?: string
file?: string
}
export type PermissionStep = {
state: PermissionBodyState
reply?: PermissionReply
}
function dict(v: unknown): Dict {
if (!v || typeof v !== "object" || Array.isArray(v)) {
return {}
}
return { ...v }
}
function text(v: unknown): string {
return typeof v === "string" ? v : ""
}
function data(request: PermissionRequest): Dict {
const meta = dict(request.metadata)
return {
...meta,
...dict(meta.input),
}
}
function patterns(request: PermissionRequest): string[] {
return request.patterns.filter((item): item is string => typeof item === "string")
}
export function createPermissionBodyState(requestID: string): PermissionBodyState {
return {
requestID,
stage: "permission",
selected: "once",
message: "",
submitting: false,
}
}
export function permissionOptions(stage: PermissionStage): PermissionOption[] {
if (stage === "permission") {
return ["once", "always", "reject"]
}
if (stage === "always") {
return ["confirm", "cancel"]
}
return []
}
export function permissionInfo(request: PermissionRequest): PermissionInfo {
const pats = patterns(request)
const input = data(request)
const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats)
if (info) {
return info
}
if (request.permission === "external_directory") {
const meta = dict(request.metadata)
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
return {
icon: "←",
title: `Access external directory ${toolPath(dir, { home: true })}`,
lines: pats.map((item) => `- ${item}`),
}
}
if (request.permission === "doom_loop") {
return {
icon: "⟳",
title: "Continue after repeated failures",
lines: ["This keeps the session running despite repeated failures."],
}
}
return {
icon: "⚙",
title: `Call tool ${request.permission}`,
lines: [`Tool: ${request.permission}`],
}
}
export function permissionAlwaysLines(request: PermissionRequest): string[] {
if (request.always.length === 1 && request.always[0] === "*") {
return [`This will allow ${request.permission} until OpenCode is restarted.`]
}
return [
"This will allow the following patterns until OpenCode is restarted.",
...request.always.map((item) => `- ${item}`),
]
}
export function permissionLabel(option: PermissionOption): string {
if (option === "once") return "Allow once"
if (option === "always") return "Allow always"
if (option === "reject") return "Reject"
if (option === "confirm") return "Confirm"
return "Cancel"
}
export function permissionReply(requestID: string, reply: PermissionReply["reply"], message?: string): PermissionReply {
return {
requestID,
reply,
...(message && message.trim() ? { message: message.trim() } : {}),
}
}
export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
const list = permissionOptions(state.stage)
if (list.length === 0) {
return state
}
const idx = Math.max(0, list.indexOf(state.selected))
const selected = list[(idx + dir + list.length) % list.length]
return {
...state,
selected,
}
}
export function permissionHover(state: PermissionBodyState, option: PermissionOption): PermissionBodyState {
return {
...state,
selected: option,
}
}
export function permissionRun(state: PermissionBodyState, requestID: string, option: PermissionOption): PermissionStep {
if (state.submitting) {
return { state }
}
if (state.stage === "permission") {
if (option === "always") {
return {
state: {
...state,
stage: "always",
selected: "confirm",
},
}
}
if (option === "reject") {
return {
state: {
...state,
stage: "reject",
selected: "reject",
},
}
}
return {
state,
reply: permissionReply(requestID, "once"),
}
}
if (state.stage !== "always") {
return { state }
}
if (option === "cancel") {
return {
state: {
...state,
stage: "permission",
selected: "always",
},
}
}
return {
state,
reply: permissionReply(requestID, "always"),
}
}
export function permissionReject(state: PermissionBodyState, requestID: string): PermissionReply | undefined {
if (state.submitting) {
return undefined
}
return permissionReply(requestID, "reject", state.message)
}
export function permissionCancel(state: PermissionBodyState): PermissionBodyState {
return {
...state,
stage: "permission",
selected: "reject",
}
}
export function permissionEscape(state: PermissionBodyState): PermissionBodyState {
if (state.stage === "always") {
return {
...state,
stage: "permission",
selected: "always",
}
}
return {
...state,
stage: "reject",
selected: "reject",
}
}
@@ -0,0 +1,328 @@
// Pure state machine for the prompt input.
//
// Handles keybind parsing, history ring navigation, and the leader-key
// sequence for variant cycling. All functions are pure -- they take state
// in and return new state out, with no side effects.
//
// The history ring (PromptHistoryState) stores past prompts and tracks
// the current browse position. When the user arrows up at cursor offset 0,
// the current draft is saved and history begins. Arrowing past the end
// restores the draft.
//
// The leader-key cycle (promptCycle) uses a two-step pattern: first press
// arms the leader, second press within the timeout fires the action.
import type { KeyBinding } from "@opentui/core"
import { formatBinding, parseBindings } from "./keymap.shared"
import type { FooterKeybinds, RunPrompt } from "./types"
const HISTORY_LIMIT = 200
export type PromptHistoryState = {
items: RunPrompt[]
index: number | null
draft: string
}
export function promptInfo(event: {
name: string
ctrl?: boolean
meta?: boolean
shift?: boolean
super?: boolean
}) {
return {
name: event.name === " " ? "space" : event.name,
ctrl: !!event.ctrl,
meta: !!event.meta,
shift: !!event.shift,
super: !!event.super,
leader: false,
}
}
type PromptInfo = ReturnType<typeof promptInfo>
export type PromptKeys = {
leaders: PromptInfo[]
cycles: PromptInfo[]
interrupts: PromptInfo[]
previous: PromptInfo[]
next: PromptInfo[]
clear: PromptInfo[]
bindings: KeyBinding[]
}
export type PromptCycle = {
arm: boolean
clear: boolean
cycle: boolean
consume: boolean
}
export type PromptMove = {
state: PromptHistoryState
text?: string
cursor?: number
apply: boolean
}
export function promptCopy(prompt: RunPrompt): RunPrompt {
return {
text: prompt.text,
parts: structuredClone(prompt.parts),
}
}
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
}
function promptKey(binding: ReturnType<typeof parseBindings>[number]): PromptInfo | undefined {
if (binding.event !== "press") {
return undefined
}
const first = binding.sequence[0]
const second = binding.sequence[1]
if (!first) {
return undefined
}
if (!second) {
return first.patternName || first.tokenName
? undefined
: {
name: first.stroke.name,
ctrl: first.stroke.ctrl,
meta: first.stroke.meta,
shift: first.stroke.shift,
super: first.stroke.super,
leader: false,
}
}
if (binding.sequence.length !== 2 || first.tokenName !== "leader" || second.patternName || second.tokenName) {
return undefined
}
return {
name: second.stroke.name,
ctrl: second.stroke.ctrl,
meta: second.stroke.meta,
shift: second.stroke.shift,
super: second.stroke.super,
leader: true,
}
}
export function promptBindings(bindings: FooterKeybinds["commandList"], leader: string): PromptInfo[] {
return parseBindings(bindings, leader).flatMap((binding) => {
const key = promptKey(binding)
return key ? [key] : []
})
}
function mapInputBindings(bindings: FooterKeybinds["inputSubmit"], leader: string, action: "submit" | "newline"): KeyBinding[] {
return promptBindings(bindings, leader).flatMap((key) => {
if (key.leader) {
return []
}
return [
{
name: key.name,
ctrl: key.ctrl || undefined,
meta: key.meta || undefined,
shift: key.shift || undefined,
super: key.super || undefined,
action,
},
]
})
}
function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
return [
...mapInputBindings(keybinds.inputSubmit, keybinds.leader, "submit"),
...mapInputBindings(keybinds.inputNewline, keybinds.leader, "newline"),
]
}
export function promptKeys(keybinds: FooterKeybinds): PromptKeys {
return {
leaders: promptBindings([{ key: keybinds.leader }], keybinds.leader),
cycles: promptBindings(keybinds.variantCycle, keybinds.leader),
interrupts: promptBindings(keybinds.interrupt, keybinds.leader),
previous: promptBindings(keybinds.historyPrevious, keybinds.leader),
next: promptBindings(keybinds.historyNext, keybinds.leader),
clear: promptBindings(keybinds.inputClear, keybinds.leader),
bindings: textareaBindings(keybinds),
}
}
export function printableBinding(bindings: FooterKeybinds["commandList"], leader: string): string {
return formatBinding(bindings, leader)
}
export function isExitCommand(input: string): boolean {
const text = input.trim().toLowerCase()
return text === "/exit" || text === "/quit" || text === ":q"
}
export function isNewCommand(input: string): boolean {
return input.trim().toLowerCase() === "/new"
}
export function promptHit(bindings: PromptInfo[], event: PromptInfo): boolean {
return bindings.some(
(item) =>
item.name === event.name &&
item.ctrl === event.ctrl &&
item.meta === event.meta &&
item.shift === event.shift &&
item.super === event.super &&
item.leader === event.leader,
)
}
export function promptCycle(
armed: boolean,
event: PromptInfo,
leaders: PromptInfo[],
cycles: PromptInfo[],
): PromptCycle {
if (!armed && promptHit(leaders, event)) {
return {
arm: true,
clear: false,
cycle: false,
consume: true,
}
}
if (armed) {
return {
arm: false,
clear: true,
cycle: promptHit(cycles, { ...event, leader: true }),
consume: true,
}
}
if (!promptHit(cycles, event)) {
return {
arm: false,
clear: false,
cycle: false,
consume: false,
}
}
return {
arm: false,
clear: false,
cycle: true,
consume: true,
}
}
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
const next: RunPrompt[] = []
for (const item of list) {
if (next.length > 0 && promptSame(next[next.length - 1], item)) {
continue
}
next.push(item)
}
return {
items: next.slice(-HISTORY_LIMIT),
index: null,
draft: "",
}
}
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
if (!prompt.text.trim()) {
return state
}
const next = promptCopy(prompt)
if (state.items[state.items.length - 1] && promptSame(state.items[state.items.length - 1], next)) {
return {
...state,
index: null,
draft: "",
}
}
const items = [...state.items, next].slice(-HISTORY_LIMIT)
return {
...state,
items,
index: null,
draft: "",
}
}
export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptMove {
if (state.items.length === 0) {
return { state, apply: false }
}
if (dir === -1 && cursor !== 0) {
return { state, apply: false }
}
if (dir === 1 && cursor !== text.length) {
return { state, apply: false }
}
if (state.index === null) {
if (dir === 1) {
return { state, apply: false }
}
const idx = state.items.length - 1
return {
state: {
...state,
index: idx,
draft: text,
},
text: state.items[idx].text,
cursor: 0,
apply: true,
}
}
const idx = state.index + dir
if (idx < 0) {
return { state, apply: false }
}
if (idx >= state.items.length) {
return {
state: {
...state,
index: null,
},
text: state.draft,
cursor: state.draft.length,
apply: true,
}
}
return {
state: {
...state,
index: idx,
},
text: state.items[idx].text,
cursor: dir === -1 ? 0 : state.items[idx].text.length,
apply: true,
}
}
@@ -0,0 +1,340 @@
// 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 { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
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: QuestionRequest): boolean {
return request.questions.length === 1 && request.questions[0]?.multiple !== true
}
export function questionTabs(request: QuestionRequest): number {
return questionSingle(request) ? 1 : request.questions.length + 1
}
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
return !questionSingle(request) && state.tab === request.questions.length
}
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
return request.questions[state.tab]
}
export function questionCustom(request: QuestionRequest, 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: QuestionRequest, 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: QuestionRequest, 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: QuestionRequest,
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: QuestionRequest, 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: QuestionRequest): 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: QuestionRequest): 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: QuestionRequest, state: QuestionBodyState): QuestionReply {
return {
requestID: request.id,
answers: questionAnswers(state, request.questions.length),
}
}
export function questionReject(request: QuestionRequest): QuestionReject {
return {
requestID: request.id,
}
}
export function questionHint(request: QuestionRequest, 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`
}
@@ -0,0 +1,214 @@
// Boot-time resolution for direct interactive mode.
//
// These functions run concurrently at startup to gather everything the runtime
// needs before the first frame: keybinds from TUI config, diff display style,
// model variant list with context limits, and session history for the prompt
// history ring. All are async because they read config or hit the SDK, but
// none block each other.
import { Context, Effect, Layer } from "effect"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { makeRuntime } from "@/effect/run-service"
import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared"
import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } from "./types"
import { pickVariant } from "./variant.shared"
const DEFAULT_KEYBINDS: FooterKeybinds = {
leader: "ctrl+x",
leaderTimeout: 2000,
commandList: [{ key: "ctrl+p" }],
variantCycle: [{ key: "ctrl+t" }],
interrupt: [{ key: "escape" }],
historyPrevious: [{ key: "up" }],
historyNext: [{ key: "down" }],
inputClear: [{ key: "ctrl+c" }],
inputSubmit: [{ key: "return" }],
inputNewline: [{ key: "shift+return,ctrl+return,alt+return,ctrl+j" }],
}
export type ModelInfo = {
providers: RunProvider[]
variants: string[]
limits: Record<string, number>
}
export type SessionInfo = {
first: boolean
history: RunPrompt[]
variant: string | undefined
}
type Config = Awaited<ReturnType<typeof TuiConfig.get>>
type BootService = {
readonly resolveModelInfo: (
sdk: RunInput["sdk"],
directory: string,
model: RunInput["model"],
) => Effect.Effect<ModelInfo>
readonly resolveSessionInfo: (
sdk: RunInput["sdk"],
sessionID: string,
model: RunInput["model"],
) => Effect.Effect<SessionInfo>
readonly resolveFooterKeybinds: () => Effect.Effect<FooterKeybinds>
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
}
const configTask: { current?: Promise<Config> } = {}
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
function loadConfig() {
return reusePendingTask(configTask, () => TuiConfig.get())
}
function emptyModelInfo(): ModelInfo {
return {
providers: [],
variants: [],
limits: {},
}
}
function emptySessionInfo(): SessionInfo {
return {
first: true,
history: [],
variant: undefined,
}
}
function footerKeybinds(config: Config | undefined): FooterKeybinds {
if (!config) {
return DEFAULT_KEYBINDS
}
return {
leader: config.keymap.leader,
leaderTimeout: config.keymap.leader_timeout,
commandList: config.keymap.get("global", "command.palette.show") ?? [],
variantCycle: config.keymap.get("global", "variant.cycle") ?? [],
interrupt: config.keymap.get("prompt", "session.interrupt") ?? [],
historyPrevious: config.keymap.get("prompt", "prompt.history.previous") ?? [],
historyNext: config.keymap.get("prompt", "prompt.history.next") ?? [],
inputClear: config.keymap.get("prompt", "prompt.clear") ?? [],
inputSubmit: config.keymap.get("input", "input.submit") ?? [],
inputNewline: config.keymap.get("input", "input.newline") ?? [],
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined)))
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
sdk: RunInput["sdk"],
directory: string,
model: RunInput["model"],
) {
const connected = yield* Effect.promise(() =>
sdk.config
.providers({ directory })
.then((item) => item.data?.providers)
.catch(() => undefined),
)
const providers = yield* Effect.promise(() =>
connected
? Promise.resolve(connected)
: sdk.provider
.list()
.then((item) => item.data?.all ?? [])
.catch(() => []),
)
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,
}
})
const resolveSessionInfo = Effect.fn("RunBoot.resolveSessionInfo")(function* (
sdk: RunInput["sdk"],
sessionID: string,
model: RunInput["model"],
) {
const session = yield* Effect.promise(() => resolveSession(sdk, sessionID).catch(() => undefined))
if (!session) {
return emptySessionInfo()
}
return {
first: session.first,
history: sessionHistory(session),
variant: pickVariant(model, session),
}
})
const resolveFooterKeybinds = Effect.fn("RunBoot.resolveFooterKeybinds")(function* () {
return footerKeybinds(yield* config())
})
const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
return (yield* config())?.diff_style ?? "auto"
})
return Service.of({
resolveModelInfo,
resolveSessionInfo,
resolveFooterKeybinds,
resolveDiffStyle,
})
}),
)
const runtime = makeRuntime(Service, layer)
// Fetches available variants and context limits for every provider/model pair.
export async function resolveModelInfo(
sdk: RunInput["sdk"],
directory: string,
model: RunInput["model"],
): Promise<ModelInfo> {
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model)).catch(() => emptyModelInfo())
}
// Fetches session messages to determine if this is the first turn and build prompt history.
export async function resolveSessionInfo(
sdk: RunInput["sdk"],
sessionID: string,
model: RunInput["model"],
): Promise<SessionInfo> {
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
}
// Reads keybind overrides from TUI config and merges them with defaults.
export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS)
}
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
}
@@ -0,0 +1,308 @@
// Lifecycle management for the split-footer renderer.
//
// Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme
// from the terminal palette, writes the entry splash to scrollback, and
// constructs the RunFooter. Returns a Lifecycle handle whose close() writes
// the exit splash and tears everything down in the right order:
// footer.close → footer.destroy → renderer shutdown.
//
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
// back to the usual two-press exit sequence through RunFooter.requestExit().
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { Session as SessionApi } from "@/session/session"
import * as Locale from "@/util/locale"
import { withRunSpan } from "./otel"
import { resolveInteractiveStdin } from "./runtime.stdin"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
import type {
FooterApi,
FooterKeybinds,
PermissionReply,
QuestionReject,
QuestionReply,
RunAgent,
RunDiffStyle,
RunInput,
RunPrompt,
RunResource,
} from "./types"
import { formatModelLabel } from "./variant.shared"
const FOOTER_HEIGHT = 7
type SplashState = {
entry: boolean
exit: boolean
}
type CycleResult = {
modelLabel?: string
status?: string
variant?: string | undefined
variants?: string[]
}
type FooterLabels = {
agentLabel: string
modelLabel: string
}
export type LifecycleInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
sessionID: string
sessionTitle?: string
getSessionID?: () => string | undefined
first: boolean
history: RunPrompt[]
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
keybinds: FooterKeybinds
diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => 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
onSubagentSelect?: (sessionID: string | undefined) => void
}
export type Lifecycle = {
footer: FooterApi
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
}
// Gracefully tears down the renderer. Order matters: switch external output
// back to passthrough before leaving split-footer mode, so pending stdout
// doesn't get captured into the now-dead scrollback pipeline.
function shutdown(renderer: CliRenderer): void {
if (renderer.isDestroyed) {
return
}
if (renderer.externalOutputMode === "capture-stdout") {
renderer.externalOutputMode = "passthrough"
}
if (renderer.screenMode === "split-footer") {
renderer.screenMode = "main-screen"
}
if (!renderer.isDestroyed) {
renderer.destroy()
}
}
function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !SessionApi.isDefaultTitle(title)) {
return {
title,
showSession: true,
}
}
const next = history.find((item) => item.text.trim().length > 0)
return {
title: next?.text ?? title,
showSession: !!next,
}
}
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
const agentLabel = Locale.titlecase(input.agent ?? "build")
if (!input.model) {
return {
agentLabel,
modelLabel: "Model default",
}
}
return {
agentLabel,
modelLabel: formatModelLabel(input.model, input.variant),
}
}
function queueSplash(
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
state: SplashState,
phase: keyof SplashState,
write: ScrollbackWriter | undefined,
): boolean {
if (state[phase]) {
return false
}
if (!write) {
return false
}
state[phase] = true
renderer.writeToScrollback(write)
renderer.requestRender()
return true
}
// Boots the split-footer renderer and constructs the RunFooter.
//
// The renderer starts in split-footer mode with captured stdout so that
// scrollback commits and footer repaints happen in the same frame. After
// the entry splash, RunFooter takes over the footer region.
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
return withRunSpan(
"RunLifecycle.boot",
{
"opencode.agent.name": input.agent,
"opencode.directory": input.directory,
"opencode.first": input.first,
"opencode.model.provider": input.model?.providerID,
"opencode.model.id": input.model?.modelID,
"opencode.model.variant": input.variant,
"session.id": input.getSessionID?.() || input.sessionID || undefined,
},
async () => {
const source = resolveInteractiveStdin()
try {
const renderer = await createCliRenderer({
stdin: source.stdin,
targetFps: 30,
maxFps: 60,
useMouse: false,
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
useKittyKeyboard: { events: process.platform === "win32" },
screenMode: "split-footer",
footerHeight: FOOTER_HEIGHT,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
clearOnShutdown: false,
})
const theme = await resolveRunTheme(renderer)
renderer.setBackgroundColor(theme.background)
const state: SplashState = {
entry: false,
exit: false,
}
const splash = splashInfo(input.sessionTitle, input.history)
const meta = splashMeta({
title: splash.title,
session_id: input.sessionID,
})
const footerTask = import("./footer")
const wrote = queueSplash(
renderer,
state,
"entry",
entrySplash({
...meta,
theme: theme.splash,
showSession: splash.showSession,
}),
)
await renderer.idle().catch(() => {})
const { RunFooter } = await footerTask
const labels = footerLabels({
agent: input.agent,
model: input.model,
variant: input.variant,
})
const footer = new RunFooter(renderer, {
directory: input.directory,
findFiles: input.findFiles,
agents: input.agents,
resources: input.resources,
sessionID: input.getSessionID ?? (() => input.sessionID),
...labels,
model: input.model,
variant: input.variant,
first: input.first,
history: input.history,
theme,
wrote,
keybinds: input.keybinds,
diffStyle: input.diffStyle,
onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply,
onQuestionReject: input.onQuestionReject,
onCycleVariant: input.onCycleVariant,
onModelSelect: input.onModelSelect,
onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt,
onSubagentSelect: input.onSubagentSelect,
})
const sigint = () => {
footer.requestExit()
}
process.on("SIGINT", sigint)
let closed = false
const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }) => {
if (closed) {
return
}
closed = true
return withRunSpan(
"RunLifecycle.close",
{
"opencode.show_exit": next.showExit,
"session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined,
},
async () => {
process.off("SIGINT", sigint)
try {
await footer.idle().catch(() => {})
const show = renderer.isDestroyed ? false : next.showExit
if (!renderer.isDestroyed && show) {
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
queueSplash(
renderer,
state,
"exit",
exitSplash({
...splashMeta({
title: splash.title,
session_id: sessionID,
}),
theme: theme.splash,
}),
)
await renderer.idle().catch(() => {})
}
} finally {
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
shutdown(renderer)
source.cleanup?.()
}
},
)
}
return {
footer,
close,
}
} catch (error) {
source.cleanup?.()
throw error
}
},
)
}
@@ -0,0 +1,293 @@
// Serial prompt queue for direct interactive mode.
//
// Prompts arrive from the footer (user types and hits enter) and queue up
// here. The queue drains one turn at a time: it appends the user row to
// scrollback, calls input.run() to execute the turn through the stream
// transport, and waits for completion before starting the next prompt.
//
// The queue also handles /exit, /quit, and /new commands, empty-prompt rejection,
// and tracks per-turn wall-clock duration for the footer status line.
//
// Resolves when the footer closes and all in-flight work finishes.
import * as Locale from "@/util/locale"
import { isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
type Trace = {
write(type: string, data?: unknown): void
}
type Deferred<T = void> = {
promise: Promise<T>
resolve: (value: T | PromiseLike<T>) => void
reject: (error?: unknown) => void
}
export type QueueInput = {
footer: FooterApi
initialInput?: string
trace?: Trace
onSend?: (prompt: RunPrompt) => void
onNewSession?: () => void | Promise<void>
run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
}
type State = {
queue: RunPrompt[]
ctrl?: AbortController
closed: boolean
}
function defer<T = void>(): Deferred<T> {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (error?: unknown) => void
const promise = new Promise<T>((next, fail) => {
resolve = next
reject = fail
})
return { promise, resolve, reject }
}
// Runs the prompt queue until the footer closes.
//
// Subscribes to footer prompt events, queues them, and drains one at a
// time through input.run(). If the user submits multiple prompts while
// a turn is running, they queue up and execute in order. The footer shows
// the queue depth so the user knows how many are pending.
export async function runPromptQueue(input: QueueInput): Promise<void> {
const stop = defer<{ type: "closed" }>()
const done = defer()
const state: State = {
queue: [],
closed: input.footer.isClosed,
}
let draining: Promise<void> | undefined
const emit = (next: FooterEvent, row: Record<string, unknown>) => {
input.trace?.write("ui.patch", row)
input.footer.event(next)
}
const finish = () => {
if (!state.closed || draining) {
return
}
done.resolve()
}
const close = () => {
if (state.closed) {
return
}
state.closed = true
state.queue.length = 0
state.ctrl?.abort()
stop.resolve({ type: "closed" })
finish()
}
const drain = () => {
if (draining || state.closed || state.queue.length === 0) {
return
}
draining = (async () => {
try {
while (!state.closed && state.queue.length > 0) {
const prompt = state.queue.shift()
if (!prompt) {
continue
}
if (isNewCommand(prompt.text)) {
emit(
{
type: "queue",
queue: state.queue.length,
},
{
queue: state.queue.length,
},
)
if (!input.onNewSession) {
emit(
{
type: "stream.patch",
patch: {
status: "new sessions unavailable",
},
},
{
status: "new sessions unavailable",
},
)
continue
}
emit(
{
type: "stream.patch",
patch: {
phase: "running",
status: "starting new session",
queue: state.queue.length,
},
},
{
phase: "running",
status: "starting new session",
queue: state.queue.length,
},
)
await input.onNewSession()
continue
}
emit(
{
type: "turn.send",
queue: state.queue.length,
},
{
phase: "running",
status: "sending prompt",
queue: state.queue.length,
},
)
const start = Date.now()
const ctrl = new AbortController()
state.ctrl = ctrl
try {
await input.footer.idle()
if (state.closed) {
break
}
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
input.onSend?.(prompt)
if (state.closed) {
break
}
const task = input.run(prompt, ctrl.signal).then(
() => ({ type: "done" as const }),
(error) => ({ type: "error" as const, error }),
)
const next = await Promise.race([task, stop.promise])
if (next.type === "closed") {
ctrl.abort()
break
}
if (next.type === "error") {
throw next.error
}
} finally {
if (state.ctrl === ctrl) {
state.ctrl = undefined
}
const duration = Locale.duration(Math.max(0, Date.now() - start))
emit(
{
type: "turn.duration",
duration,
},
{
duration,
},
)
}
}
} catch (error) {
done.reject(error)
return
} finally {
draining = undefined
emit(
{
type: "turn.idle",
queue: state.queue.length,
},
{
phase: "idle",
status: "",
queue: state.queue.length,
},
)
}
finish()
})()
}
const submit = (prompt: RunPrompt) => {
if (!prompt.text.trim() || state.closed) {
return
}
if (isExitCommand(prompt.text)) {
input.footer.close()
return
}
state.queue.push(prompt)
emit(
{
type: "queue",
queue: state.queue.length,
},
{
queue: state.queue.length,
},
)
if (isNewCommand(prompt.text)) {
drain()
return
}
emit(
{
type: "first",
first: false,
},
{
first: false,
},
)
drain()
}
const offPrompt = input.footer.onPrompt((prompt) => {
submit(prompt)
})
const offClose = input.footer.onClose(() => {
close()
})
try {
if (state.closed) {
return
}
submit({
text: input.initialInput ?? "",
parts: [],
})
finish()
await done.promise
} finally {
offPrompt()
offClose()
close()
await draining?.catch(() => {})
}
}
@@ -0,0 +1,17 @@
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
}
@@ -0,0 +1,37 @@
import fs from "fs"
import * as tty from "node:tty"
export const INTERACTIVE_INPUT_ERROR = "--interactive requires a controlling terminal for input"
type InteractiveStdin = {
stdin: NodeJS.ReadStream
cleanup?: () => void
}
function openTerminalStdin(path: string): NodeJS.ReadStream {
return new tty.ReadStream(fs.openSync(path, "r"))
}
export function resolveInteractiveStdin(
stdin: NodeJS.ReadStream = process.stdin,
open: (path: string) => NodeJS.ReadStream = openTerminalStdin,
platform = process.platform,
): InteractiveStdin {
if (stdin.isTTY) {
return { stdin }
}
const file = platform === "win32" ? "CONIN$" : "/dev/tty"
try {
const stream = open(file)
return {
stdin: stream,
cleanup: () => {
stream.destroy()
},
}
} catch (error) {
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
}
}
@@ -0,0 +1,793 @@
// Top-level orchestrator for `run --interactive`.
//
// 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)
// runInteractiveLocalMode -- used for local in-process mode (no server)
//
// Both delegate to runInteractiveRuntime, which:
// 1. resolves keybinds, diff style, 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 { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createRunDemo } from "./demo"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
import { trace } from "./trace"
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
import type { RunInput, RunPrompt, RunProvider } 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 CreateSessionInput = {
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
}
type CreateSession = (
sdk: RunInput["sdk"],
input: CreateSessionInput,
) => Promise<{ id: string; title?: string }>
type RunRuntimeInput = {
boot: () => Promise<BootContext>
afterPaint?: (ctx: BootContext) => Promise<void> | void
resolveSession?: (
ctx: BootContext,
) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession>
files: RunInput["files"]
initialInput?: string
thinking: boolean
demo?: RunInput["demo"]
}
type RunLocalInput = {
directory: string
fetch: typeof globalThis.fetch
resolveAgent: () => Promise<string | undefined>
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
share: (sdk: RunInput["sdk"], sessionID: string) => Promise<void>
createSession?: CreateSession
agent: RunInput["agent"]
model: RunInput["model"]
variant: RunInput["variant"]
files: RunInput["files"]
initialInput?: string
thinking: boolean
demo?: RunInput["demo"]
}
type StreamState = {
mod: Awaited<typeof import("./stream.transport")>
handle: Awaited<ReturnType<Awaited<typeof import("./stream.transport")>["createSessionTransport"]>>
}
type ResolvedSession = {
sessionID: string
sessionTitle?: string
agent?: string | undefined
}
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 = {
shown: boolean
aborting: boolean
model: RunInput["model"]
providers: RunProvider[]
variants: string[]
limits: Record<string, number>
activeVariant: string | undefined
sessionID: string
history: RunPrompt[]
sessionTitle?: string
agent: string | undefined
switching?: Promise<void>
demo?: ReturnType<typeof createRunDemo>
selectSubagent?: (sessionID: string | undefined) => void
session?: Promise<void>
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
}
function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
if (!model) {
return []
}
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
}
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((x) => x.data?.title)
.catch(() => undefined)
}
// Core runtime loop. Boot resolves the SDK context, then we set up the
// lifecycle (renderer + footer), wire the stream transport for SDK events,
// and feed prompts through the queue until the user exits.
//
// Files only attach on the first prompt turn -- after that, includeFiles
// flips to false so subsequent turns don't re-send attachments.
async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
return withRunSpan(
"RunInteractive.session",
{
"opencode.mode": input.resolveSession ? "local" : "attach",
"opencode.initial_input": !!input.initialInput,
"opencode.demo": input.demo,
},
async (span) => {
const start = performance.now()
const log = trace()
const keybindTask = resolveFooterKeybinds()
const diffTask = resolveDiffStyle()
const ctx = await input.boot()
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
const sessionTask =
ctx.resume === true
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
: Promise.resolve({
first: true,
history: [],
variant: undefined,
})
const savedTask = resolveSavedVariant(ctx.model)
const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
keybindTask,
diffTask,
sessionTask,
savedTask,
])
const state: RuntimeState = {
shown: !session.first,
aborting: false,
model: ctx.model,
providers: [],
variants: [],
limits: {},
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
sessionID: ctx.sessionID,
history: [...session.history],
sessionTitle: ctx.sessionTitle,
agent: ctx.agent,
}
setRunSpanAttributes(span, {
"opencode.directory": ctx.directory,
"opencode.resume": ctx.resume === true,
"opencode.agent.name": state.agent,
"opencode.model.provider": state.model?.providerID,
"opencode.model.id": state.model?.modelID,
"opencode.model.variant": state.activeVariant,
"session.id": state.sessionID || undefined,
})
const ensureSession = () => {
if (!input.resolveSession || state.sessionID) {
return Promise.resolve()
}
if (state.session) {
return state.session
}
state.session = input.resolveSession(ctx).then((next) => {
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent
setRunSpanAttributes(span, {
"opencode.agent.name": state.agent,
"session.id": state.sessionID,
})
})
return state.session
}
const shell = await createRuntimeLifecycle({
directory: ctx.directory,
findFiles: (query) =>
ctx.sdk.find
.files({ query, directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
agents: [],
resources: [],
sessionID: state.sessionID,
sessionTitle: state.sessionTitle,
getSessionID: () => state.sessionID,
first: session.first,
history: session.history,
agent: state.agent,
model: state.model,
variant: state.activeVariant,
keybinds,
diffStyle,
onPermissionReply: async (next) => {
if (state.demo?.permission(next)) {
return
}
log?.write("send.permission.reply", next)
await ctx.sdk.permission.reply(next)
},
onQuestionReply: async (next) => {
if (state.demo?.questionReply(next)) {
return
}
await ctx.sdk.question.reply(next)
},
onQuestionReject: async (next) => {
if (state.demo?.questionReject(next)) {
return
}
await ctx.sdk.question.reject(next)
},
onCycleVariant: () => {
if (!state.model || state.variants.length === 0) {
return {
status: "no variants available",
}
}
state.activeVariant = cycleVariant(state.activeVariant, state.variants)
saveVariant(state.model, state.activeVariant)
setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant,
})
return {
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
variant: state.activeVariant,
}
},
onModelSelect: async (model) => {
if (state.model?.providerID === model.providerID && state.model.modelID === model.modelID) {
return
}
state.model = model
state.activeVariant = undefined
state.variants = variantsFor(state.providers, model)
const switching = resolveSavedVariant(model).then((saved) => {
const current = state.model
if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) {
return
}
state.activeVariant = resolveVariant(ctx.variant, undefined, saved, state.variants)
})
state.switching = switching
await switching
if (state.switching === switching) {
state.switching = undefined
}
const current = state.model
if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) {
return
}
setRunSpanAttributes(span, {
"opencode.model.provider": model.providerID,
"opencode.model.id": model.modelID,
"opencode.model.variant": state.activeVariant,
})
return {
modelLabel: formatModelLabel(model, state.activeVariant, state.providers),
status: `model ${model.modelID}`,
variant: state.activeVariant,
variants: state.variants,
}
},
onVariantSelect: async (variant) => {
if (!state.model || state.variants.length === 0) {
return {
status: "no variants available",
}
}
if (variant && !state.variants.includes(variant)) {
return {
status: `variant ${variant} unavailable`,
}
}
state.activeVariant = variant
saveVariant(state.model, state.activeVariant)
setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant,
})
return {
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
variant: state.activeVariant,
variants: state.variants,
}
},
onInterrupt: () => {
if (!hasSession(input, state) || state.aborting) {
return
}
state.aborting = true
void ctx.sdk.session
.abort({
sessionID: state.sessionID,
})
.catch(() => {})
.finally(() => {
state.aborting = false
})
},
onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID)
log?.write("subagent.select", {
sessionID,
})
},
})
const footer = shell.footer
const loadCatalog = async (): Promise<void> => {
if (footer.isClosed) {
return
}
const [agents, resources, commands] = await Promise.all([
ctx.sdk.app
.agents({ directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
ctx.sdk.experimental.resource
.list({ directory: ctx.directory })
.then((x) => Object.values(x.data ?? {}))
.catch(() => []),
ctx.sdk.command
.list({ directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
])
if (footer.isClosed) {
return
}
footer.event({
type: "catalog",
agents,
resources,
commands,
})
}
void footer
.idle()
.then(loadCatalog)
.catch(() => {})
if (Flag.OPENCODE_SHOW_TTFD) {
footer.append({
kind: "system",
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
phase: "final",
source: "system",
})
}
if (input.demo) {
await ensureSession()
state.demo = createRunDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,
})
}
if (input.afterPaint) {
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
}
void modelTask.then((info) => {
state.providers = info.providers
state.variants = variantsFor(state.providers, state.model)
state.limits = info.limits
const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
if (next !== state.activeVariant) {
state.activeVariant = next
setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant,
})
}
if (footer.isClosed) {
return
}
footer.event({ type: "models", providers: info.providers })
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
if (!state.model) {
return
}
footer.event({
type: "model",
model: formatModelLabel(state.model, state.activeVariant, state.providers),
})
})
const streamTask = import("./stream.transport")
const ensureStream = () => {
if (state.stream) {
return state.stream
}
// Share eager prewarm and first-turn boot through one in-flight promise,
// but clear it if transport creation fails so a later prompt can retry.
const next = (async () => {
await ensureSession()
if (footer.isClosed) {
throw new Error("runtime closed")
}
const mod = await streamTask
if (footer.isClosed) {
throw new Error("runtime closed")
}
const handle = await mod.createSessionTransport({
sdk: ctx.sdk,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,
footer,
trace: log,
})
if (footer.isClosed) {
await handle.close()
throw new Error("runtime closed")
}
state.selectSubagent = (sessionID) => handle.selectSubagent(sessionID)
return { mod, handle }
})()
state.stream = next
void next.catch(() => {
if (state.stream === next) {
state.stream = undefined
}
})
return next
}
const runQueue = async () => {
let includeFiles = true
if (state.demo) {
await state.demo.start()
}
const mod = await import("./runtime.queue")
const createSession = input.createSession
await mod.runPromptQueue({
footer,
initialInput: input.initialInput,
trace: log,
onSend: (prompt) => {
state.shown = true
state.history.push(prompt)
},
onNewSession: createSession
? async () => {
try {
await state.switching?.catch(() => {})
const created = await createSession(ctx, {
agent: state.agent,
model: state.model,
variant: state.activeVariant,
})
await footer.idle().catch(() => {})
await state.stream?.then((item) => item.handle.close()).catch(() => {})
state.stream = undefined
state.session = undefined
state.selectSubagent = undefined
state.shown = false
state.sessionID = created.sessionID
state.sessionTitle = created.sessionTitle
state.agent = created.agent ?? state.agent
state.history = []
includeFiles = true
state.demo = input.demo
? createRunDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,
})
: undefined
setRunSpanAttributes(span, {
"opencode.agent.name": state.agent,
"opencode.model.provider": state.model?.providerID,
"opencode.model.id": state.model?.modelID,
"opencode.model.variant": state.activeVariant,
"session.id": state.sessionID,
})
log?.write("session.new", {
sessionID: state.sessionID,
})
footer.event({
type: "stream.subagent",
state: {
tabs: [],
details: {},
permissions: [],
questions: [],
},
})
footer.event({ type: "stream.view", view: { type: "prompt" } })
footer.event({
type: "stream.patch",
patch: {
phase: "idle",
duration: "",
usage: "",
first: true,
},
})
footer.append({
kind: "system",
text: `new session ${state.sessionID}`,
phase: "final",
source: "system",
})
await state.demo?.start()
} catch (error) {
footer.event({
type: "stream.patch",
patch: {
phase: "idle",
status: "failed to start new session",
},
})
footer.append({
kind: "error",
text: error instanceof Error ? error.message : String(error),
phase: "start",
source: "system",
})
}
}
: undefined,
run: async (prompt, signal) => {
if (state.demo && (await state.demo.prompt(prompt, signal))) {
return
}
await state.switching?.catch(() => {})
return withRunSpan(
"RunInteractive.turn",
{
"opencode.agent.name": state.agent,
"opencode.model.provider": state.model?.providerID,
"opencode.model.id": state.model?.modelID,
"opencode.model.variant": state.activeVariant,
"opencode.prompt.chars": prompt.text.length,
"opencode.prompt.parts": prompt.parts.length,
"opencode.prompt.include_files": includeFiles,
"opencode.prompt.file_parts": includeFiles ? input.files.length : 0,
"session.id": state.sessionID || undefined,
},
async (span) => {
try {
const next = await ensureStream()
setRunSpanAttributes(span, {
"opencode.agent.name": state.agent,
"opencode.model.provider": state.model?.providerID,
"opencode.model.id": state.model?.modelID,
"opencode.model.variant": state.activeVariant,
"session.id": state.sessionID || undefined,
})
await next.handle.runPromptTurn({
agent: state.agent,
model: state.model,
variant: state.activeVariant,
prompt,
files: input.files,
includeFiles,
signal,
})
includeFiles = false
} catch (error) {
if (signal.aborted || footer.isClosed) {
return
}
recordRunSpanError(span, error)
const text =
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
(error instanceof Error ? error.message : String(error))
footer.append({ kind: "error", text, phase: "start", source: "system" })
}
},
)
},
})
}
try {
const eager = eagerStream(input, ctx)
if (eager) {
await ensureStream()
}
if (!eager && input.resolveSession) {
queueMicrotask(() => {
if (footer.isClosed) {
return
}
void ensureStream().catch(() => {})
})
}
try {
await runQueue()
} finally {
await state.stream?.then((item) => item.handle.close()).catch(() => {})
}
} finally {
const title = await resolveExitTitle(ctx, input, state)
await shell.close({
showExit: state.shown && hasSession(input, state),
sessionTitle: title,
sessionID: state.sessionID,
history: state.history,
})
}
},
)
}
// Local in-process mode. Creates an SDK client backed by a direct fetch to
// the in-process server, so no external HTTP server is needed.
export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
return withRunSpan(
"RunInteractive.localMode",
{
"opencode.directory": input.directory,
"opencode.initial_input": !!input.initialInput,
"opencode.demo": input.demo,
},
async () => {
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: input.fetch,
directory: input.directory,
})
let session: Promise<ResolvedSession> | undefined
return runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
demo: input.demo,
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")
}
void input.share(sdk, next.id).catch(() => {})
return {
sessionID: next.id,
sessionTitle: next.title,
agent,
}
})
return session
},
createSession: createSessionResolver(input.createSession),
boot: async () => {
return {
sdk,
directory: input.directory,
sessionID: "",
sessionTitle: undefined,
resume: false,
agent: input.agent,
model: input.model,
variant: input.variant,
}
},
})
},
)
}
// Attach mode. Uses the caller-provided SDK client directly.
export async function runInteractiveMode(input: RunInput & { createSession?: CreateSession }): Promise<void> {
return withRunSpan(
"RunInteractive.attachMode",
{
"opencode.directory": input.directory,
"opencode.initial_input": !!input.initialInput,
"session.id": input.sessionID,
},
async () =>
runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
demo: input.demo,
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),
}),
)
}
@@ -0,0 +1,92 @@
import { SyntaxStyle, TextAttributes, type ColorInput } from "@opentui/core"
import { type RunEntryTheme, type RunTheme } from "./theme"
import type { StreamCommit } from "./types"
function syntax(style?: SyntaxStyle): SyntaxStyle {
return style ?? SyntaxStyle.fromTheme([])
}
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
if (commit.kind === "reasoning") {
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
}
return syntax(theme.block.syntax)
}
export function entryFailed(commit: StreamCommit): boolean {
return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
}
export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } {
if (commit.kind === "user") {
return {
fg: theme.user.body,
//attrs: TextAttributes.BOLD,
}
}
if (entryFailed(commit)) {
return {
fg: theme.error.body,
attrs: TextAttributes.BOLD,
}
}
if (commit.phase === "final") {
return {
fg: theme.system.body,
attrs: TextAttributes.DIM,
}
}
if (commit.kind === "tool" && commit.phase === "start") {
return {
fg: theme.tool.start ?? theme.tool.body,
}
}
if (commit.kind === "assistant") {
return { fg: theme.assistant.body }
}
if (commit.kind === "reasoning") {
return {
fg: theme.reasoning.body,
attrs: TextAttributes.DIM,
}
}
if (commit.kind === "error") {
return {
fg: theme.error.body,
attrs: TextAttributes.BOLD,
}
}
if (commit.kind === "tool") {
return { fg: theme.tool.body }
}
return { fg: theme.system.body }
}
export function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput {
if (commit.kind === "assistant") {
return theme.entry.assistant.body
}
if (commit.kind === "reasoning") {
return theme.entry.reasoning.body
}
if (entryFailed(commit)) {
return theme.entry.error.body
}
if (commit.kind === "tool") {
return theme.block.text
}
return entryLook(commit, theme.entry).fg
}
@@ -0,0 +1,391 @@
// Retained streaming append logic for direct-mode scrollback.
//
// Static entries are rendered through `scrollback.writer.tsx`. This file only
// keeps the retained-surface machinery needed for streaming assistant,
// reasoning, and tool progress entries that need stable markdown/code layout
// while content is still arriving.
import {
CodeRenderable,
MarkdownRenderable,
TextRenderable,
getTreeSitterClient,
type TreeSitterClient,
type CliRenderer,
type ScrollbackSurface,
} from "@opentui/core"
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
import { withRunSpan } from "./otel"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter } from "./scrollback.writer"
import { type RunTheme } from "./theme"
import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
type ActiveBody = Exclude<RunEntryBody, { type: "none" | "structured" }>
type ActiveEntry = {
body: ActiveBody
commit: StreamCommit
surface: ScrollbackSurface
renderable: TextRenderable | CodeRenderable | MarkdownRenderable
content: string
committedRows: number
committedBlocks: number
pendingSpacerRows: number
rendered: boolean
}
let nextId = 0
function commitMarkdownBlocks(input: {
surface: ScrollbackSurface
renderable: MarkdownRenderable
startBlock: number
endBlockExclusive: number
trailingNewline: boolean
beforeCommit?: () => void
}) {
if (input.endBlockExclusive <= input.startBlock) {
return false
}
const first = input.renderable._blockStates[input.startBlock]
const last = input.renderable._blockStates[input.endBlockExclusive - 1]
if (!first || !last) {
return false
}
const next = input.renderable._blockStates[input.endBlockExclusive]
const start = first.renderable.y
const end = next ? next.renderable.y : last.renderable.y + last.renderable.height
input.beforeCommit?.()
input.surface.commitRows(start, end, {
trailingNewline: input.trailingNewline,
})
return true
}
function staticBody(commit: StreamCommit, body: RunEntryBody, spaced: number): RunEntryBody {
if (spaced === 0 || body.type !== "text") {
return body
}
if (commit.kind !== "tool" || commit.phase !== "progress" || commit.toolState !== "completed") {
return body
}
if (!body.content.startsWith("\n")) {
return body
}
return {
...body,
content: body.content.replace(/^\n/, ""),
}
}
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
constructor(
private renderer: CliRenderer,
private theme: RunTheme,
options: {
wrote?: boolean
diffStyle?: RunDiffStyle
sessionID?: () => string | undefined
treeSitterClient?: TreeSitterClient
} = {},
) {
this.diffStyle = options.diffStyle
this.sessionID = options.sessionID
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
this.wrote = options.wrote ?? false
}
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
const surface = this.renderer.createScrollbackSurface({
startOnNewLine: entryFlags(commit).startOnNewLine,
})
const id = `run-scrollback-entry-${nextId++}`
const style = entryLook(commit, this.theme.entry)
const renderable =
body.type === "text"
? new TextRenderable(surface.renderContext, {
id,
content: "",
width: "100%",
wrapMode: "word",
fg: style.fg,
attributes: style.attrs,
})
: body.type === "code"
? new CodeRenderable(surface.renderContext, {
id,
content: "",
filetype: body.filetype,
syntaxStyle: entrySyntax(commit, this.theme),
width: "100%",
wrapMode: "word",
drawUnstyledText: false,
streaming: true,
fg: entryColor(commit, this.theme),
treeSitterClient: this.treeSitterClient,
})
: new MarkdownRenderable(surface.renderContext, {
id,
content: "",
syntaxStyle: entrySyntax(commit, this.theme),
width: "100%",
streaming: true,
internalBlockMode: "top-level",
tableOptions: { widthMode: "content" },
fg: entryColor(commit, this.theme),
treeSitterClient: this.treeSitterClient,
})
surface.root.add(renderable)
const rows = separatorRows(this.rendered, commit, body)
return {
body,
commit,
surface,
renderable,
content: "",
committedRows: 0,
committedBlocks: 0,
pendingSpacerRows: rows || (!this.rendered && this.wrote ? 1 : 0),
rendered: false,
}
}
private markRendered(commit: StreamCommit | undefined): void {
if (!commit) {
return
}
this.rendered = commit
}
private writeSpacer(rows: number): void {
if (rows === 0) {
return
}
this.renderer.writeToScrollback(spacerWriter())
this.wrote = false
}
private flushPendingSpacer(active: ActiveEntry): void {
this.writeSpacer(active.pendingSpacerRows)
active.pendingSpacerRows = 0
}
private async flushActive(done: boolean, trailingNewline: boolean): Promise<boolean> {
const active = this.active
if (!active) {
return false
}
if (active.body.type === "text") {
if (!(active.renderable instanceof TextRenderable)) {
return false
}
const renderable = active.renderable
renderable.content = active.content
active.surface.render()
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
if (targetRows <= active.committedRows) {
return false
}
this.flushPendingSpacer(active)
active.surface.commitRows(active.committedRows, targetRows, {
trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
})
active.committedRows = targetRows
active.rendered = true
return true
}
if (active.body.type === "code") {
if (!(active.renderable instanceof CodeRenderable)) {
return false
}
const renderable = active.renderable
renderable.content = active.content
renderable.streaming = !done
await active.surface.settle()
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
if (targetRows <= active.committedRows) {
return false
}
this.flushPendingSpacer(active)
active.surface.commitRows(active.committedRows, targetRows, {
trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
})
active.committedRows = targetRows
active.rendered = true
return true
}
if (!(active.renderable instanceof MarkdownRenderable)) {
return false
}
const renderable = active.renderable
renderable.content = active.content
renderable.streaming = !done
await active.surface.settle()
const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
if (targetBlockCount <= active.committedBlocks) {
return false
}
if (
commitMarkdownBlocks({
surface: active.surface,
renderable,
startBlock: active.committedBlocks,
endBlockExclusive: targetBlockCount,
trailingNewline: done && targetBlockCount === renderable._blockStates.length ? trailingNewline : false,
beforeCommit: () => this.flushPendingSpacer(active),
})
) {
active.committedBlocks = targetBlockCount
active.rendered = true
return true
}
return false
}
private async finishActive(trailingNewline: boolean): Promise<StreamCommit | undefined> {
if (!this.active) {
return undefined
}
const active = this.active
try {
await this.flushActive(true, trailingNewline)
} finally {
if (this.active === active) {
this.active = undefined
}
if (!active.surface.isDestroyed) {
active.surface.destroy()
}
}
return active.rendered ? active.commit : undefined
}
private async writeStreaming(commit: StreamCommit, body: ActiveBody): Promise<void> {
if (!this.active || !sameEntryGroup(this.active.commit, commit) || this.active.body.type !== body.type) {
this.markRendered(await this.finishActive(false))
this.active = this.createEntry(commit, body)
}
this.active.body = body
this.active.commit = commit
this.active.content += body.content
await this.flushActive(false, false)
if (this.active.rendered) {
this.markRendered(this.active.commit)
}
}
public async append(commit: StreamCommit): Promise<void> {
const same = sameEntryGroup(this.tail, commit)
if (!same) {
this.markRendered(await this.finishActive(false))
}
const body = entryBody(commit)
if (body.type === "none") {
if (entryDone(commit)) {
this.markRendered(await this.finishActive(false))
}
this.tail = commit
return
}
if (
body.type !== "structured" &&
(entryCanStream(commit, body) ||
(commit.kind === "tool" && commit.phase === "final" && body.type === "markdown"))
) {
await this.writeStreaming(commit, body)
if (entryDone(commit)) {
this.markRendered(await this.finishActive(false))
}
this.tail = commit
return
}
if (same) {
this.markRendered(await this.finishActive(false))
}
const rows = separatorRows(this.rendered, commit, body)
const spaced = rows || (!this.rendered && this.wrote ? 1 : 0)
this.writeSpacer(spaced)
this.renderer.writeToScrollback(
entryWriter({
commit,
body: staticBody(commit, body, spaced),
theme: this.theme,
opts: {
diffStyle: this.diffStyle,
},
}),
)
this.markRendered(commit)
this.tail = commit
}
private resetActive(): void {
if (!this.active) {
return
}
if (!this.active.surface.isDestroyed) {
this.active.surface.destroy()
}
this.active = undefined
}
public async complete(trailingNewline = false): Promise<void> {
return withRunSpan(
"RunScrollbackStream.complete",
{
"opencode.entry.active": !!this.active,
"opencode.trailing_newline": trailingNewline,
"session.id": this.sessionID?.() || undefined,
},
async () => {
this.markRendered(await this.finishActive(trailingNewline))
},
)
}
public destroy(): void {
this.resetActive()
}
}
@@ -0,0 +1,330 @@
import { createScrollbackWriter } from "@opentui/solid"
import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core"
import { Match, Switch, createMemo } from "solid-js"
import { entryBody, entryFlags } from "./entry.body"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { toolFiletype, toolStructuredFinal } from "./tool"
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
function todoText(item: { status: string; content: string }): string {
if (item.status === "completed") {
return `[✓] ${item.content}`
}
if (item.status === "cancelled") {
return `~[ ] ${item.content}~`
}
if (item.status === "in_progress") {
return `[•] ${item.content}`
}
return `[ ] ${item.content}`
}
function todoColor(theme: RunTheme, status: string) {
return status === "in_progress" ? theme.footer.warning : theme.block.muted
}
export function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
return undefined
}
if (toolStructuredFinal(commit)) {
return `tool:${commit.partID}:final`
}
return `${commit.kind}:${commit.partID}`
}
export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean {
if (!left) {
return false
}
const current = entryGroupKey(left)
const next = entryGroupKey(right)
return Boolean(current && next && current === next)
}
export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
if (commit.kind === "tool") {
if (body.type === "structured" || body.type === "markdown") {
return "block"
}
if (commit.phase === "progress" && commit.toolState === "completed" && body.type === "text" && body.content.includes("\n")) {
return "block"
}
return "inline"
}
if (commit.kind === "reasoning") {
return "block"
}
if (commit.kind === "error") {
return "block"
}
return "block"
}
export function separatorRows(
prev: StreamCommit | undefined,
next: StreamCommit,
body: RunEntryBody = entryBody(next),
): number {
if (!prev || sameEntryGroup(prev, next)) {
return 0
}
if (entryLayout(prev) === "inline" && entryLayout(next, body) === "inline") {
return 0
}
return 1
}
export function RunEntryContent(props: {
commit: StreamCommit
body?: RunEntryBody
theme?: RunTheme
opts?: ScrollbackOptions
width?: number
}) {
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const body = createMemo(() => props.body ?? entryBody(props.commit))
const style = createMemo(() => entryLook(props.commit, theme().entry))
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
const color = createMemo(() => entryColor(props.commit, theme()))
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
const streaming = createMemo(() => props.commit.phase === "progress")
const text = createMemo(() => {
const next = body()
return next.type === "text" ? next : undefined
})
const code = createMemo(() => {
const next = body()
return next.type === "code" ? next : undefined
})
const structured = createMemo(() => {
const next = body()
return next.type === "structured" ? next.snapshot : undefined
})
const markdown = createMemo(() => {
const next = body()
return next.type === "markdown" ? next : undefined
})
const code_snapshot = createMemo(() => {
const next = structured()
return next?.kind === "code" ? next : undefined
})
const diff_snapshot = createMemo(() => {
const next = structured()
return next?.kind === "diff" ? next : undefined
})
const task_snapshot = createMemo(() => {
const next = structured()
return next?.kind === "task" ? next : undefined
})
const todo_snapshot = createMemo(() => {
const next = structured()
return next?.kind === "todo" ? next : undefined
})
const question_snapshot = createMemo(() => {
const next = structured()
return next?.kind === "question" ? next : undefined
})
return (
<Switch fallback={null}>
<Match when={text()}>
<text width="100%" wrapMode="word" fg={style().fg} attributes={style().attrs}>
{text()!.content}
</text>
</Match>
<Match when={code()}>
<code
width="100%"
wrapMode="word"
filetype={code()!.filetype}
drawUnstyledText={false}
streaming={streaming()}
syntaxStyle={syntax()}
content={code()!.content}
fg={color()}
/>
</Match>
<Match when={code_snapshot()}>
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{code_snapshot()!.title}
</text>
<box width="100%" paddingLeft={1}>
<line_number width="100%" fg={theme().block.muted} minWidth={3} paddingRight={1}>
<code
width="100%"
wrapMode="char"
filetype={toolFiletype(code_snapshot()!.file)}
streaming={false}
syntaxStyle={syntax()}
content={code_snapshot()!.content}
fg={theme().block.text}
/>
</line_number>
</box>
</box>
</Match>
<Match when={diff_snapshot()}>
<box width="100%" flexDirection="column" gap={1}>
{diff_snapshot()!.items.map((item) => (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{item.title}
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<diff
diff={item.diff}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme().block.text}
addedBg={diffBg(theme().block.diffAddedBg)}
removedBg={diffBg(theme().block.diffRemovedBg)}
contextBg={diffBg(theme().block.diffContextBg)}
addedSignColor={theme().block.diffHighlightAdded}
removedSignColor={theme().block.diffHighlightRemoved}
lineNumberFg={theme().block.diffLineNumber}
lineNumberBg={diffBg(theme().block.diffContextBg)}
addedLineNumberBg={diffBg(theme().block.diffAddedLineNumberBg)}
removedLineNumberBg={diffBg(theme().block.diffRemovedLineNumberBg)}
/>
</box>
) : (
<text width="100%" wrapMode="word" fg={theme().block.diffRemoved}>
-{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
</text>
)}
</box>
))}
</box>
</Match>
<Match when={task_snapshot()}>
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{task_snapshot()!.title}
</text>
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
{task_snapshot()!.rows.map((row) => (
<text width="100%" wrapMode="word" fg={theme().block.text}>
{row}
</text>
))}
{task_snapshot()!.tail ? (
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{task_snapshot()!.tail}
</text>
) : null}
</box>
</box>
</Match>
<Match when={todo_snapshot()}>
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
# Todos
</text>
<box width="100%" flexDirection="column" gap={0}>
{todo_snapshot()!.items.map((item) => (
<text width="100%" wrapMode="word" fg={todoColor(theme(), item.status)}>
{todoText(item)}
</text>
))}
{todo_snapshot()!.tail ? (
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{todo_snapshot()!.tail}
</text>
) : null}
</box>
</box>
</Match>
<Match when={question_snapshot()}>
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
# Questions
</text>
<box width="100%" flexDirection="column" gap={1}>
{question_snapshot()!.items.map((item) => (
<box width="100%" flexDirection="column" gap={0}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{item.question}
</text>
<text width="100%" wrapMode="word" fg={theme().block.text}>
{item.answer}
</text>
</box>
))}
{question_snapshot()!.tail ? (
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{question_snapshot()!.tail}
</text>
) : null}
</box>
</box>
</Match>
<Match when={markdown()}>
<markdown
width="100%"
syntaxStyle={syntax()}
streaming={streaming()}
content={markdown()!.content}
fg={color()}
tableOptions={{ widthMode: "content" }}
/>
</Match>
</Switch>
)
}
export function entryWriter(input: {
commit: StreamCommit
body?: RunEntryBody
theme?: RunTheme
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),
)
}
export function spacerWriter(): ScrollbackWriter {
return (ctx: ScrollbackRenderContext) => ({
root: new TextRenderable(ctx.renderContext, {
id: "run-scrollback-spacer",
width: Math.max(1, Math.trunc(ctx.width)),
height: 1,
content: "",
}),
width: Math.max(1, Math.trunc(ctx.width)),
height: 1,
startOnNewLine: true,
trailingNewline: true,
})
}
@@ -0,0 +1,970 @@
// Core reducer for direct interactive mode.
//
// Takes raw SDK events and produces two outputs:
// - StreamCommit[]: append-only scrollback entries (text, tool, error, etc.)
// - FooterOutput: status bar patches and view transitions (permission, question)
//
// The reducer mutates SessionData in place for performance but has no
// external side effects -- no IO, no footer calls. The caller
// (stream.transport.ts) feeds events in and forwards output to the footer
// through stream.ts.
//
// Key design decisions:
//
// - Text parts buffer in `data.text` until their message role is confirmed as
// "assistant". This prevents echoing user-role text parts. The `ready()`
// check gates output: if we see a text delta before the message.updated
// event that tells us the role, we stash it and flush later via `replay()`.
//
// - Tool echo stripping: bash tools may echo their own output in the next
// assistant text part. `stashEcho()` records completed bash output, and
// `stripEcho()` removes it from the start of the next assistant chunk.
//
// - Permission and question requests queue in `data.permissions` and
// `data.questions`. The footer shows whichever is first. When a reply
// event arrives, the queue entry is removed and the footer falls back
// to the next pending request or to the prompt view.
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "@/util/locale"
import { toolView } from "./tool"
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
type Tokens = {
input?: number
output?: number
reasoning?: number
cache?: {
read?: number
write?: number
}
}
type PartKind = "assistant" | "reasoning" | "user"
type MessageRole = "assistant" | "user"
type Dict = Record<string, unknown>
type SessionCommit = StreamCommit
// Mutable accumulator for the reducer. Each field tracks a different aspect
// of the stream so we can produce correct incremental output:
//
// - ids: parts and error keys we've already committed (dedup guard)
// - tools: tool parts we've emitted a "start" for but not yet completed
// - call: tool call inputs, keyed by msg:call, for enriching permission views
// - role: message ID → "assistant" | "user", learned from message.updated
// - msg: part ID → message ID
// - part: part ID → "assistant" | "reasoning" (text parts only)
// - text: part ID → full accumulated text so far
// - sent: part ID → byte offset of last flushed text (for incremental output)
// - end: part IDs whose time.end has arrived (part is finished)
// - echo: message ID → bash outputs to strip from the next assistant chunk
export type SessionData = {
includeUserText: boolean
announced: boolean
ids: Set<string>
tools: Set<string>
call: Map<string, Dict>
permissions: PermissionRequest[]
questions: QuestionRequest[]
role: Map<string, MessageRole>
msg: Map<string, string>
part: Map<string, PartKind>
text: Map<string, string>
sent: Map<string, number>
end: Set<string>
echo: Map<string, Set<string>>
}
export type SessionDataInput = {
data: SessionData
event: Event
sessionID: string
thinking: boolean
limits: Record<string, number>
}
export type SessionDataOutput = {
data: SessionData
commits: SessionCommit[]
footer?: FooterOutput
}
export function createSessionData(
input: {
includeUserText?: boolean
} = {},
): SessionData {
return {
includeUserText: input.includeUserText ?? false,
announced: false,
ids: new Set(),
tools: new Set(),
call: new Map(),
permissions: [],
questions: [],
role: new Map(),
msg: new Map(),
part: new Map(),
text: new Map(),
sent: new Map(),
end: new Set(),
echo: new Map(),
}
}
function modelKey(provider: string, model: string): string {
return `${provider}/${model}`
}
function formatUsage(
tokens: Tokens | undefined,
limit: number | undefined,
cost: number | undefined,
): string | undefined {
const total =
(tokens?.input ?? 0) +
(tokens?.output ?? 0) +
(tokens?.reasoning ?? 0) +
(tokens?.cache?.read ?? 0) +
(tokens?.cache?.write ?? 0)
if (total <= 0) {
if (typeof cost === "number" && cost > 0) {
return money.format(cost)
}
return undefined
}
const text =
limit && limit > 0 ? `${Locale.number(total)} (${Math.round((total / limit) * 100)}%)` : Locale.number(total)
if (typeof cost === "number" && cost > 0) {
return `${text} · ${money.format(cost)}`
}
return text
}
export function formatError(error: {
name?: string
message?: string
data?: {
message?: string
}
}): string {
if (error.data?.message) {
return error.data.message
}
if (error.message) {
return error.message
}
if (error.name) {
return error.name
}
return "unknown error"
}
function isAbort(error: { name?: string } | undefined): boolean {
return error?.name === "MessageAbortedError"
}
function msgErr(id: string): string {
return `msg:${id}:error`
}
function patch(patch?: FooterPatch, view?: FooterView): FooterOutput | undefined {
if (!patch && !view) {
return undefined
}
return {
patch,
view,
}
}
function out(data: SessionData, commits: SessionCommit[], footer?: FooterOutput): SessionDataOutput {
if (!footer) {
return {
data,
commits,
}
}
return {
data,
commits,
footer,
}
}
export function pickBlockerView(input: {
permission?: PermissionRequest
question?: QuestionRequest
}): FooterView {
if (input.permission) {
return { type: "permission", request: input.permission }
}
if (input.question) {
return { type: "question", request: input.question }
}
return { type: "prompt" }
}
export function blockerStatus(view: FooterView) {
if (view.type === "permission") {
return "awaiting permission"
}
if (view.type === "question") {
return "awaiting answer"
}
return ""
}
function pickSessionView(data: SessionData): FooterView {
return pickBlockerView({
permission: data.permissions[0],
question: data.questions[0],
})
}
function queueFooter(data: SessionData): FooterOutput {
const view = pickSessionView(data)
return {
view,
patch: { status: blockerStatus(view) },
}
}
function queueOut(data: SessionData, commits: SessionCommit[]): SessionDataOutput {
return out(data, commits, queueFooter(data))
}
function upsert<T extends { id: string }>(list: T[], item: T) {
const idx = list.findIndex((entry) => entry.id === item.id)
if (idx === -1) {
list.push(item)
return
}
list[idx] = item
}
function remove(list: Array<{ id: string }>, id: string): boolean {
const idx = list.findIndex((entry) => entry.id === id)
if (idx === -1) {
return false
}
list.splice(idx, 1)
return true
}
export function bootstrapSessionData(input: {
data: SessionData
messages: Array<{
parts: Part[]
}>
permissions: PermissionRequest[]
questions: QuestionRequest[]
}) {
for (const message of input.messages) {
for (const part of message.parts) {
if (part.type !== "tool") {
continue
}
input.data.call.set(key(part.messageID, part.callID), part.state.input)
}
}
for (const request of input.permissions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
upsert(input.data.permissions, enrichPermission(input.data, request))
}
for (const request of input.questions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
upsert(input.data.questions, request)
}
}
function key(msg: string, call: string): string {
return `${msg}:${call}`
}
function enrichPermission(data: SessionData, request: PermissionRequest): PermissionRequest {
if (!request.tool) {
return request
}
const input = data.call.get(key(request.tool.messageID, request.tool.callID))
if (!input) {
return request
}
const meta = request.metadata ?? {}
if (meta.input === input) {
return request
}
return {
...request,
metadata: {
...meta,
input,
},
}
}
// Updates the active permission request when the matching tool part gets
// new input (e.g., a diff). This keeps the permission UI in sync with the
// tool's evolving state. Only triggers a footer update if the currently
// displayed permission was the one that changed.
function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undefined {
data.call.set(key(part.messageID, part.callID), part.state.input)
if (data.permissions.length === 0) {
return undefined
}
let changed = false
let active = false
data.permissions = data.permissions.map((request, index) => {
if (!request.tool || request.tool.messageID !== part.messageID || request.tool.callID !== part.callID) {
return request
}
const next = enrichPermission(data, request)
if (next === request) {
return request
}
changed = true
active ||= index === 0
return next
})
if (!changed || !active) {
return undefined
}
return {
view: pickSessionView(data),
}
}
// Question tool replies can complete without a matching question.replied event.
// When that happens, drop the recovered pending request tied to this tool call so
// the footer can return to the next blocker or to the prompt.
function syncQuestion(data: SessionData, part: ToolPart): FooterOutput | undefined {
if (part.tool !== "question") {
return undefined
}
if (part.state.status !== "completed" && part.state.status !== "error") {
return undefined
}
const next = data.questions.filter(
(request) => request.tool?.messageID !== part.messageID || request.tool?.callID !== part.callID,
)
if (next.length === data.questions.length) {
return undefined
}
data.questions = next
return queueFooter(data)
}
function toolStatus(part: ToolPart): string {
if (part.tool !== "task") {
return `running ${part.tool}`
}
const state = part.state as {
input?: {
description?: unknown
subagent_type?: unknown
}
}
const desc = state.input?.description
if (typeof desc === "string" && desc.trim()) {
return `running ${desc.trim()}`
}
const type = state.input?.subagent_type
if (typeof type === "string" && type.trim()) {
return `running ${type.trim()}`
}
return "running task"
}
// Returns true if we can flush this part's text to scrollback.
//
// We gate on the message role being "assistant" because user-role messages
// also contain text parts (the user's own input) which we don't want to
// echo. If we haven't received the message.updated event yet, we return
// false and the text stays buffered until replay() flushes it.
function ready(data: SessionData, partID: string): boolean {
const msg = data.msg.get(partID)
if (!msg) {
return true
}
const role = data.role.get(msg)
if (!role) {
return false
}
if (role === "assistant") {
return true
}
return data.includeUserText && role === "user"
}
function syncText(data: SessionData, partID: string, next: string) {
const prev = data.text.get(partID) ?? ""
if (!next) {
return prev
}
if (!prev || next.length >= prev.length) {
data.text.set(partID, next)
return next
}
return prev
}
// Records bash tool output for echo stripping. Some models echo bash output
// verbatim at the start of their next text part. We save both the raw and
// trimmed forms so stripEcho() can match either.
function stashEcho(data: SessionData, part: ToolPart) {
if (part.tool !== "bash") {
return
}
if (typeof part.messageID !== "string" || !part.messageID) {
return
}
const output = "output" in part.state ? part.state.output : undefined
if (typeof output !== "string") {
return
}
const text = output.replace(/^\n+/, "")
if (!text.trim()) {
return
}
const set = data.echo.get(part.messageID) ?? new Set<string>()
set.add(text)
const trim = text.replace(/\n+$/, "")
if (trim && trim !== text) {
set.add(trim)
}
data.echo.set(part.messageID, set)
}
function stripEcho(data: SessionData, msg: string | undefined, chunk: string): string {
if (!msg) {
return chunk
}
const set = data.echo.get(msg)
if (!set || set.size === 0) {
return chunk
}
data.echo.delete(msg)
const list = [...set].sort((a, b) => b.length - a.length)
for (const item of list) {
if (!item || !chunk.startsWith(item)) {
continue
}
return chunk.slice(item.length).replace(/^\n+/, "")
}
return chunk
}
function flushPart(data: SessionData, commits: SessionCommit[], partID: string, interrupted = false) {
const kind = data.part.get(partID)
if (!kind) {
return
}
const text = data.text.get(partID) ?? ""
const sent = data.sent.get(partID) ?? 0
let chunk = text.slice(sent)
const msg = data.msg.get(partID)
if (sent === 0) {
chunk = chunk.replace(/^\n+/, "")
// Some models emit a standalone whitespace token before real content.
// Keep buffering until we have visible text so scrollback doesn't get a blank row.
if (!chunk.trim()) {
return
}
if (kind === "reasoning" && chunk) {
chunk = `Thinking: ${chunk.replace(/\[REDACTED\]/g, "")}`
}
if (kind === "assistant" && chunk) {
chunk = stripEcho(data, msg, chunk)
if (!chunk.trim()) {
return
}
}
}
if (chunk) {
data.sent.set(partID, text.length)
commits.push({
kind,
text: chunk,
phase: "progress",
source: kind === "user" ? "system" : kind,
messageID: msg,
partID,
})
}
if (!interrupted) {
return
}
commits.push({
kind,
text: "",
phase: "final",
source: kind === "user" ? "system" : kind,
messageID: msg,
partID,
interrupted: true,
})
}
function drop(data: SessionData, partID: string) {
data.part.delete(partID)
data.text.delete(partID)
data.sent.delete(partID)
data.msg.delete(partID)
data.end.delete(partID)
}
// Called when we learn a message's role (from message.updated). Flushes any
// buffered text parts that were waiting on role confirmation. User-role
// parts are silently dropped.
function replay(data: SessionData, commits: SessionCommit[], messageID: string, role: MessageRole, thinking: boolean) {
for (const [partID, msg] of data.msg.entries()) {
if (msg !== messageID || data.ids.has(partID)) {
continue
}
if (role === "user" && !data.includeUserText) {
data.ids.add(partID)
drop(data, partID)
continue
}
const kind = data.part.get(partID)
if (!kind) {
continue
}
if (role === "user" && kind === "assistant") {
data.part.set(partID, "user")
}
if (kind === "reasoning" && !thinking) {
if (data.end.has(partID)) {
data.ids.add(partID)
}
drop(data, partID)
continue
}
flushPart(data, commits, partID)
if (!data.end.has(partID)) {
continue
}
data.ids.add(partID)
drop(data, partID)
}
}
function toolCommit(
part: ToolPart,
next: Pick<SessionCommit, "text" | "phase" | "toolState"> & { toolError?: string },
): SessionCommit {
return {
kind: "tool",
source: "tool",
messageID: part.messageID,
partID: part.id,
tool: part.tool,
part,
...next,
}
}
function startTool(part: ToolPart): SessionCommit {
return toolCommit(part, {
text: toolStatus(part),
phase: "start",
toolState: "running",
})
}
function doneTool(part: ToolPart): SessionCommit {
return toolCommit(part, {
text: "",
phase: "final",
toolState: "completed",
})
}
function failTool(part: ToolPart, text: string): SessionCommit {
return toolCommit(part, {
text,
phase: "final",
toolState: "error",
toolError: text,
})
}
// Emits "interrupted" final entries for all in-flight parts. Called when a turn is aborted.
export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
for (const partID of data.part.keys()) {
if (data.ids.has(partID)) {
continue
}
const msg = data.msg.get(partID)
if (msg && data.role.get(msg) === "user" && !data.includeUserText) {
data.ids.add(partID)
drop(data, partID)
continue
}
flushPart(data, commits, partID, true)
data.ids.add(partID)
drop(data, partID)
}
}
// The main reducer. Takes one SDK event and returns scrollback commits and
// footer updates. Called once per event from the stream transport's watch loop.
//
// Event handling follows the SDK event types:
// message.updated → learn role, flush buffered parts, track usage
// message.part.delta → accumulate text, flush if ready
// message.part.updated → handle text/reasoning/tool state transitions
// permission.* → manage the permission queue, drive footer view
// question.* → manage the question queue, drive footer view
// session.error → emit error scrollback entry
export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
const commits: SessionCommit[] = []
const data = input.data
const event = input.event
if (event.type === "message.updated") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
const info = event.properties.info
if (typeof info.id === "string") {
data.role.set(info.id, info.role)
replay(data, commits, info.id, info.role, input.thinking)
}
if (info.role !== "assistant") {
return out(data, commits)
}
let next: FooterPatch | undefined
if (!data.announced) {
data.announced = true
next = { status: "assistant responding" }
}
const usage = formatUsage(
info.tokens,
input.limits[modelKey(info.providerID, info.modelID)],
typeof info.cost === "number" ? info.cost : undefined,
)
if (usage) {
next = {
...next,
usage,
}
}
if (typeof info.id === "string" && info.error && !isAbort(info.error) && !data.ids.has(msgErr(info.id))) {
data.ids.add(msgErr(info.id))
commits.push({
kind: "error",
text: formatError(info.error),
phase: "start",
source: "system",
messageID: info.id,
})
}
return out(data, commits, patch(next))
}
if (event.type === "message.part.delta") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
if (
typeof event.properties.partID !== "string" ||
typeof event.properties.field !== "string" ||
typeof event.properties.delta !== "string"
) {
return out(data, commits)
}
if (event.properties.field !== "text") {
return out(data, commits)
}
const partID = event.properties.partID
if (data.ids.has(partID)) {
return out(data, commits)
}
if (typeof event.properties.messageID === "string") {
data.msg.set(partID, event.properties.messageID)
}
const text = data.text.get(partID) ?? ""
data.text.set(partID, text + event.properties.delta)
const kind = data.part.get(partID)
if (!kind) {
return out(data, commits)
}
if (kind === "reasoning" && !input.thinking) {
return out(data, commits)
}
if (!ready(data, partID)) {
return out(data, commits)
}
flushPart(data, commits, partID)
return out(data, commits)
}
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID !== input.sessionID) {
return out(data, commits)
}
if (part.type === "tool") {
const view = syncPermission(data, part) ?? syncQuestion(data, part)
if (part.state.status === "running") {
if (data.ids.has(part.id)) {
return out(data, commits, view)
}
if (!data.tools.has(part.id)) {
data.tools.add(part.id)
commits.push(startTool(part))
}
return out(data, commits, view ?? patch({ status: toolStatus(part) }))
}
if (part.state.status === "completed") {
const seen = data.tools.has(part.id)
const mode = toolView(part.tool)
data.tools.delete(part.id)
if (data.ids.has(part.id)) {
return out(data, commits, view)
}
if (!seen) {
commits.push(startTool(part))
}
data.ids.add(part.id)
stashEcho(data, part)
const output = part.state.output
if (mode.output && typeof output === "string" && output.trim()) {
commits.push({
kind: "tool",
text: output,
phase: "progress",
source: "tool",
messageID: part.messageID,
partID: part.id,
tool: part.tool,
part,
toolState: "completed",
})
}
if (mode.final) {
commits.push(doneTool(part))
}
return out(data, commits, view)
}
if (part.state.status === "error") {
const seen = data.tools.has(part.id)
data.tools.delete(part.id)
if (data.ids.has(part.id)) {
return out(data, commits, view)
}
if (!seen) {
commits.push(startTool(part))
}
data.ids.add(part.id)
const text =
typeof part.state.error === "string" && part.state.error.trim() ? part.state.error : "unknown error"
commits.push(failTool(part, text))
return out(data, commits, view)
}
}
if (part.type !== "text" && part.type !== "reasoning") {
return out(data, commits)
}
if (data.ids.has(part.id)) {
return out(data, commits)
}
const kind = part.type === "text" ? "assistant" : "reasoning"
if (typeof part.messageID === "string") {
data.msg.set(part.id, part.messageID)
}
const msg = part.messageID
const role = msg ? data.role.get(msg) : undefined
if (role === "user" && part.type === "text" && !data.includeUserText) {
data.ids.add(part.id)
drop(data, part.id)
return out(data, commits)
}
if (kind === "reasoning" && !input.thinking) {
if (part.time?.end) {
data.ids.add(part.id)
}
drop(data, part.id)
return out(data, commits)
}
data.part.set(part.id, role === "user" && kind === "assistant" ? "user" : kind)
syncText(data, part.id, part.text)
if (part.time?.end) {
data.end.add(part.id)
}
if (msg && !role) {
return out(data, commits)
}
if (!ready(data, part.id)) {
return out(data, commits)
}
flushPart(data, commits, part.id)
if (!part.time?.end) {
return out(data, commits)
}
data.ids.add(part.id)
drop(data, part.id)
return out(data, commits)
}
if (event.type === "permission.asked") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
upsert(data.permissions, enrichPermission(data, event.properties))
return queueOut(data, commits)
}
if (event.type === "permission.replied") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
if (!remove(data.permissions, event.properties.requestID)) {
return out(data, commits)
}
return queueOut(data, commits)
}
if (event.type === "question.asked") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
upsert(data.questions, event.properties)
return queueOut(data, commits)
}
if (event.type === "question.replied" || event.type === "question.rejected") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
if (!remove(data.questions, event.properties.requestID)) {
return out(data, commits)
}
return queueOut(data, commits)
}
if (event.type === "session.error") {
if (event.properties.sessionID !== input.sessionID || !event.properties.error) {
return out(data, commits)
}
commits.push({
kind: "error",
text: formatError(event.properties.error),
phase: "start",
source: "system",
})
return out(data, commits)
}
return out(data, commits)
}
@@ -0,0 +1,196 @@
// Session message extraction and prompt history.
//
// Fetches session messages from the SDK and extracts user turn text for
// the prompt history ring. Also finds the most recently used variant for
// the current model so the footer can pre-select it.
import { promptCopy, promptSame } from "./prompt.shared"
import type { RunInput, RunPrompt } from "./types"
const LIMIT = 200
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
type Turn = {
prompt: RunPrompt
provider: string | undefined
model: string | undefined
variant: string | undefined
}
export type RunSession = {
first: boolean
turns: Turn[]
}
function fileName(url: string, filename?: string) {
if (filename) {
return filename
}
try {
const next = new URL(url)
if (next.protocol !== "file:") {
return url
}
const name = next.pathname.split("/").at(-1)
if (name) {
return decodeURIComponent(name)
}
} catch {}
return url
}
function fileSource(
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
text: { start: number; end: number; value: string },
) {
if (part.source) {
return {
...structuredClone(part.source),
text,
}
}
return {
type: "file" as const,
path: part.filename ?? part.url,
text,
}
}
function prompt(msg: SessionMessages[number]): RunPrompt {
const parts: RunPrompt["parts"] = []
let text = msg.parts
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
return part.type === "text" && !part.synthetic
})
.map((part) => part.text)
.join("")
let cursor = Bun.stringWidth(text)
const used: Array<{ start: number; end: number }> = []
const take = (value: string): { start: number; end: number; value: string } | undefined => {
let from = 0
while (true) {
const idx = text.indexOf(value, from)
if (idx === -1) {
return undefined
}
const start = Bun.stringWidth(text.slice(0, idx))
const end = start + Bun.stringWidth(value)
if (!used.some((item) => item.start < end && start < item.end)) {
return { start, end, value }
}
from = idx + value.length
}
}
const add = (value: string) => {
const gap = text ? " " : ""
const start = cursor + Bun.stringWidth(gap)
text += gap + value
const end = start + Bun.stringWidth(value)
cursor = end
return { start, end, value }
}
for (const part of msg.parts) {
if (part.type === "file") {
const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename))
const span = next ?? add("@" + fileName(part.url, part.filename))
used.push({ start: span.start, end: span.end })
parts.push({
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: fileSource(part, span),
})
continue
}
if (part.type !== "agent") {
continue
}
const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name))
used.push({ start: span.start, end: span.end })
parts.push({
type: "agent",
name: part.name,
source: span,
})
}
return { text, parts }
}
function turn(msg: SessionMessages[number]): Turn | undefined {
if (msg.info.role !== "user") {
return undefined
}
return {
prompt: prompt(msg),
provider: msg.info.model.providerID,
model: msg.info.model.modelID,
variant: msg.info.model.variant,
}
}
export function createSession(messages: SessionMessages): RunSession {
return {
first: messages.length === 0,
turns: messages.flatMap((msg) => {
const item = turn(msg)
return item ? [item] : []
}),
}
}
export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise<RunSession> {
const response = await sdk.session.messages({
sessionID,
limit,
})
return createSession(response.data ?? [])
}
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
const out: RunPrompt[] = []
for (const turn of session.turns) {
if (!turn.prompt.text.trim()) {
continue
}
if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
continue
}
out.push(promptCopy(turn.prompt))
}
return out.slice(-limit)
}
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
if (!model) {
return undefined
}
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
const turn = session.turns[idx]
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
continue
}
return turn.variant
}
return undefined
}
+302
View File
@@ -0,0 +1,302 @@
// Entry and exit splash banners for direct interactive mode scrollback.
//
// Renders the full opencode entry logo and a compact [O] exit badge, plus
// session metadata and the resume command. These are scrollback snapshots, so
// they become immutable terminal history once committed.
//
// Both variants use a cell-based renderer. cells() classifies each character
// in the source template as text, full-block, half-block-mix, or
// half-block-top, and draw() renders it with foreground/background shadow
// colors from the theme.
import {
BoxRenderable,
type ColorInput,
RGBA,
TextAttributes,
TextRenderable,
type ScrollbackRenderContext,
type ScrollbackSnapshot,
type ScrollbackWriter,
} from "@opentui/core"
import * as Locale from "@/util/locale"
import { go, logo } from "@/cli/logo"
import type { RunSplashTheme } from "./theme"
export const SPLASH_TITLE_LIMIT = 50
export const SPLASH_TITLE_FALLBACK = "Untitled session"
type SplashInput = {
title: string | undefined
session_id: string
}
type SplashWriterInput = SplashInput & {
theme: RunSplashTheme
showSession?: boolean
}
export type SplashMeta = {
title: string
session_id: string
}
type Cell = {
char: string
mark: "text" | "full" | "mix" | "top"
}
let id = 0
function cells(line: string): Cell[] {
const list: Cell[] = []
for (const char of line) {
if (char === "_") {
list.push({ char: " ", mark: "full" })
continue
}
if (char === "^") {
list.push({ char: "▀", mark: "mix" })
continue
}
if (char === "~") {
list.push({ char: "▀", mark: "top" })
continue
}
list.push({ char, mark: "text" })
}
return list
}
function title(text: string | undefined): string {
if (!text) {
return SPLASH_TITLE_FALLBACK
}
let value = ""
let gap = false
for (const char of text.trim()) {
if (char === " " || char === "\n" || char === "\r" || char === "\t") {
gap = true
continue
}
if (gap && value.length > 0) {
value += " "
}
value += char
gap = false
}
if (!value) {
return SPLASH_TITLE_FALLBACK
}
return Locale.truncate(value, SPLASH_TITLE_LIMIT)
}
function write(
root: BoxRenderable,
ctx: ScrollbackRenderContext,
line: {
left: number
top: number
text: string
fg: ColorInput
bg?: ColorInput
attrs?: number
},
): void {
if (line.left >= ctx.width) {
return
}
root.add(
new TextRenderable(ctx.renderContext, {
id: `run-direct-splash-line-${id++}`,
position: "absolute",
left: line.left,
top: line.top,
width: Math.max(1, ctx.width - line.left),
height: 1,
wrapMode: "none",
content: line.text,
fg: line.fg,
bg: line.bg,
attributes: line.attrs,
}),
)
}
function push(
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
left: number,
top: number,
text: string,
fg: ColorInput,
bg?: ColorInput,
attrs?: number,
): void {
lines.push({ left, top, text, fg, bg, attrs })
}
function color(input: ColorInput, fallback: RGBA): RGBA {
if (input instanceof RGBA) {
return input
}
if (typeof input === "string") {
if (input === "transparent" || input === "none") {
return RGBA.fromValues(0, 0, 0, 0)
}
if (input.startsWith("#")) {
return RGBA.fromHex(input)
}
}
return fallback
}
function fallback(index: number, hex: string): RGBA {
return RGBA.fromIndex(index, RGBA.fromHex(hex))
}
function draw(
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
row: string,
input: {
left: number
top: number
fg: ColorInput
shadow: ColorInput
attrs?: number
},
) {
let x = input.left
for (const cell of cells(row)) {
if (cell.mark === "full" || cell.mark === "mix") {
push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
x += 1
continue
}
if (cell.mark === "top") {
push(lines, x, input.top, cell.char, input.shadow, undefined, input.attrs)
x += 1
continue
}
push(lines, x, input.top, cell.char, input.fg, undefined, input.attrs)
x += 1
}
}
function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: ScrollbackRenderContext): ScrollbackSnapshot {
const width = Math.max(1, ctx.width)
const meta = splashMeta(input)
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
const left = color(input.theme.left, fallback(81, "#38bdf8"))
const right = color(input.theme.right, RGBA.defaultForeground(RGBA.fromHex("#f8fafc")))
const leftShadow = color(input.theme.leftShadow, fallback(238, "#334155"))
let height = 1
if (kind === "entry") {
const rightShadow = color(input.theme.rightShadow, fallback(240, "#475569"))
for (let i = 0; i < logo.left.length; i += 1) {
const leftText = logo.left[i] ?? ""
const rightText = logo.right[i] ?? ""
draw(lines, leftText, {
left: 0,
top: i,
fg: left,
shadow: leftShadow,
})
draw(lines, rightText, {
left: leftText.length + 1,
top: i,
fg: right,
shadow: rightShadow,
})
}
height = logo.left.length
if (input.showSession !== false) {
const top = logo.left.length + 1
const label = "Session".padEnd(10, " ")
push(lines, 0, top, label, left, undefined, TextAttributes.DIM)
push(lines, label.length, top, meta.title, right, undefined, TextAttributes.BOLD)
height = top + 1
}
}
if (kind === "exit") {
const mark = go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
const session = "Session "
const label = "Continue "
for (let i = 0; i < mark.length; i += 1) {
draw(lines, mark[i] ?? "", {
left: 0,
top: top + i,
fg: left,
shadow: leftShadow,
})
}
if (input.showSession !== false) {
push(lines, body_left, top, session, left, undefined, TextAttributes.DIM)
push(lines, body_left + session.length, top, meta.title, right, undefined, TextAttributes.BOLD)
}
push(lines, body_left, top + 1, label, left, undefined, TextAttributes.DIM)
push(lines, body_left + label.length, top + 1, `opencode run -i -s ${meta.session_id}`, right, undefined, TextAttributes.BOLD)
height = top + mark.length
}
const root = new BoxRenderable(ctx.renderContext, {
id: `run-direct-splash-${kind}-${id++}`,
position: "absolute",
left: 0,
top: 0,
width,
height,
})
for (const line of lines) {
write(root, ctx, line)
}
return {
root,
width,
height,
rowColumns: width,
startOnNewLine: true,
trailingNewline: false,
}
}
export function splashMeta(input: SplashInput): SplashMeta {
return {
title: title(input.title),
session_id: input.session_id,
}
}
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
return (ctx) => build(input, "entry", ctx)
}
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
return (ctx) => build(input, "exit", ctx)
}
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
// Thin bridge between reducer output and the footer API.
//
// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
// view + subagent state). This module forwards them to footer.append() and
// footer.event() respectively, adding trace writes along the way. It also
// defaults status updates to phase "running" if the caller didn't set a
// phase -- a convenience so reducer code doesn't have to repeat that.
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
type Trace = {
write(type: string, data?: unknown): void
}
type OutputInput = {
footer: FooterApi
trace?: Trace
}
type StreamOutput = {
commits: StreamCommit[]
footer?: FooterOutput
}
// Default to "running" phase when a status string arrives without an explicit phase.
function patch(next: FooterPatch): FooterPatch {
if (typeof next.status === "string" && next.phase === undefined) {
return {
phase: "running",
...next,
}
}
return next
}
function summarize(value: unknown): unknown {
if (typeof value === "string") {
if (value.length <= 160) {
return value
}
return {
type: "string",
length: value.length,
preview: `${value.slice(0, 160)}...`,
}
}
if (Array.isArray(value)) {
return {
type: "array",
length: value.length,
}
}
if (!value || typeof value !== "object") {
return value
}
return {
type: "object",
keys: Object.keys(value),
}
}
function traceCommit(commit: StreamCommit) {
return {
...commit,
text: summarize(commit.text),
textLength: commit.text.length,
part: commit.part
? {
id: commit.part.id,
sessionID: commit.part.sessionID,
messageID: commit.part.messageID,
callID: commit.part.callID,
tool: commit.part.tool,
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,
},
}
: undefined,
}
}
export function traceSubagentState(state: FooterSubagentState) {
return {
tabs: state.tabs,
details: Object.fromEntries(
Object.entries(state.details).map(([sessionID, detail]) => [
sessionID,
{
sessionID,
commits: detail.commits.map(traceCommit),
},
]),
),
permissions: state.permissions.map((item) => ({
id: item.id,
sessionID: item.sessionID,
permission: item.permission,
patterns: item.patterns,
tool: item.tool,
metadata: item.metadata
? {
keys: Object.keys(item.metadata),
input: summarize(item.metadata.input),
}
: undefined,
})),
questions: state.questions.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,
})),
})),
}
}
export function traceFooterOutput(footer?: FooterOutput) {
if (!footer?.subagent) {
return footer
}
return {
...footer,
subagent: traceSubagentState(footer.subagent),
}
}
// Forwards reducer 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) {
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
if (out.footer?.patch) {
const next = patch(out.footer.patch)
input.trace?.write("ui.patch", next)
input.footer.event({
type: "stream.patch",
patch: next,
})
}
if (out.footer?.subagent) {
input.trace?.write("ui.subagent", traceSubagentState(out.footer.subagent))
input.footer.event({
type: "stream.subagent",
state: out.footer.subagent,
})
}
if (!out.footer?.view) {
return
}
input.trace?.write("ui.patch", {
view: out.footer.view,
})
input.footer.event({
type: "stream.view",
view: out.footer.view,
})
}
@@ -0,0 +1,746 @@
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "@/util/locale"
import {
bootstrapSessionData,
createSessionData,
formatError,
reduceSessionData,
type SessionData,
} from "./session-data"
import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
export const SUBAGENT_BOOTSTRAP_LIMIT = 200
export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80
const SUBAGENT_COMMIT_LIMIT = 80
const SUBAGENT_CALL_LIMIT = 32
const SUBAGENT_ROLE_LIMIT = 32
const SUBAGENT_ERROR_LIMIT = 16
const SUBAGENT_ECHO_LIMIT = 8
type SessionMessage = {
parts: Part[]
}
type Frame = {
key: string
commit: StreamCommit
}
type DetailState = {
sessionID: string
data: SessionData
frames: Frame[]
}
export type SubagentData = {
tabs: Map<string, FooterSubagentTab>
details: Map<string, DetailState>
}
export type BootstrapSubagentInput = {
data: SubagentData
messages: SessionMessage[]
children: Array<{ id: string; title?: string }>
permissions: PermissionRequest[]
questions: QuestionRequest[]
}
function createDetail(sessionID: string): DetailState {
return {
sessionID,
data: createSessionData({
includeUserText: true,
}),
frames: [],
}
}
function ensureDetail(data: SubagentData, sessionID: string) {
const current = data.details.get(sessionID)
if (current) {
return current
}
const next = createDetail(sessionID)
data.details.set(sessionID, next)
return next
}
export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) {
if (!a || !b) {
return false
}
return (
a.sessionID === b.sessionID &&
a.partID === b.partID &&
a.callID === b.callID &&
a.label === b.label &&
a.description === b.description &&
a.status === b.status &&
a.title === b.title &&
a.toolCalls === b.toolCalls &&
a.lastUpdatedAt === b.lastUpdatedAt
)
}
function sameQueue<T extends { id: string }>(left: T[], right: T[]) {
return (
left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index])
)
}
function queueSnapshot(data: SessionData) {
return {
permissions: data.permissions.slice(),
questions: data.questions.slice(),
}
}
function queueChanged(data: SessionData, before: ReturnType<typeof queueSnapshot>) {
return !sameQueue(before.permissions, data.permissions) || !sameQueue(before.questions, data.questions)
}
function sameCommit(left: StreamCommit, right: StreamCommit) {
return (
left.kind === right.kind &&
left.text === right.text &&
left.phase === right.phase &&
left.source === right.source &&
left.messageID === right.messageID &&
left.partID === right.partID &&
left.tool === right.tool &&
left.interrupted === right.interrupted &&
left.toolState === right.toolState &&
left.toolError === right.toolError
)
}
function text(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined
}
const next = value.trim()
return next || undefined
}
function num(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
return undefined
}
function inputLabel(input: Record<string, unknown>): string | undefined {
const description = text(input.description)
if (description) {
return description
}
const command = text(input.command)
if (command) {
return command
}
const filePath = text(input.filePath) ?? text(input.filepath)
if (filePath) {
return filePath
}
const pattern = text(input.pattern)
if (pattern) {
return pattern
}
const query = text(input.query)
if (query) {
return query
}
const url = text(input.url)
if (url) {
return url
}
const path = text(input.path)
if (path) {
return path
}
const prompt = text(input.prompt)
if (prompt) {
return prompt
}
return undefined
}
function stateTitle(part: ToolPart) {
return text("title" in part.state ? part.state.title : undefined)
}
function callKey(messageID: string | undefined, callID: string | undefined): string | undefined {
if (!messageID || !callID) {
return undefined
}
return `${messageID}:${callID}`
}
function compactToolState(part: ToolPart): ToolPart["state"] {
if (part.state.status === "pending") {
return {
status: "pending",
input: part.state.input,
raw: part.state.raw,
}
}
if (part.state.status === "running") {
return {
status: "running",
input: part.state.input,
time: part.state.time,
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
...(part.state.title ? { title: part.state.title } : {}),
}
}
if (part.state.status === "completed") {
return {
status: "completed",
input: part.state.input,
output: part.state.output,
title: part.state.title,
metadata: part.state.metadata,
time: part.state.time,
}
}
return {
status: "error",
input: part.state.input,
error: part.state.error,
time: part.state.time,
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
}
}
function recent<T>(input: Iterable<T>, limit: number) {
const list = [...input]
return list.slice(Math.max(0, list.length - limit))
}
function copyMap<K, V>(source: Map<K, V>, keep: Set<K>) {
const out = new Map<K, V>()
for (const [key, value] of source) {
if (!keep.has(key)) {
continue
}
out.set(key, value)
}
return out
}
function compactToolPart(part: ToolPart): ToolPart {
return {
id: part.id,
type: "tool",
sessionID: part.sessionID,
messageID: part.messageID,
callID: part.callID,
tool: part.tool,
state: compactToolState(part),
...(part.metadata ? { metadata: part.metadata } : {}),
}
}
function compactCommit(commit: StreamCommit): StreamCommit {
if (!commit.part) {
return commit
}
return {
...commit,
part: compactToolPart(commit.part),
}
}
function stateUpdatedAt(part: ToolPart) {
if (!("time" in part.state)) {
return Date.now()
}
const time = part.state.time
if (!("end" in time)) {
return time.start ?? Date.now()
}
return time.end ?? time.start ?? Date.now()
}
function metadata(part: ToolPart, key: string) {
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
}
function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
const status = part.state.status === "error" ? "error" : part.state.status === "completed" ? "completed" : "running"
return {
sessionID,
partID: part.id,
callID: part.callID,
label,
description,
status,
title: stateTitle(part),
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
lastUpdatedAt: stateUpdatedAt(part),
}
}
function taskSessionID(part: ToolPart) {
return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID"))
}
function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set<string>) {
if (part.tool !== "task") {
return false
}
const sessionID = taskSessionID(part)
if (!sessionID) {
return false
}
if (children && children.size > 0 && !children.has(sessionID)) {
return false
}
const next = taskTab(part, sessionID)
if (sameSubagentTab(data.tabs.get(sessionID), next)) {
ensureDetail(data, sessionID)
return false
}
data.tabs.set(sessionID, next)
ensureDetail(data, sessionID)
return true
}
function frameKey(commit: StreamCommit) {
if (commit.partID) {
return `${commit.kind}:${commit.partID}:${commit.phase}`
}
if (commit.messageID) {
return `${commit.kind}:${commit.messageID}:${commit.phase}`
}
return `${commit.kind}:${commit.phase}:${commit.text}`
}
function limitFrames(detail: DetailState) {
if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) {
return
}
detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT)
}
function mergeLiveCommit(current: StreamCommit, next: StreamCommit) {
if (current.phase !== "progress" || next.phase !== "progress") {
if (sameCommit(current, next)) {
return current
}
return next
}
const merged = {
...current,
...next,
text: current.text + next.text,
}
if (sameCommit(current, merged)) {
return current
}
return merged
}
function appendCommits(detail: DetailState, commits: StreamCommit[]) {
let changed = false
for (const commit of commits.map(compactCommit)) {
const key = frameKey(commit)
const index = detail.frames.findIndex((item) => item.key === key)
if (index === -1) {
detail.frames.push({
key,
commit,
})
changed = true
continue
}
const next = mergeLiveCommit(detail.frames[index].commit, commit)
if (sameCommit(detail.frames[index].commit, next)) {
continue
}
detail.frames[index] = {
key,
commit: next,
}
changed = true
}
if (changed) {
limitFrames(detail)
}
return changed
}
function ensureBlockerTab(
data: SubagentData,
sessionID: string,
title: string | undefined,
kind: "permission" | "question",
) {
if (data.tabs.has(sessionID)) {
ensureDetail(data, sessionID)
return false
}
data.tabs.set(sessionID, {
sessionID,
partID: `bootstrap:${sessionID}`,
callID: `bootstrap:${sessionID}`,
label: text(title) ?? Locale.titlecase(kind),
description: kind === "permission" ? "Pending permission" : "Pending question",
status: "running",
lastUpdatedAt: Date.now(),
})
ensureDetail(data, sessionID)
return true
}
function compactCallMap(detail: DetailState) {
const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
for (const request of detail.data.permissions) {
const key = callKey(request.tool?.messageID, request.tool?.callID)
if (key) {
keep.add(key)
}
}
for (const item of detail.frames) {
const key = callKey(item.commit.part?.messageID, item.commit.part?.callID)
if (key) {
keep.add(key)
}
}
return copyMap(detail.data.call, keep)
}
function compactEchoMap(data: SessionData, messageIDs: Set<string>) {
const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)])
return copyMap(data.echo, keys)
}
function compactIDs(detail: DetailState) {
return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT))
}
function compactDetail(detail: DetailState) {
const next = createSessionData({
includeUserText: true,
})
const activePartIDs = new Set(detail.data.part.keys())
const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : [])))
const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools])
const messageIDs = new Set([
...[...activePartIDs]
.map((partID) => detail.data.msg.get(partID))
.filter((item): item is string => typeof item === "string"),
...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT),
])
next.announced = detail.data.announced
next.permissions = detail.data.permissions
next.questions = detail.data.questions
next.ids = compactIDs(detail)
next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item)))
next.call = compactCallMap(detail)
next.role = copyMap(detail.data.role, messageIDs)
next.msg = copyMap(detail.data.msg, activePartIDs)
next.part = copyMap(detail.data.part, activePartIDs)
next.text = copyMap(detail.data.text, activePartIDs)
next.sent = copyMap(detail.data.sent, activePartIDs)
next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item)))
next.echo = compactEchoMap(detail.data, messageIDs)
detail.data = next
}
function applyChildEvent(input: {
detail: DetailState
event: Event
thinking: boolean
limits: Record<string, number>
}) {
const before = queueSnapshot(input.detail.data)
const out = reduceSessionData({
data: input.detail.data,
event: input.event,
sessionID: input.detail.sessionID,
thinking: input.thinking,
limits: input.limits,
})
const changed = appendCommits(input.detail, out.commits)
compactDetail(input.detail)
return changed || queueChanged(input.detail.data, before)
}
function knownSession(data: SubagentData, sessionID: string) {
return data.tabs.has(sessionID)
}
export function listSubagentPermissions(data: SubagentData) {
return [...data.details.values()].flatMap((detail) => detail.data.permissions)
}
export function listSubagentQuestions(data: SubagentData) {
return [...data.details.values()].flatMap((detail) => detail.data.questions)
}
export function createSubagentData(): SubagentData {
return {
tabs: new Map(),
details: new Map(),
}
}
function snapshotDetail(detail: DetailState) {
return {
sessionID: detail.sessionID,
commits: detail.frames.map((item) => item.commit),
}
}
export function listSubagentTabs(data: SubagentData) {
return [...data.tabs.values()].sort((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) {
return active
}
return b.lastUpdatedAt - a.lastUpdatedAt
})
}
function snapshotQueues(data: SubagentData) {
return {
permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)),
questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)),
}
}
function snapshotState(data: SubagentData, details: FooterSubagentState["details"]): FooterSubagentState {
return {
tabs: listSubagentTabs(data),
details,
...snapshotQueues(data),
}
}
export function snapshotSubagentData(data: SubagentData): FooterSubagentState {
return snapshotState(
data,
Object.fromEntries([...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)])),
)
}
export function snapshotSelectedSubagentData(
data: SubagentData,
selectedSessionID: string | undefined,
): FooterSubagentState {
const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined
return snapshotState(data, detail ? { [detail.sessionID]: snapshotDetail(detail) } : {})
}
export function bootstrapSubagentData(input: BootstrapSubagentInput) {
const child = new Map(input.children.map((item) => [item.id, item]))
const children = new Set(child.keys())
let changed = false
for (const message of input.messages) {
for (const part of message.parts) {
if (part.type !== "tool") {
continue
}
changed = syncTaskTab(input.data, part, children) || changed
}
}
for (const item of input.permissions) {
if (!children.has(item.sessionID)) {
continue
}
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed
}
for (const item of input.questions) {
if (!children.has(item.sessionID)) {
continue
}
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed
}
for (const sessionID of input.data.tabs.keys()) {
const detail = ensureDetail(input.data, sessionID)
const before = queueSnapshot(detail.data)
bootstrapSessionData({
data: detail.data,
messages: [],
permissions: input.permissions
.filter((item) => item.sessionID === sessionID)
.sort((a, b) => a.id.localeCompare(b.id)),
questions: input.questions
.filter((item) => item.sessionID === sessionID)
.sort((a, b) => a.id.localeCompare(b.id)),
})
compactDetail(detail)
changed = queueChanged(detail.data, before) || changed
}
return changed
}
export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) {
if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
return false
}
const detail = ensureDetail(input.data, input.sessionID)
const before = queueSnapshot(detail.data)
const beforeCallCount = detail.data.call.size
bootstrapSessionData({
data: detail.data,
messages: input.messages,
permissions: detail.data.permissions,
questions: detail.data.questions,
})
compactDetail(detail)
return beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
}
export function clearFinishedSubagents(data: SubagentData) {
let changed = false
for (const [sessionID, tab] of data.tabs.entries()) {
if (tab.status === "running") {
continue
}
data.tabs.delete(sessionID)
data.details.delete(sessionID)
changed = true
}
return changed
}
export function reduceSubagentData(input: {
data: SubagentData
event: Event
sessionID: string
thinking: boolean
limits: Record<string, number>
}) {
const event = input.event
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID === input.sessionID) {
if (part.type !== "tool") {
return false
}
return syncTaskTab(input.data, part)
}
}
const sessionID =
event.type === "message.updated" ||
event.type === "message.part.delta" ||
event.type === "permission.asked" ||
event.type === "permission.replied" ||
event.type === "question.asked" ||
event.type === "question.replied" ||
event.type === "question.rejected" ||
event.type === "session.error" ||
event.type === "session.status"
? event.properties.sessionID
: event.type === "message.part.updated"
? event.properties.part.sessionID
: undefined
if (!sessionID || !knownSession(input.data, sessionID)) {
return false
}
const detail = ensureDetail(input.data, sessionID)
if (event.type === "session.status") {
if (event.properties.status.type !== "retry") {
return false
}
return appendCommits(detail, [
{
kind: "error",
text: event.properties.status.message,
phase: "start",
source: "system",
messageID: `retry:${event.properties.status.attempt}`,
},
])
}
if (event.type === "session.error" && event.properties.error) {
return appendCommits(detail, [
{
kind: "error",
text: formatError(event.properties.error),
phase: "start",
source: "system",
messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
},
])
}
return applyChildEvent({
detail,
event,
thinking: input.thinking,
limits: input.limits,
})
}
+599
View File
@@ -0,0 +1,599 @@
// Theme resolution for direct interactive mode.
//
// Derives scrollback and footer colors from the terminal's actual palette.
// resolveRunTheme() queries the renderer for the terminal's palette,
// detects dark/light mode, builds a small system theme locally, and maps it to
// the run footer + scrollback color model. Falls back to a hardcoded dark-mode
// 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"
type Tone = {
body: ColorInput
start?: ColorInput
}
export type RunEntryTheme = Record<EntryKind, Tone>
export type RunSplashTheme = {
left: ColorInput
right: ColorInput
leftShadow: ColorInput
rightShadow: ColorInput
}
export type RunFooterTheme = {
highlight: ColorInput
warning: ColorInput
success: ColorInput
error: ColorInput
muted: ColorInput
text: ColorInput
shade: ColorInput
surface: ColorInput
pane: ColorInput
border: ColorInput
line: ColorInput
}
export type RunBlockTheme = {
text: ColorInput
muted: ColorInput
syntax?: SyntaxStyle
subtleSyntax?: SyntaxStyle
diffAdded: ColorInput
diffRemoved: ColorInput
diffAddedBg: ColorInput
diffRemovedBg: ColorInput
diffContextBg: ColorInput
diffHighlightAdded: ColorInput
diffHighlightRemoved: ColorInput
diffLineNumber: ColorInput
diffAddedLineNumberBg: ColorInput
diffRemovedLineNumberBg: ColorInput
}
export type RunTheme = {
background: ColorInput
footer: RunFooterTheme
entry: RunEntryTheme
splash: RunSplashTheme
block: RunBlockTheme
}
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
type HexColor = `#${string}`
type RefName = string
type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
type ColorValue = HexColor | RefName | Variant | RGBA | number
type ThemeJson = {
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
type SharedSyntaxTheme = TuiThemeCurrent & {
_hasSelectedListItemText: boolean
}
export const transparent = RGBA.fromValues(0, 0, 0, 0)
function alpha(color: RGBA, value: number): RGBA {
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, value)))
}
function rgba(hex: string, value?: number): RGBA {
const color = RGBA.fromHex(hex)
return value === undefined ? color : alpha(color, value)
}
function mode(bg: RGBA): "dark" | "light" {
const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
return lum > 0.5 ? "light" : "dark"
}
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
if (color.a === 0) {
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, fallback)))
}
const target = Math.min(limit, color.a * scale)
const mix = Math.min(1, target / color.a)
return RGBA.fromValues(
base.r + (color.r - base.r) * mix,
base.g + (color.g - base.g) * mix,
base.b + (color.b - base.b) * mix,
color.a,
)
}
function ansiToRgba(code: number): RGBA {
if (code < 16) {
const ansi = [
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
]
return RGBA.fromHex(ansi[code] ?? "#000000")
}
if (code < 232) {
const index = code - 16
const b = index % 6
const g = Math.floor(index / 6) % 6
const r = Math.floor(index / 36)
const value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
return RGBA.fromInts(value(r), value(g), value(b))
}
if (code < 256) {
const gray = (code - 232) * 10 + 8
return RGBA.fromInts(gray, gray, gray)
}
return RGBA.fromInts(0, 0, 0)
}
function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
return RGBA.fromInts(
Math.round((base.r + (overlay.r - base.r) * value) * 255),
Math.round((base.g + (overlay.g - base.g) * value) * 255),
Math.round((base.b + (overlay.b - base.b) * value) * 255),
)
}
function blend(color: RGBA, bg: RGBA): RGBA {
if (color.a >= 1) {
return color
}
return RGBA.fromValues(
bg.r + (color.r - bg.r) * color.a,
bg.g + (color.g - bg.g) * color.a,
bg.b + (color.b - bg.b) * color.a,
1,
)
}
function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
}
function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
if (!style) {
return undefined
}
return SyntaxStyle.fromStyles(
Object.fromEntries(
[...style.getAllStyles()].map(([name, value]) => [
name,
{
...value,
fg: value.fg ? blend(value.fg, bg) : value.fg,
bg: value.bg ? blend(value.bg, bg) : value.bg,
},
]),
),
)
}
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
return Array.from({ length: size }, (_, index) => {
const value = colors.palette[index]
return RGBA.fromIndex(index, value ? RGBA.fromHex(value) : ansiToRgba(index))
})
}
function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
const hit = indexed.reduce(
(best, item) => {
const dr = item.r - rgba.r
const dg = item.g - rgba.g
const db = item.b - rgba.b
const dist = dr * dr + dg * dg + db * db
if (dist >= best.dist) return best
return {
dist,
item,
}
},
{
dist: Number.POSITIVE_INFINITY,
item: indexed[0]!,
},
)
return RGBA.clone(hit.item)
}
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
const mixed = tint(base, overlay, value)
return nearestIndexed(indexed, mixed)
}
export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent {
const defs = theme.defs ?? {}
const resolveColor = (value: ColorValue, chain: string[] = []): RGBA => {
if (value instanceof RGBA) return value
if (typeof value === "number") {
return RGBA.fromIndex(value, ansiToRgba(value))
}
if (typeof value !== "string") {
return resolveColor(value[pick], chain)
}
if (value === "transparent" || value === "none") {
return RGBA.fromInts(0, 0, 0, 0)
}
if (value.startsWith("#")) {
return RGBA.fromHex(value)
}
if (chain.includes(value)) {
throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
}
const next = defs[value] ?? theme.theme[value as ThemeColor]
if (next === undefined) {
throw new Error(`Color reference "${value}" not found in defs or theme`)
}
return resolveColor(next, [...chain, value])
}
const resolved = Object.fromEntries(
Object.entries(theme.theme)
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
.map(([key, value]) => [key, resolveColor(value as ColorValue)]),
) as Partial<Record<ThemeColor, RGBA>>
return {
...(resolved as Record<ThemeColor, RGBA>),
selectedListItemText:
theme.theme.selectedListItemText === undefined
? resolved.background!
: resolveColor(theme.theme.selectedListItemText),
backgroundMenu:
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
}
}
function generateGrayScale(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): Record<number, RGBA> {
const r = bg.r * 255
const g = bg.g * 255
const b = bg.b * 255
const lum = 0.299 * r + 0.587 * g + 0.114 * b
const cast = 0.25 * (1 - chroma(bg)) ** 2
const gray = (level: number) => {
const factor = level / 12
if (isDark && lum < 10) {
const value = Math.floor(factor * 0.4 * 255)
return map(RGBA.fromInts(value, value, value))
}
if (!isDark && lum > 245) {
const value = Math.floor(255 - factor * 0.4 * 255)
return map(RGBA.fromInts(value, value, value))
}
const value = isDark ? lum + (255 - lum) * factor * 0.4 : lum * (1 - factor * 0.4)
const tone = RGBA.fromInts(Math.floor(value), Math.floor(value), Math.floor(value))
if (cast === 0) return map(tone)
const ratio = lum === 0 ? 0 : value / lum
return map(
tint(
tone,
RGBA.fromInts(
Math.floor(Math.max(0, Math.min(r * ratio, 255))),
Math.floor(Math.max(0, Math.min(g * ratio, 255))),
Math.floor(Math.max(0, Math.min(b * ratio, 255))),
),
cast,
),
)
}
return Object.fromEntries(Array.from({ length: 12 }, (_, index) => [index + 1, gray(index + 1)]))
}
function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): RGBA {
const lum = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
const gray = isDark
? lum < 10
? 180
: Math.min(Math.floor(160 + lum * 0.3), 200)
: lum > 245
? 75
: Math.max(Math.floor(100 - (255 - lum) * 0.2), 60)
return map(RGBA.fromInts(gray, gray, gray))
}
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson {
const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const bg = RGBA.defaultBackground(bg_snapshot)
const fg = RGBA.defaultForeground(fg_snapshot)
const isDark = pick === "dark"
const indexed = indexedPalette(colors)
const color = (index: number) => RGBA.clone(indexed[index]!)
const nearest = (rgba: RGBA) => nearestIndexed(indexed, rgba)
const grays = generateGrayScale(bg_snapshot, isDark, nearest)
const menu_grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
const textMuted = generateMutedTextColor(bg_snapshot, isDark, nearest)
const ansi = {
red: color(1),
green: color(2),
yellow: color(3),
blue: color(4),
magenta: color(5),
cyan: color(6),
red_bright: color(9),
green_bright: color(10),
}
const diff_alpha = isDark ? 0.22 : 0.14
const diff_context_bg = grays[2]
const primary = ansi.cyan
const secondary = ansi.magenta
return {
theme: {
primary,
secondary,
accent: primary,
error: ansi.red,
warning: ansi.yellow,
success: ansi.green,
info: ansi.cyan,
text: fg,
textMuted,
selectedListItemText: bg,
background: alpha(bg, 0),
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: menu_grays[3],
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
diffAdded: ansi.green,
diffRemoved: ansi.red,
diffContext: grays[7],
diffHunkHeader: grays[7],
diffHighlightAdded: ansi.green_bright,
diffHighlightRemoved: ansi.red_bright,
diffAddedBg: nearest(tint(bg_snapshot, ansi.green, diff_alpha)),
diffRemovedBg: nearest(tint(bg_snapshot, ansi.red, diff_alpha)),
diffContextBg: diff_context_bg,
diffLineNumber: textMuted,
diffAddedLineNumberBg: nearest(tint(diff_context_bg, ansi.green, diff_alpha)),
diffRemovedLineNumberBg: nearest(tint(diff_context_bg, ansi.red, diff_alpha)),
markdownText: fg,
markdownHeading: fg,
markdownLink: ansi.blue,
markdownLinkText: ansi.cyan,
markdownCode: ansi.green,
markdownBlockQuote: ansi.yellow,
markdownEmph: ansi.yellow,
markdownStrong: fg,
markdownHorizontalRule: grays[7],
markdownListItem: ansi.blue,
markdownListEnumeration: ansi.cyan,
markdownImage: ansi.blue,
markdownImageText: ansi.cyan,
markdownCodeBlock: fg,
syntaxComment: textMuted,
syntaxKeyword: ansi.magenta,
syntaxFunction: ansi.blue,
syntaxVariable: fg,
syntaxString: ansi.green,
syntaxNumber: ansi.yellow,
syntaxType: ansi.cyan,
syntaxOperator: ansi.cyan,
syntaxPunctuation: fg,
},
}
}
function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
const left = nearestIndexed(indexed, theme.textMuted)
const right = nearestIndexed(indexed, theme.text)
return {
left,
right,
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
rightShadow: splashShadow(indexed, theme.background, right, 0.14),
}
}
function map(
theme: TuiThemeCurrent,
splash: RunSplashTheme,
syntax?: SyntaxStyle,
subtleSyntax?: SyntaxStyle,
): RunTheme {
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, theme.background)
subtleSyntax?.destroy()
const shade = fade(theme.backgroundMenu, theme.background, 0.12, 0.56, 0.72)
const surface = fade(theme.backgroundMenu, theme.background, 0.18, 0.76, 0.9)
const line = fade(theme.backgroundMenu, theme.background, 0.24, 0.9, 0.98)
return {
background: theme.background,
footer: {
highlight: theme.primary,
warning: theme.warning,
success: theme.success,
error: theme.error,
muted: theme.textMuted,
text: theme.text,
shade,
surface,
pane: theme.backgroundMenu,
border: theme.border,
line,
},
entry: {
system: {
body: theme.textMuted,
},
user: {
body: theme.primary,
},
assistant: {
body: theme.text,
},
reasoning: {
body: theme.textMuted,
},
tool: {
body: theme.text,
start: theme.textMuted,
},
error: {
body: theme.error,
},
},
splash,
block: {
text: theme.text,
muted: theme.textMuted,
syntax,
subtleSyntax: opaqueSubtleSyntax,
diffAdded: theme.diffAdded,
diffRemoved: theme.diffRemoved,
diffAddedBg: transparent,
diffRemovedBg: transparent,
diffContextBg: transparent,
diffHighlightAdded: theme.diffHighlightAdded,
diffHighlightRemoved: theme.diffHighlightRemoved,
diffLineNumber: theme.diffLineNumber,
diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
},
}
}
const seed = {
highlight: RGBA.fromIndex(6, rgba("#38bdf8")),
muted: RGBA.fromIndex(8, rgba("#64748b")),
text: RGBA.defaultForeground(rgba("#f8fafc")),
panel: rgba("#0f172a"),
success: RGBA.fromIndex(2, rgba("#22c55e")),
warning: RGBA.fromIndex(3, rgba("#f59e0b")),
error: RGBA.fromIndex(1, rgba("#ef4444")),
}
function tone(body: ColorInput, start?: ColorInput): Tone {
return {
body,
start,
}
}
const fallbackSplashIndexed = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index))
const fallbackSplashLeft = RGBA.fromIndex(67)
const fallbackSplashRight = RGBA.fromIndex(110)
export const RUN_THEME_FALLBACK: RunTheme = {
background: RGBA.fromValues(0, 0, 0, 0),
footer: {
highlight: seed.highlight,
warning: seed.warning,
success: seed.success,
error: seed.error,
muted: seed.muted,
text: seed.text,
shade: alpha(seed.panel, 0.68),
surface: alpha(seed.panel, 0.86),
pane: seed.panel,
border: seed.muted,
line: alpha(seed.panel, 0.96),
},
entry: {
system: tone(seed.muted),
user: tone(seed.highlight),
assistant: tone(seed.text),
reasoning: tone(seed.muted),
tool: tone(seed.text, seed.muted),
error: tone(seed.error),
},
splash: {
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: {
text: seed.text,
muted: seed.muted,
diffAdded: seed.success,
diffRemoved: seed.error,
diffAddedBg: alpha(seed.success, 0.18),
diffRemovedBg: alpha(seed.error, 0.18),
diffContextBg: alpha(seed.panel, 0.72),
diffHighlightAdded: seed.success,
diffHighlightRemoved: seed.error,
diffLineNumber: seed.muted,
diffAddedLineNumberBg: alpha(seed.success, 0.12),
diffRemovedLineNumberBg: alpha(seed.error, 0.12),
},
}
export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
try {
const colors = await renderer.getPalette({
size: 256,
})
const bg = colors.defaultBackground ?? colors.palette[0]
if (!bg) {
return RUN_THEME_FALLBACK
}
const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
const theme = resolveTheme(generateSystem(colors, pick), pick)
const indexed = indexedPalette(colors, 256)
const shared = await import("../tui/context/theme")
const syntaxTheme: SharedSyntaxTheme = {
...theme,
_hasSelectedListItemText: true,
}
const syntax = shared.generateSyntax(syntaxTheme)
return map(theme, splashTheme(theme, indexed), syntax, shared.generateSubtleSyntax(syntaxTheme))
} catch {
return RUN_THEME_FALLBACK
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
// Dev-only JSONL event trace for direct interactive mode.
//
// Enable with OPENCODE_DIRECT_TRACE=1. Writes one JSON line per event to
// ~/.local/share/opencode/log/direct/<timestamp>-<pid>.jsonl. Also writes
// a latest.json pointer so you can quickly find the most recent trace.
//
// The trace captures the full closed loop: outbound prompts, inbound SDK
// events, reducer output, footer commits, and turn lifecycle markers.
// Useful for debugging stream ordering, permission behavior, and
// footer/transcript mismatches.
//
// Lazy-initialized: the first call to trace() decides whether tracing is
// active based on the env var, and subsequent calls return the cached result.
import fs from "fs"
import path from "path"
import { Global } from "@opencode-ai/core/global"
export type Trace = {
write(type: string, data?: unknown): void
}
let state: Trace | false | undefined
function stamp() {
return new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "Z")
}
function file() {
return path.join(Global.Path.log, "direct", `${stamp()}-${process.pid}.jsonl`)
}
function latest() {
return path.join(Global.Path.log, "direct", "latest.json")
}
function text(data: unknown) {
return JSON.stringify(
data,
(_key, value) => {
if (typeof value === "bigint") {
return String(value)
}
return value
},
0,
)
}
export function trace(): Trace | undefined {
if (state !== undefined) {
return state || undefined
}
if (!process.env.OPENCODE_DIRECT_TRACE) {
state = false
return undefined
}
const target = file()
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(
latest(),
text({
time: new Date().toISOString(),
pid: process.pid,
cwd: process.cwd(),
argv: process.argv.slice(2),
path: target,
}) + "\n",
)
state = {
write(type: string, data?: unknown) {
fs.appendFileSync(
target,
text({
time: new Date().toISOString(),
pid: process.pid,
type,
data,
}) + "\n",
)
},
}
state.write("trace.start", {
argv: process.argv.slice(2),
cwd: process.cwd(),
path: target,
})
return state
}
+317
View File
@@ -0,0 +1,317 @@
// Shared type vocabulary for the direct interactive mode (`run --interactive`).
//
// 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
// aligned on that two-lane model.
//
// Data flow through the system:
//
// SDK events → session-data reducer → StreamCommit[] + FooterOutput
// → stream.ts bridges to footer API
// → footer.ts queues commits and patches the footer view
// → OpenTUI split-footer renderer writes to terminal
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
export type RunFilePart = {
type: "file"
url: string
filename: string
mime: string
}
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
export type RunCommand = NonNullable<Awaited<ReturnType<OpencodeClient["command"]["list"]>>["data"]>[number]
export type RunProvider = NonNullable<Awaited<ReturnType<OpencodeClient["provider"]["list"]>>["data"]>["all"][number]
export type RunPrompt = {
text: string
parts: RunPromptPart[]
command?: {
name: string
arguments: string
}
}
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
export type RunResource = RunResourceMap[string]
export type RunInput = {
sdk: OpencodeClient
directory: string
sessionID: string
sessionTitle?: string
resume?: boolean
agent: string | undefined
model: PromptModel | undefined
variant: string | undefined
files: RunFilePart[]
initialInput?: string
thinking: boolean
demo?: boolean
}
// The semantic role of a scrollback entry. Maps 1:1 to theme colors.
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
// Whether the assistant is actively processing a turn.
export type FooterPhase = "idle" | "running"
// Full snapshot of footer status bar state. Every update replaces the whole
// object in the SolidJS signal so the view re-renders atomically.
export type FooterState = {
phase: FooterPhase
status: string
queue: number
model: string
duration: string
usage: string
first: boolean
interrupt: number
exit: number
}
// 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 ScrollbackOptions = {
diffStyle?: RunDiffStyle
suppressBackgrounds?: boolean
}
export type ToolCodeSnapshot = {
kind: "code"
title: string
content: string
file?: string
}
export type ToolDiffSnapshot = {
kind: "diff"
items: Array<{
title: string
diff: string
file?: string
deletions?: number
}>
}
export type ToolTaskSnapshot = {
kind: "task"
title: string
rows: string[]
tail: string
}
export type ToolTodoSnapshot = {
kind: "todo"
items: Array<{
status: string
content: string
}>
tail: string
}
export type ToolQuestionSnapshot = {
kind: "question"
items: Array<{
question: string
answer: string
}>
tail: string
}
export type ToolSnapshot =
| ToolCodeSnapshot
| ToolDiffSnapshot
| ToolTaskSnapshot
| ToolTodoSnapshot
| ToolQuestionSnapshot
export type EntryLayout = "inline" | "block"
export type RunEntryBody =
| { type: "none" }
| { type: "text"; content: string }
| { type: "code"; content: string; filetype?: string }
| { type: "markdown"; content: string }
| { type: "structured"; snapshot: ToolSnapshot }
// Which interactive surface the footer is showing. Only one view is active at
// a time. The reducer drives transitions: when a permission arrives the view
// switches to "permission", and when the permission resolves it falls back to
// "prompt".
export type FooterView =
| { type: "prompt" }
| { type: "permission"; request: PermissionRequest }
| { type: "question"; request: QuestionRequest }
export type FooterPromptRoute =
| { type: "composer" }
| { type: "subagent"; sessionID: string }
| { type: "command" }
| { type: "model" }
| { type: "variant" }
export type FooterSubagentTab = {
sessionID: string
partID: string
callID: string
label: string
description: string
status: "running" | "completed" | "error"
title?: string
toolCalls?: number
lastUpdatedAt: number
}
export type FooterSubagentDetail = {
sessionID: string
commits: StreamCommit[]
}
export type FooterSubagentState = {
tabs: FooterSubagentTab[]
details: Record<string, FooterSubagentDetail>
permissions: PermissionRequest[]
questions: QuestionRequest[]
}
// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
export type FooterOutput = {
patch?: FooterPatch
view?: FooterView
subagent?: FooterSubagentState
}
// Typed messages sent to RunFooter.event(). The prompt queue and stream
// transport both emit these to update footer state without reaching into
// internal signals directly.
export type FooterEvent =
| {
type: "catalog"
agents: RunAgent[]
resources: RunResource[]
commands?: RunCommand[]
}
| {
type: "models"
providers: RunProvider[]
}
| {
type: "variants"
variants: string[]
current: string | undefined
}
| {
type: "queue"
queue: number
}
| {
type: "first"
first: boolean
}
| {
type: "model"
model: string
}
| {
type: "turn.send"
queue: number
}
| {
type: "turn.wait"
}
| {
type: "turn.idle"
queue: number
}
| {
type: "turn.duration"
duration: string
}
| {
type: "stream.patch"
patch: FooterPatch
}
| {
type: "stream.view"
view: FooterView
}
| {
type: "stream.subagent"
state: FooterSubagentState
}
export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
type FooterBinding = Binding<Renderable, KeyEvent>
export type FooterKeybinds = {
leader: string
leaderTimeout: number
commandList: readonly FooterBinding[]
variantCycle: readonly FooterBinding[]
interrupt: readonly FooterBinding[]
historyPrevious: readonly FooterBinding[]
historyNext: readonly FooterBinding[]
inputClear: readonly FooterBinding[]
inputSubmit: readonly FooterBinding[]
inputNewline: readonly FooterBinding[]
}
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
// appends content (coalesced in the footer queue), "final" closes it.
export type StreamPhase = "start" | "progress" | "final"
export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
export type StreamToolState = "running" | "completed" | "error"
// A single append-only commit to scrollback. The session-data reducer produces
// these from SDK events, and RunFooter.append() queues them for the next
// microtask flush. Once flushed, they become immutable terminal scrollback
// rows -- they cannot be rewritten.
export type StreamCommit = {
kind: EntryKind
text: string
phase: StreamPhase
source: StreamSource
messageID?: string
partID?: string
tool?: string
part?: ToolPart
interrupted?: boolean
toolState?: StreamToolState
toolError?: string
}
// The public contract between the stream transport / prompt queue and
// the footer. RunFooter implements this. The transport and queue never
// touch the renderer directly -- they go through this interface.
export type FooterApi = {
readonly isClosed: boolean
onPrompt(fn: (input: RunPrompt) => void): () => void
onClose(fn: () => void): () => void
event(next: FooterEvent): void
append(commit: StreamCommit): void
idle(): Promise<void>
close(): void
destroy(): void
}
@@ -0,0 +1,213 @@
// Model variant resolution and persistence.
//
// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
// Resolution priority: CLI --variant flag > saved preference > session history.
//
// The saved variant persists across sessions in ~/.local/state/opencode/model.json
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
// variant and the persisted file.
import path from "path"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Context, Effect, Layer } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@opencode-ai/core/global"
import { isRecord } from "@/util/record"
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
import type { RunInput, RunProvider } from "./types"
const MODEL_FILE = path.join(Global.Path.state, "model.json")
type ModelState = Record<string, unknown> & {
variant?: Record<string, string | undefined>
}
type VariantService = {
readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined>
readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void>
}
type VariantRuntime = {
resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined>
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
}
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
function modelKey(provider: string, model: string): string {
return `${provider}/${model}`
}
function variantKey(model: NonNullable<RunInput["model"]>): string {
return modelKey(model.providerID, model.modelID)
}
function modelInfo(providers: RunProvider[] | undefined, model: NonNullable<RunInput["model"]>) {
const provider = providers?.find((item) => item.id === model.providerID)
return {
provider: provider?.name ?? model.providerID,
model: provider?.models[model.modelID]?.name ?? model.modelID,
}
}
export function formatModelLabel(
model: NonNullable<RunInput["model"]>,
variant: string | undefined,
providers?: RunProvider[],
): string {
const names = modelInfo(providers, model)
const label = variant ? ` · ${variant}` : ""
return `${names.model} · ${names.provider}${label}`
}
export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
if (variants.length === 0) {
return undefined
}
if (!current) {
return variants[0]
}
const idx = variants.indexOf(current)
if (idx === -1 || idx === variants.length - 1) {
return undefined
}
return variants[idx + 1]
}
export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
return sessionVariant(Array.isArray(input) ? createSession(input) : input, model)
}
function fitVariant(value: string | undefined, variants: string[]): string | undefined {
if (!value) {
return undefined
}
if (variants.length === 0 || variants.includes(value)) {
return value
}
return undefined
}
// Picks the active variant. CLI flag wins, then saved preference, then session
// history. fitVariant() checks saved and session values against the available
// variants list -- if the provider doesn't offer a variant, it drops.
export function resolveVariant(
input: string | undefined,
session: string | undefined,
saved: string | undefined,
variants: string[],
): string | undefined {
if (input !== undefined) {
return input
}
const fallback = fitVariant(saved, variants)
const current = fitVariant(session, variants)
if (current !== undefined) {
return current
}
return fallback
}
function state(value: unknown): ModelState {
if (!isRecord(value)) {
return {}
}
const variant = isRecord(value.variant)
? Object.fromEntries(
Object.entries(value.variant).flatMap(([key, item]) => {
if (typeof item !== "string") {
return []
}
return [[key, item] as const]
}),
)
: undefined
return {
...value,
variant,
}
}
function createLayer(fs = AppFileSystem.defaultLayer) {
return Layer.fresh(
Layer.effect(
Service,
Effect.gen(function* () {
const file = yield* AppFileSystem.Service
const read = Effect.fn("RunVariant.read")(function* () {
return yield* file.readJson(MODEL_FILE).pipe(
Effect.map(state),
Effect.catchCause(() => Effect.succeed(state(undefined))),
)
})
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
if (!model) {
return undefined
}
return (yield* read()).variant?.[variantKey(model)]
})
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
model: RunInput["model"],
variant: string | undefined,
) {
if (!model) {
return
}
const current = yield* read()
const next = {
...current.variant,
}
const key = variantKey(model)
if (variant) {
next[key] = variant
}
if (!variant) {
delete next[key]
}
yield* file.writeJson(MODEL_FILE, {
...current,
variant: next,
}).pipe(Effect.orElseSucceed(() => undefined))
})
return Service.of({
resolveSavedVariant,
saveVariant,
})
}),
).pipe(Layer.provide(fs)),
)
}
/** @internal Exported for testing. */
export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime {
const runtime = makeRuntime(Service, createLayer(fs))
return {
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
}
}
const runtime = createVariantRuntime()
export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
return runtime.resolveSavedVariant(model)
}
export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
void runtime.saveVariant(model, variant)
}
+1 -1
View File
@@ -1,6 +1,5 @@
import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { errorMessage } from "@/util/error"
@@ -68,6 +67,7 @@ export const AttachCommand = cmd({
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
const { tui } = await import("./app")
try {
await validateSession({
@@ -5,7 +5,7 @@ import type { JSX } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import "opentui-spinner/solid"
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
const { theme } = useTheme()
@@ -14,7 +14,7 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
return (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}> {props.children}</text>}>
<box flexDirection="row" gap={1}>
<spinner frames={frames} interval={80} color={color()} />
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
@@ -518,7 +518,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
@@ -714,11 +714,11 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
return RGBA.fromInts(grayValue, grayValue, grayValue)
}
function generateSyntax(theme: Theme) {
export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
function generateSubtleSyntax(theme: Theme) {
export function generateSubtleSyntax(theme: Theme) {
const rules = getSyntaxRules(theme)
return SyntaxStyle.fromTheme(
rules.map((rule) => {
+1 -1
View File
@@ -1,5 +1,4 @@
import { cmd } from "@/cli/cmd/cmd"
import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
@@ -229,6 +228,7 @@ export const TuiThreadCommand = cmd({
}, 1000).unref?.()
try {
const { tui } = await import("./app")
await tui({
url: transport.url,
async onSnapshot() {