Compare commits

...

13 Commits

Author SHA1 Message Date
Simon Klee 0063202527 cli: remove split diff view and diff backgrounds
Unified view works well at all terminal widths. Drop the
width-based split/unified toggle and the colored diff backgrounds
to simplify rendering and reduce visual noise.
2026-05-07 22:05:33 +02:00
Simon Klee 6edb564a27 cli: clean up tool completion output formatting
Remove the └ prefix from tool completion lines and suppress
redundant patch-update rows when a patch also creates or deletes
files. Return undefined from snapPatch when no visible diff items
exist, preventing blank snapshots between tool entries.
2026-05-07 22:05:33 +02:00
Simon Klee 8cfcfd0def cli/run: fix tool spacing and question recovery
Completed bash output rendered with extra trailing blank lines and
double-spacing between adjacent groups. Strip leading/trailing blanks
from tool progress text and adjust the surface layer to trim the
leading newline when a spacer row was already emitted.

When the question.asked SSE event is missed, the footer hangs without
showing the question prompt. Poll question.list as a fallback when a
question tool enters running state with no matching request, and clear
stale requests when the tool completes independently.

Synthesize a start commit for tools that arrive as errors without a
prior running state so glob failures render under their header.
2026-05-07 22:05:33 +02:00
Simon Klee a861959470 simplify demo 2026-05-07 22:05:33 +02:00
Simon Klee 2a3c547feb dont make user message bold 2026-05-07 22:05:33 +02:00
Simon Klee aebe0cc845 fix type errors 2026-05-07 22:05:33 +02:00
Simon Klee e58c30a483 command menu 2026-05-07 22:05:33 +02:00
Simon Klee 2e6594feb8 fix types 2026-05-07 22:05:32 +02:00
Simon Klee 83cbc855f5 fix whitespace 2026-05-07 22:05:32 +02:00
Simon Klee ca1f041b02 tui: add preview margin to menu scrolling
The footer menu now starts scrolling before the selection reaches
the viewport edge, keeping surrounding items visible for context.
Extract offset logic into pure helpers and add tests.

Simplify theme primary color selection to use fixed ansi colors
instead of a contrast/chroma heuristic, and suppress diff
backgrounds in scrollback mode for cleaner rendering.
2026-05-07 22:05:32 +02:00
Simon Klee 2c260c4b46 cli: add variant selection panel to command menu
Previously model variants (reasoning effort) could only be cycled
with a keybind. Add a searchable selection panel accessible from the
command palette, matching the existing model selection flow. The
footer hint bar now advertises the command palette keybind instead
of the variant cycle shortcut.
2026-05-07 22:05:32 +02:00
Simon Klee 991ed7a7c4 run: clear prompt draft on Ctrl-c before exit
When the prompt has text, Ctrl-c now clears it instead of
immediately starting the two-press exit sequence. An empty prompt
still triggers exit as before. The clear key is configurable via
the input_clear keybind.

Model labels now show provider display names (e.g. "GPT-5 · OpenAI")
instead of raw IDs, preferring configured providers over the full
provider list for resolution.
2026-05-07 22:05:32 +02:00
Simon Klee 511312c426 run: add -i interactive minimal mode 2026-05-07 22:05:32 +02:00
56 changed files with 21552 additions and 321 deletions
+454 -314
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 } 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,22 @@ 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
}
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: `Exa Web Search "${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
inline(next)
} catch {
inline({
icon: "\u2699",
title: part.tool,
})
}
}
export const RunCommand = effectCmd({
@@ -296,38 +193,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 +301,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 +315,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 +341,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 +435,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,11 +605,11 @@ 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: "",
icon: "\u2717",
title: `${part.tool} failed`,
})
UI.error(part.state.error)
@@ -481,7 +622,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)
}
@@ -506,7 +647,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
@@ -546,7 +687,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",
})
@@ -556,7 +697,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",
})
@@ -564,114 +705,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)
})
}),
+1281
View File
@@ -0,0 +1,1281 @@
// Demo mode for testing direct interactive mode without a real SDK.
//
// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic
// SDK events that feed through the real reducer and footer pipeline. This
// lets you test scrollback formatting, permission UI, question UI, and tool
// snapshots without making actual model calls. Pass a demo slash command as
// the initial interactive message to trigger a preview immediately.
//
// Slash commands:
// /permission [kind] → triggers a permission request variant
// /question [kind] → triggers a question request variant
// /fmt <kind> → emits a specific tool/text type (text, reasoning, bash,
// write, edit, patch, task, todo, question, error, mix)
//
// Demo mode also handles permission and question replies locally, completing
// or failing the synthetic tool parts as appropriate.
import path from "path"
import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { writeSessionOutput } from "./stream"
import type {
FooterApi,
PermissionReply,
QuestionReject,
QuestionReply,
RunPrompt,
StreamCommit,
} from "./types"
const KINDS = [
"markdown",
"table",
"text",
"reasoning",
"bash",
"write",
"edit",
"patch",
"task",
"todo",
"question",
"error",
"mix",
]
const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const
const QUESTIONS = ["multi", "single", "checklist", "custom"] as const
type PermissionKind = (typeof PERMISSIONS)[number]
type QuestionKind = (typeof QUESTIONS)[number]
function permissionKind(value: string | undefined): PermissionKind | undefined {
const next = (value || "edit").toLowerCase()
return PERMISSIONS.find((item) => item === next)
}
function questionKind(value: string | undefined): QuestionKind | undefined {
const next = (value || "multi").toLowerCase()
return QUESTIONS.find((item) => item === next)
}
const SAMPLE_MARKDOWN = [
"# Direct Mode Demo",
"",
"This is a realistic assistant response for direct-mode formatting checks.",
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
"",
"## Summary",
"",
"- Restored the final markdown flush so the last block is committed on idle.",
"- Switched markdown scrollback commits back to top-level block boundaries.",
"- Added footer-level regression coverage for split-footer rendering.",
"",
"## Status",
"",
"| Area | Before | After | Notes |",
"| --- | --- | --- | --- |",
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
"",
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
"",
"```ts",
"const result = { markdown: true, tables: 2, stable: true }",
"```",
"",
"## Files",
"",
"| File | Change |",
"| --- | --- |",
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
"",
"Next step: run `/fmt table` if you want a tighter table-only sample.",
].join("\n")
const SAMPLE_TABLE = [
"# Table Sample",
"",
"| Kind | Example | Notes |",
"| --- | --- | --- |",
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
"| Unicode | `漢字` | Wide characters should remain aligned |",
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
"| Status | done | Final row should still appear after idle |",
].join("\n")
type Ref = {
msg: string
part: string
call: string
tool: string
input: Record<string, unknown>
start: number
}
type Ask = {
ref: Ref
}
type Perm = {
ref: Ref
done: {
title: string
output: string
metadata?: Record<string, unknown>
}
}
type Permit = {
ref: Ref
permission: string
patterns: string[]
metadata?: Record<string, unknown>
always: string[]
done: Perm["done"]
}
type State = {
id: string
thinking: boolean
data: SessionData
footer: FooterApi
limits: () => Record<string, number>
msg: number
part: number
call: number
perm: number
ask: number
perms: Map<string, Perm>
asks: Map<string, Ask>
}
type Input = {
sessionID: string
thinking: boolean
limits: () => Record<string, number>
footer: FooterApi
}
function note(footer: FooterApi, text: string): void {
footer.append({
kind: "system",
text,
phase: "start",
source: "system",
})
}
function clearSubagent(footer: FooterApi): void {
footer.event({
type: "stream.subagent",
state: {
tabs: [],
details: {},
permissions: [],
questions: [],
},
})
}
function showSubagent(
state: State,
input: {
sessionID: string
partID: string
callID: string
label: string
description: string
status: "running" | "completed" | "error"
title?: string
toolCalls?: number
commits: StreamCommit[]
},
) {
state.footer.event({
type: "stream.subagent",
state: {
tabs: [
{
sessionID: input.sessionID,
partID: input.partID,
callID: input.callID,
label: input.label,
description: input.description,
status: input.status,
title: input.title,
toolCalls: input.toolCalls,
lastUpdatedAt: Date.now(),
},
],
details: {
[input.sessionID]: {
sessionID: input.sessionID,
commits: input.commits,
},
},
permissions: [],
questions: [],
},
})
}
function wait(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (!signal) {
setTimeout(resolve, ms)
return
}
if (signal.aborted) {
resolve()
return
}
const done = () => {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", done)
resolve()
}, ms)
signal.addEventListener("abort", done, { once: true })
})
}
function split(text: string): string[] {
if (text.length <= 48) {
return [text]
}
const size = Math.ceil(text.length / 3)
return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)]
}
function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string {
state[key] += 1
return `demo_${prefix}_${state[key]}`
}
function feed(state: State, event: Event): void {
const out = reduceSessionData({
data: state.data,
event,
sessionID: state.id,
thinking: state.thinking,
limits: state.limits(),
})
state.data = out.data
writeSessionOutput(
{
footer: state.footer,
},
out,
)
}
function open(state: State): string {
const id = take(state, "msg", "msg")
feed(state, {
type: "message.updated",
properties: {
sessionID: state.id,
info: {
id,
sessionID: state.id,
role: "assistant",
time: {
created: Date.now(),
},
parentID: `user_${id}`,
modelID: "demo",
providerID: "demo",
mode: "demo",
agent: "demo",
path: {
cwd: process.cwd(),
root: process.cwd(),
},
cost: 0.001,
tokens: {
input: 120,
output: 320,
reasoning: 80,
cache: {
read: 0,
write: 0,
},
},
},
},
} as Event)
return id
}
async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state)
const part = take(state, "part", "part")
const start = Date.now()
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "text",
text: "",
time: {
start,
},
},
},
} as Event)
let next = ""
for (const item of split(body)) {
if (signal?.aborted) {
return
}
next += item
feed(state, {
type: "message.part.delta",
properties: {
sessionID: state.id,
messageID: msg,
partID: part,
field: "text",
delta: item,
},
} as Event)
await wait(45, signal)
}
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "text",
text: next,
time: {
start,
end: Date.now(),
},
},
},
} as Event)
}
async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state)
const part = take(state, "part", "part")
const start = Date.now()
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "reasoning",
text: "",
time: {
start,
},
},
},
} as Event)
let next = ""
for (const item of split(body)) {
if (signal?.aborted) {
return
}
next += item
feed(state, {
type: "message.part.delta",
properties: {
sessionID: state.id,
messageID: msg,
partID: part,
field: "text",
delta: item,
},
} as Event)
await wait(45, signal)
}
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "reasoning",
text: next,
time: {
start,
end: Date.now(),
},
},
},
} as Event)
}
function make(state: State, tool: string, input: Record<string, unknown>): Ref {
return {
msg: open(state),
part: take(state, "part", "part"),
call: take(state, "call", "call"),
tool,
input,
start: Date.now(),
}
}
function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
type: "tool",
callID: ref.call,
tool: ref.tool,
state: {
status: "running",
input: ref.input,
metadata,
time: {
start: ref.start,
},
},
},
},
} as Event)
}
function askPermission(state: State, item: Permit): void {
startTool(state, item.ref)
const id = take(state, "perm", "perm")
state.perms.set(id, {
ref: item.ref,
done: item.done,
})
feed(state, {
type: "permission.asked",
properties: {
id,
sessionID: state.id,
permission: item.permission,
patterns: item.patterns,
metadata: item.metadata ?? {},
always: item.always,
tool: {
messageID: item.ref.msg,
callID: item.ref.call,
},
},
} as Event)
}
function doneTool(
state: State,
ref: Ref,
output: {
title: string
output: string
metadata?: Record<string, unknown>
},
): void {
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
type: "tool",
callID: ref.call,
tool: ref.tool,
state: {
status: "completed",
input: ref.input,
output: output.output,
title: output.title,
metadata: output.metadata ?? {},
time: {
start: ref.start,
end: Date.now(),
},
},
},
},
} as Event)
}
function failTool(state: State, ref: Ref, error: string): void {
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part,
sessionID: state.id,
messageID: ref.msg,
type: "tool",
callID: ref.call,
tool: ref.tool,
state: {
status: "error",
input: ref.input,
error,
metadata: {},
time: {
start: ref.start,
end: Date.now(),
},
},
},
},
} as Event)
}
function emitError(state: State, text: string): void {
const event = {
id: `session.error:${state.id}:${Date.now()}`,
type: "session.error",
properties: {
sessionID: state.id,
error: {
name: "UnknownError",
data: {
message: text,
},
},
},
} satisfies Event
feed(state, event)
}
async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
const ref = make(state, "bash", {
command: "git status",
workdir: process.cwd(),
description: "Show git status",
})
startTool(state, ref)
await wait(70, signal)
doneTool(state, ref, {
title: "git status",
output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
metadata: {
exitCode: 0,
},
})
}
function emitWrite(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "write", {
filePath: file,
content: "export const demo = 42\n",
})
doneTool(state, ref, {
title: "write",
output: "",
metadata: {},
})
}
function emitEdit(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "edit", {
filePath: file,
})
doneTool(state, ref, {
title: "edit",
output: "",
metadata: {
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
},
})
}
function emitPatch(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "apply_patch", {
patchText: "*** Begin Patch\n*** End Patch",
})
doneTool(state, ref, {
title: "apply_patch",
output: "",
metadata: {
files: [
{
type: "update",
filePath: file,
relativePath: "src/demo-format.ts",
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
deletions: 1,
},
{
type: "add",
filePath: path.join(process.cwd(), "README-demo.md"),
relativePath: "README-demo.md",
diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
deletions: 0,
},
],
},
})
}
function emitTask(state: State): void {
const ref = make(state, "task", {
description: "Scan run/* for reducer touchpoints",
subagent_type: "explore",
})
doneTool(state, ref, {
title: "Reducer touchpoints found",
output: "",
metadata: {
toolcalls: 4,
sessionId: "sub_demo_1",
},
})
const part = {
id: "sub_demo_tool_1",
type: "tool",
sessionID: "sub_demo_1",
messageID: "sub_demo_msg_tool",
callID: "sub_demo_call_1",
tool: "read",
state: {
status: "running",
input: {
filePath: "packages/opencode/src/cli/cmd/run/stream.ts",
offset: 1,
limit: 200,
},
time: {
start: Date.now(),
},
},
} satisfies ToolPart
showSubagent(state, {
sessionID: "sub_demo_1",
partID: ref.part,
callID: ref.call,
label: "Explore",
description: "Scan run/* for reducer touchpoints",
status: "completed",
title: "Reducer touchpoints found",
toolCalls: 4,
commits: [
{
kind: "user",
text: "Scan run/* for reducer touchpoints",
phase: "start",
source: "system",
},
{
kind: "reasoning",
text: "Thinking: tracing reducer and footer boundaries",
phase: "progress",
source: "reasoning",
messageID: "sub_demo_msg_reasoning",
partID: "sub_demo_reasoning_1",
},
{
kind: "tool",
text: "running read",
phase: "start",
source: "tool",
messageID: "sub_demo_msg_tool",
partID: "sub_demo_tool_1",
tool: "read",
part,
},
{
kind: "assistant",
text: "Footer updates flow through stream.ts into RunFooter",
phase: "progress",
source: "assistant",
messageID: "sub_demo_msg_text",
partID: "sub_demo_text_1",
},
],
})
}
function emitTodo(state: State): void {
const ref = make(state, "todowrite", {
todos: [
{
content: "Trigger permission UI",
status: "completed",
},
{
content: "Trigger question UI",
status: "in_progress",
},
{
content: "Tune tool formatting",
status: "pending",
},
],
})
doneTool(state, ref, {
title: "todowrite",
output: "",
metadata: {},
})
}
function emitQuestionTool(state: State): void {
const ref = make(state, "question", {
questions: [
{
header: "Style",
question: "Which output style do you want to inspect?",
options: [
{ label: "Diff", description: "Show diff block" },
{ label: "Code", description: "Show code block" },
],
multiple: false,
},
{
header: "Extras",
question: "Pick extra rows",
options: [
{ label: "Usage", description: "Add usage row" },
{ label: "Duration", description: "Add duration row" },
],
multiple: true,
custom: true,
},
],
})
doneTool(state, ref, {
title: "question",
output: "",
metadata: {
answers: [["Diff"], ["Usage", "custom-note"]],
},
})
}
function emitPermission(state: State, kind: PermissionKind = "edit"): void {
const root = process.cwd()
const file = path.join(root, "src", "demo-format.ts")
if (kind === "bash") {
const command = "git status --short"
const ref = make(state, "bash", {
command,
workdir: root,
description: "Inspect worktree changes",
})
askPermission(state, {
ref,
permission: "bash",
patterns: [command],
always: ["*"],
done: {
title: "git status --short",
output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
metadata: {
exitCode: 0,
},
},
})
return
}
if (kind === "read") {
const target = path.join(root, "package.json")
const ref = make(state, "read", {
filePath: target,
offset: 1,
limit: 80,
})
askPermission(state, {
ref,
permission: "read",
patterns: [target],
always: [target],
done: {
title: "read",
output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"),
metadata: {},
},
})
return
}
if (kind === "task") {
const ref = make(state, "task", {
description: "Inspect footer spacing across direct-mode prompts",
subagent_type: "explore",
})
askPermission(state, {
ref,
permission: "task",
patterns: ["explore"],
always: ["*"],
done: {
title: "Footer spacing checked",
output: "",
metadata: {
toolcalls: 3,
sessionId: "sub_demo_perm_1",
},
},
})
return
}
if (kind === "external") {
const dir = path.join(path.dirname(root), "demo-shared")
const target = path.join(dir, "README.md")
const ref = make(state, "read", {
filePath: target,
offset: 1,
limit: 40,
})
askPermission(state, {
ref,
permission: "external_directory",
patterns: [`${dir}/**`],
metadata: {
parentDir: dir,
filepath: target,
},
always: [`${dir}/**`],
done: {
title: "read",
output: `1: # External demo\n2: Shared preview file\nPath: ${target}`,
metadata: {},
},
})
return
}
if (kind === "doom") {
const ref = make(state, "task", {
description: "Retry the formatter after repeated failures",
subagent_type: "general",
})
askPermission(state, {
ref,
permission: "doom_loop",
patterns: ["*"],
always: ["*"],
done: {
title: "Retry allowed",
output: "Continuing after repeated failures.\n",
metadata: {},
},
})
return
}
const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n"
const ref = make(state, "edit", {
filePath: file,
filepath: file,
diff,
})
askPermission(state, {
ref,
permission: "edit",
patterns: [file],
always: [file],
done: {
title: "edit",
output: "",
metadata: {
diff,
},
},
})
}
function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
const questions = (() => {
if (kind === "single") {
return [
{
header: "Mode",
question: "Which footer should be the reference for spacing checks?",
options: [
{ label: "Permission", description: "Inspect the permission footer" },
{ label: "Question", description: "Keep this question footer open" },
{ label: "Prompt", description: "Return to the normal composer" },
],
multiple: false,
custom: false,
},
]
}
if (kind === "checklist") {
return [
{
header: "Checks",
question: "Select the direct-mode cases you want to inspect next",
options: [
{ label: "Diff", description: "Show an edit diff in the footer" },
{ label: "Task", description: "Show a structured task summary" },
{ label: "Todo", description: "Show a todo snapshot" },
{ label: "Error", description: "Show an error transcript row" },
],
multiple: true,
custom: false,
},
]
}
if (kind === "custom") {
return [
{
header: "Reply",
question: "What custom answer should appear in the footer preview?",
options: [
{ label: "Short note", description: "Keep the answer to one line" },
{ label: "Wrapped note", description: "Use a longer answer to test wrapping" },
],
multiple: false,
custom: true,
},
]
}
return [
{
header: "Layout",
question: "Which footer view should stay active while testing?",
options: [
{ label: "Prompt", description: "Return to prompt" },
{ label: "Question", description: "Keep question open" },
],
multiple: false,
},
{
header: "Rows",
question: "Pick formatting previews",
options: [
{ label: "Diff", description: "Emit edit diff" },
{ label: "Task", description: "Emit task card" },
{ label: "Todo", description: "Emit todo card" },
],
multiple: true,
custom: true,
},
]
})()
const ref = make(state, "question", { questions })
startTool(state, ref)
const id = take(state, "ask", "ask")
state.asks.set(id, { ref })
feed(state, {
type: "question.asked",
properties: {
id,
sessionID: state.id,
questions,
tool: {
messageID: ref.msg,
callID: ref.call,
},
},
} as Event)
}
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
if (kind === "text") {
await emitText(state, body || SAMPLE_MARKDOWN, signal)
return true
}
if (kind === "markdown" || kind === "md") {
await emitText(state, body || SAMPLE_MARKDOWN, signal)
return true
}
if (kind === "table") {
await emitText(state, body || SAMPLE_TABLE, signal)
return true
}
if (kind === "reasoning") {
await emitReasoning(state, body || "Planning next steps [REDACTED] while preserving reducer ordering.", signal)
return true
}
if (kind === "bash") {
await emitBash(state, signal)
return true
}
if (kind === "write") {
emitWrite(state)
return true
}
if (kind === "edit") {
emitEdit(state)
return true
}
if (kind === "patch") {
emitPatch(state)
return true
}
if (kind === "task") {
emitTask(state)
return true
}
if (kind === "todo") {
emitTodo(state)
return true
}
if (kind === "question") {
emitQuestionTool(state)
return true
}
if (kind === "error") {
emitError(state, body || "demo error event")
return true
}
if (kind === "mix") {
await emitText(state, SAMPLE_MARKDOWN, signal)
await wait(50, signal)
await emitReasoning(state, "Thinking through formatter edge cases [REDACTED].", signal)
await wait(50, signal)
await emitBash(state, signal)
emitWrite(state)
emitEdit(state)
emitPatch(state)
emitTask(state)
emitTodo(state)
emitQuestionTool(state)
emitError(state, "demo mixed scenario error")
return true
}
return false
}
function intro(state: State): void {
note(
state.footer,
[
"Demo slash commands enabled for interactive mode.",
`- /permission [kind] (${PERMISSIONS.join(", ")})`,
`- /question [kind] (${QUESTIONS.join(", ")})`,
`- /fmt <kind> (${KINDS.join(", ")})`,
"Examples:",
"- /permission bash",
"- /question custom",
"- /fmt markdown",
"- /fmt table",
"- /fmt text your custom text",
].join("\n"),
)
}
export function createRunDemo(input: Input) {
const state: State = {
id: input.sessionID,
thinking: input.thinking,
data: createSessionData(),
footer: input.footer,
limits: input.limits,
msg: 0,
part: 0,
call: 0,
perm: 0,
ask: 0,
perms: new Map(),
asks: new Map(),
}
const start = async (): Promise<void> => {
intro(state)
}
const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise<boolean> => {
const text = line.text.trim()
const list = text.split(/\s+/)
const cmd = list[0] || ""
clearSubagent(state.footer)
if (cmd === "/help") {
intro(state)
return true
}
if (cmd === "/permission") {
const kind = permissionKind(list[1])
if (!kind) {
note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`)
return true
}
emitPermission(state, kind)
return true
}
if (cmd === "/question") {
const kind = questionKind(list[1])
if (!kind) {
note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`)
return true
}
emitQuestion(state, kind)
return true
}
if (cmd === "/fmt") {
const kind = (list[1] || "").toLowerCase()
const body = list.slice(2).join(" ")
if (!kind) {
note(state.footer, `Pick a kind: ${KINDS.join(", ")}`)
return true
}
const ok = await emitFmt(state, kind, body, signal)
if (ok) {
return true
}
note(state.footer, `Unknown kind "${kind}". Use: ${KINDS.join(", ")}`)
return true
}
return false
}
const permission = (input: PermissionReply): boolean => {
const item = state.perms.get(input.requestID)
if (!item || !input.reply) {
return false
}
state.perms.delete(input.requestID)
const event = {
id: `permission.replied:${input.requestID}:${Date.now()}`,
type: "permission.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
reply: input.reply,
},
} satisfies Event
feed(state, event)
if (input.reply === "reject") {
failTool(state, item.ref, input.message || "permission rejected")
return true
}
doneTool(state, item.ref, item.done)
return true
}
const questionReply = (input: QuestionReply): boolean => {
const ask = state.asks.get(input.requestID)
if (!ask || !input.answers) {
return false
}
state.asks.delete(input.requestID)
const event = {
id: `question.replied:${input.requestID}:${Date.now()}`,
type: "question.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
answers: input.answers,
},
} satisfies Event
feed(state, event)
doneTool(state, ask.ref, {
title: "question",
output: "",
metadata: {
answers: input.answers,
},
})
return true
}
const questionReject = (input: QuestionReject): boolean => {
const ask = state.asks.get(input.requestID)
if (!ask) {
return false
}
state.asks.delete(input.requestID)
feed(state, {
type: "question.rejected",
properties: {
sessionID: state.id,
requestID: input.requestID,
},
} as Event)
failTool(state, ask.ref, "question rejected")
return true
}
return {
start,
prompt,
permission,
questionReply,
questionReject,
}
}
@@ -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,643 @@
/** @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 type { RunFooterTheme } from "./theme"
import type { 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[]>
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",
description: "Choose model for future turns",
},
{
action: "variant.cycle",
category: "Suggested",
display: "Variant cycle",
description: "Cycle reasoning effort for future turns",
keywords: "variant cycle",
},
...(props.variants().length > 0
? [
{
action: "variant.list" as const,
category: "Suggested",
display: "Switch model variant",
description: "Choose reasoning effort for future turns",
keywords: `variant variants ${props.variants().join(" ")}`,
},
]
: []),
{
action: "slash",
category: "Session",
name: "new",
display: "New session",
description: "Start a new session",
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,
description: item.description,
keywords: item.source === "mcp" ? `/${item.name} ${item.name} mcp` : `/${item.name} ${item.name}`,
}) satisfies CommandEntry,
)
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)),
{ action: "exit", category: "System", display: "Exit", description: "Close direct mode", 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
return {
providerID: provider.id,
modelID,
providerName: provider.name,
category: provider.name,
display: title,
description: current
? "current"
: model.cost?.input === 0 && provider.id === "opencode"
? "Free"
: title !== modelID
? modelID
: undefined,
keywords: `${provider.id} ${provider.name} ${modelID} ${title}`,
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,282 @@
/** @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
}
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}
>
<text fg={active() ? props.theme().surface : props.theme().text} wrapMode="none" truncate>
{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>
</box>
</box>
</box>
)
})
)}
</box>
)
}
@@ -0,0 +1,486 @@
// 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 { 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"
type RejectArea = {
isDestroyed: boolean
plainText: string
cursorOffset: number
setText(text: string): void
focus(): void
}
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} paddingBottom={1}>
<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: RejectArea | 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}
paddingBottom={1}
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 as RejectArea
}}
/>
)
}
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} paddingBottom={1}>
<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} paddingBottom={1}>
<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>
)
}
@@ -0,0 +1,1109 @@
// Prompt textarea component and its state machine for direct interactive mode.
//
// createPromptState() wires keybinds, history navigation, leader-key sequences,
// and `@` autocomplete for files, subagents, and MCP resources.
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
// while the footer view renders the current menu state below it.
/** @jsxImportSource @opentui/solid */
import { pathToFileURL } from "bun"
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import * as Locale from "@/util/locale"
import {
createPromptHistory,
isExitCommand,
isNewCommand,
movePromptHistory,
promptCycle,
promptHit,
promptInfo,
promptKeys,
pushPromptHistory,
} from "./prompt.shared"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterKeybinds, FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource } from "./types"
const LEADER_TIMEOUT_MS = 2000
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
export const TEXTAREA_MIN_ROWS = 1
export const TEXTAREA_MAX_ROWS = 6
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
export const HINT_BREAKPOINTS = {
send: 50,
newline: 66,
history: 80,
command: 95,
}
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
type Auto = RunFooterMenuItem & {
kind: "mention"
value: string
part: Mention
directory?: boolean
}
type SlashOption = RunFooterMenuItem & {
kind: "slash"
name: string
}
type PromptOption = Auto | SlashOption
type MenuMode = false | "mention" | "slash"
type PromptInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: Accessor<RunAgent[]>
resources: Accessor<RunResource[]>
commands: Accessor<RunCommand[] | undefined>
keybinds: FooterKeybinds
state: Accessor<FooterState>
view: Accessor<string>
prompt: Accessor<boolean>
width: Accessor<number>
theme: Accessor<RunFooterTheme>
history?: RunPrompt[]
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
onInputClear: () => void
onExitRequest?: () => boolean
onExit: () => void
onRows: (rows: number) => void
onStatus: (text: string) => void
}
export type PromptState = {
placeholder: Accessor<StyledText | string>
bindings: Accessor<KeyBinding[]>
visible: Accessor<boolean>
options: Accessor<PromptOption[]>
selected: Accessor<number>
offset: Accessor<number>
rows: Accessor<number>
requestExit: () => boolean
onSubmit: () => void
submitText: (text: string) => void
onKeyDown: (event: KeyEvent) => void
onContentChange: () => void
replaceDraft: (text: string) => void
bind: (area?: TextareaRenderable) => void
}
function clamp(rows: number): number {
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
}
function clonePrompt(prompt: RunPrompt): RunPrompt {
return {
text: prompt.text,
parts: structuredClone(prompt.parts),
}
}
function removeLineRange(input: string) {
const hash = input.lastIndexOf("#")
return hash === -1 ? input : input.slice(0, hash)
}
function extractLineRange(input: string) {
const hash = input.lastIndexOf("#")
if (hash === -1) {
return { base: input }
}
const base = input.slice(0, hash)
const line = input.slice(hash + 1)
const match = line.match(/^(\d+)(?:-(\d*))?$/)
if (!match) {
return { base }
}
const start = Number(match[1])
const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined
return { base, line: { start, end } }
}
function slashHead(text: string) {
if (!text.startsWith("/")) {
return
}
for (let i = 1; i < text.length; i++) {
switch (text[i]) {
case " ":
case "\t":
case "\n":
return { name: text.slice(1, i), arguments: text.slice(i + 1), end: i }
}
}
return { name: text.slice(1), arguments: "", end: text.length }
}
function slashQuery(text: string, cursor: number) {
const head = slashHead(text.slice(0, cursor))
if (!head || head.end !== cursor) {
return
}
return head.name
}
function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
const head = slashHead(text)
if (!head || head.name.length === 0) {
return { type: "none" as const }
}
if (!commands) {
return { type: "pending" as const }
}
if (!commands.some((item) => item.name === head.name)) {
return { type: "none" as const }
}
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
}
export function hintFlags(width: number) {
return {
send: width >= HINT_BREAKPOINTS.send,
newline: width >= HINT_BREAKPOINTS.newline,
history: width >= HINT_BREAKPOINTS.history,
command: width >= HINT_BREAKPOINTS.command,
}
}
export function RunPromptBody(props: {
theme: () => RunFooterTheme
placeholder: () => StyledText | string
bindings: () => KeyBinding[]
onSubmit: () => void
onKeyDown: (event: KeyEvent) => void
onContentChange: () => void
bind: (area?: TextareaRenderable) => void
}) {
let area: TextareaRenderable | undefined
onMount(() => {
props.bind(area)
})
onCleanup(() => {
props.bind(undefined)
})
return (
<box id="run-direct-footer-prompt" width="100%">
<box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
<textarea
id="run-direct-footer-composer"
width="100%"
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS}
wrapMode="word"
placeholder={props.placeholder()}
placeholderColor={props.theme().muted}
textColor={props.theme().text}
focusedTextColor={props.theme().text}
backgroundColor={props.theme().surface}
focusedBackgroundColor={props.theme().surface}
cursorColor={props.theme().text}
keyBindings={props.bindings()}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onContentChange={props.onContentChange}
ref={(next) => {
area = next
}}
/>
</box>
</box>
)
}
export function createPromptState(input: PromptInput): PromptState {
const keys = createMemo(() => promptKeys(input.keybinds))
const bindings = createMemo(() => keys().bindings)
const placeholder = createMemo(() => {
if (!input.state().first) {
return ""
}
return new StyledText([
bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
])
})
let history = createPromptHistory(input.history)
let draft: RunPrompt = { text: "", parts: [] }
let stash: RunPrompt = { text: "", parts: [] }
let area: TextareaRenderable | undefined
let leader = false
let timeout: NodeJS.Timeout | undefined
let tick = false
let prev = input.view()
let type = 0
let parts: Mention[] = []
let marks = new Map<number, number>()
const [mode, setMode] = createSignal<MenuMode>(false)
const [at, setAt] = createSignal(0)
const [query, setQuery] = createSignal("")
const visible = createMemo(() => mode() !== false)
const width = createMemo(() => Math.max(20, input.width() - 8))
const agents = createMemo<Auto[]>(() => {
return input
.agents()
.filter((item) => !item.hidden && item.mode !== "primary")
.map((item) => ({
kind: "mention",
display: "@" + item.name,
value: item.name,
part: {
type: "agent",
name: item.name,
source: {
start: 0,
end: 0,
value: "",
},
},
}))
})
const resources = createMemo<Auto[]>(() => {
return input.resources().map((item) => ({
kind: "mention",
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
value: item.name,
description: item.description,
part: {
type: "file",
mime: item.mimeType ?? "text/plain",
filename: item.name,
url: item.uri,
source: {
type: "resource",
clientName: item.client,
uri: item.uri,
text: {
start: 0,
end: 0,
value: "",
},
},
},
}))
})
const [files] = createResource(
query,
async (value) => {
if (!visible() || mode() !== "mention") {
return []
}
const next = extractLineRange(value)
const list = await input.findFiles(next.base)
return list
.sort((a, b) => {
const dir = Number(b.endsWith("/")) - Number(a.endsWith("/"))
if (dir !== 0) {
return dir
}
const depth = a.split("/").length - b.split("/").length
if (depth !== 0) {
return depth
}
return a.localeCompare(b)
})
.map((item): Auto => {
const url = pathToFileURL(path.resolve(input.directory, item))
let filename = item
if (next.line && !item.endsWith("/")) {
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
url.searchParams.set("start", String(next.line.start))
if (next.line.end !== undefined) {
url.searchParams.set("end", String(next.line.end))
}
}
return {
kind: "mention",
display: Locale.truncateMiddle("@" + filename, width()),
value: filename,
directory: item.endsWith("/"),
part: {
type: "file",
mime: item.endsWith("/") ? "application/x-directory" : "text/plain",
filename,
url: url.href,
source: {
type: "file",
path: item,
text: {
start: 0,
end: 0,
value: "",
},
},
},
}
})
},
{ initialValue: [] as Auto[] },
)
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
const slashOptions = createMemo<SlashOption[]>(() => {
const builtins = [
{ kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close direct mode" } satisfies SlashOption,
]
const hidden = new Set(builtins.map((item) => item.name))
return [
...(input.commands() ?? [])
.filter((item) => item.source !== "skill" && !hidden.has(item.name))
.map(
(item) =>
({
kind: "slash",
name: item.name,
display: `/${item.name}${item.source === "mcp" ? ":mcp" : ""}`,
description: item.description,
}) satisfies SlashOption,
),
...builtins,
].sort((a, b) => a.display.localeCompare(b.display))
})
const options = createMemo<PromptOption[]>(() => {
const mixed: PromptOption[] = mode() === "slash" ? slashOptions() : mentionOptions()
if (!query()) {
return mixed
}
return fuzzysort
.go(removeLineRange(query()), mixed, {
keys: [(item) => (item.kind === "mention" ? item.value : item.name).trimEnd(), "display", "description"],
})
.map((item) => item.obj)
})
const menu = createFooterMenuState({ count: () => options().length, limit: AUTOCOMPLETE_ROWS })
const popup = createMemo(() => {
return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0
})
const clear = () => {
leader = false
if (!timeout) {
return
}
clearTimeout(timeout)
timeout = undefined
}
const arm = () => {
clear()
leader = true
timeout = setTimeout(() => {
clear()
}, LEADER_TIMEOUT_MS)
}
const hide = () => {
setMode(false)
setQuery("")
menu.reset()
}
const syncRows = () => {
if (!area || area.isDestroyed) {
return
}
input.onRows(clamp(area.virtualLineCount || 1) + popup())
}
const scheduleRows = () => {
if (tick) {
return
}
tick = true
queueMicrotask(() => {
tick = false
syncRows()
})
}
const syncParts = () => {
if (!area || area.isDestroyed || type === 0) {
return
}
const next: Mention[] = []
const map = new Map<number, number>()
for (const item of area.extmarks.getAllForTypeId(type)) {
const idx = marks.get(item.id)
if (idx === undefined) {
continue
}
const part = parts[idx]
if (!part) {
continue
}
const text = area.plainText.slice(item.start, item.end)
const prev =
part.type === "agent"
? (part.source?.value ?? "@" + part.name)
: (part.source?.text.value ?? "@" + (part.filename ?? ""))
if (text !== prev) {
continue
}
const copy = structuredClone(part)
if (copy.type === "agent") {
copy.source = {
start: item.start,
end: item.end,
value: text,
}
}
if (copy.type === "file" && copy.source?.text) {
copy.source.text.start = item.start
copy.source.text.end = item.end
copy.source.text.value = text
}
map.set(item.id, next.length)
next.push(copy)
}
const stale = map.size !== marks.size
parts = next
marks = map
if (stale) {
restoreParts(next)
}
}
const clearParts = () => {
if (area && !area.isDestroyed) {
area.extmarks.clear()
}
parts = []
marks = new Map()
}
const restoreParts = (value: RunPromptPart[]) => {
clearParts()
parts = value
.filter((item): item is Mention => item.type === "file" || item.type === "agent")
.map((item) => structuredClone(item))
if (!area || area.isDestroyed || type === 0) {
return
}
const box = area
parts.forEach((item, idx) => {
const start = item.type === "agent" ? item.source?.start : item.source?.text.start
const end = item.type === "agent" ? item.source?.end : item.source?.text.end
if (start === undefined || end === undefined) {
return
}
const id = box.extmarks.create({
start,
end,
virtual: true,
typeId: type,
})
marks.set(id, idx)
})
}
const restore = (value: RunPrompt, cursor = value.text.length) => {
draft = clonePrompt(value)
if (!area || area.isDestroyed) {
return
}
hide()
area.setText(value.text)
restoreParts(value.parts)
area.cursorOffset = Math.min(cursor, area.plainText.length)
scheduleRows()
area.focus()
}
const resetDraft = () => {
if (area && !area.isDestroyed) {
area.setText("")
}
clearParts()
hide()
draft = { text: "", parts: [] }
if (!area || area.isDestroyed) {
return
}
scheduleRows()
area.focus()
}
const replaceDraft = (text: string) => {
draft = { text, parts: [] }
if (!area || area.isDestroyed) {
return
}
hide()
area.setText(text)
clearParts()
draft = { text: area.plainText, parts: [] }
area.cursorOffset = Math.min(text.length, area.plainText.length)
scheduleRows()
area.focus()
}
const refresh = () => {
if (!area || area.isDestroyed) {
return
}
const cursor = area.cursorOffset
const text = area.plainText
const slash = slashQuery(text, cursor)
if (mode() === "slash") {
if (slash === undefined) {
hide()
return
}
setAt(0)
setQuery(slash)
return
}
if (slash !== undefined) {
setAt(0)
menu.reset()
setMode("slash")
setQuery(slash)
return
}
if (visible() && mode() === "mention") {
if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) {
hide()
return
}
setQuery(text.slice(at() + 1, cursor))
return
}
if (cursor === 0) {
return
}
const head = text.slice(0, cursor)
const idx = head.lastIndexOf("@")
if (idx === -1) {
return
}
const before = idx === 0 ? undefined : head[idx - 1]
const tail = head.slice(idx)
if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) {
setAt(idx)
menu.reset()
setMode("mention")
setQuery(head.slice(idx + 1))
}
}
const bind = (next?: TextareaRenderable) => {
if (area === next) {
return
}
if (area && !area.isDestroyed) {
area.off("line-info-change", scheduleRows)
}
area = next
if (!area || area.isDestroyed) {
return
}
if (type === 0) {
type = area.extmarks.registerType("run-direct-prompt-part")
}
area.on("line-info-change", scheduleRows)
queueMicrotask(() => {
if (!area || area.isDestroyed || !input.prompt()) {
return
}
restore(draft)
refresh()
})
}
const syncDraft = () => {
if (!area || area.isDestroyed) {
return
}
syncParts()
draft = {
text: area.plainText,
parts: structuredClone(parts),
}
}
const push = (value: RunPrompt) => {
history = pushPromptHistory(history, value)
}
const move = (dir: -1 | 1, event: KeyEvent) => {
if (!area || area.isDestroyed) {
return
}
if (history.index === null && dir === -1) {
stash = clonePrompt(draft)
}
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
if (!next.apply || next.text === undefined || next.cursor === undefined) {
return
}
history = next.state
const value =
next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
restore(value, next.cursor)
event.preventDefault()
}
const cycle = (event: KeyEvent): boolean => {
const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
if (!next.consume) {
return false
}
if (next.clear) {
clear()
}
if (next.arm) {
arm()
}
if (next.cycle) {
input.onCycle()
}
event.preventDefault()
return true
}
const requestExit = () => {
const text = area && !area.isDestroyed ? area.plainText : draft.text
if (input.prompt() && text.length > 0) {
input.onInputClear()
resetDraft()
return true
}
return input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
}
const cancelAutocomplete = () => {
if (!area || area.isDestroyed) {
return
}
const cursor = area.cursorOffset
const startOffset = mode() === "slash" ? 0 : at()
area.cursorOffset = startOffset
const start = area.logicalCursor
area.cursorOffset = cursor
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.cursorOffset = startOffset
hide()
syncDraft()
scheduleRows()
area.focus()
}
const select = (item?: PromptOption) => {
const next = item ?? options()[menu.selected()]
if (!next || !area || area.isDestroyed) {
return
}
if (next.kind === "slash") {
const text = `/${next.name} `
const cursor = area.cursorOffset
area.cursorOffset = 0
const start = area.logicalCursor
area.cursorOffset = cursor
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.insertText(text)
area.cursorOffset = Bun.stringWidth(text)
hide()
syncDraft()
scheduleRows()
area.focus()
return
}
const cursor = area.cursorOffset
const tail = area.plainText.at(cursor)
const append = "@" + next.value + (tail === " " ? "" : " ")
area.cursorOffset = at()
const start = area.logicalCursor
area.cursorOffset = cursor
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.insertText(append)
const text = "@" + next.value
const startOffset = at()
const endOffset = startOffset + Bun.stringWidth(text)
const part = structuredClone(next.part)
if (part.type === "agent") {
part.source = {
start: startOffset,
end: endOffset,
value: text,
}
}
if (part.type === "file" && part.source?.text) {
part.source.text.start = startOffset
part.source.text.end = endOffset
part.source.text.value = text
}
if (part.type === "file") {
const prev = parts.findIndex((item) => item.type === "file" && item.url === part.url)
if (prev !== -1) {
const mark = [...marks.entries()].find((item) => item[1] === prev)?.[0]
if (mark !== undefined) {
area.extmarks.delete(mark)
}
parts = parts.filter((_, idx) => idx !== prev)
marks = new Map(
[...marks.entries()]
.filter((item) => item[0] !== mark)
.map((item) => [item[0], item[1] > prev ? item[1] - 1 : item[1]]),
)
}
}
const id = area.extmarks.create({
start: startOffset,
end: endOffset,
virtual: true,
typeId: type,
})
marks.set(id, parts.length)
parts.push(part)
hide()
syncDraft()
scheduleRows()
area.focus()
}
const expand = () => {
const next = options()[menu.selected()]
if (!next || next.kind !== "mention" || !next.directory || !area || area.isDestroyed) {
return
}
const cursor = area.cursorOffset
area.cursorOffset = at()
const start = area.logicalCursor
area.cursorOffset = cursor
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.insertText("@" + next.value)
syncDraft()
refresh()
}
const onKeyDown = (event: KeyEvent) => {
const key = promptInfo(event)
if (visible()) {
const name = event.name.toLowerCase()
const ctrl = event.ctrl && !event.meta && !event.shift
if (name === "up" || (ctrl && name === "p")) {
event.preventDefault()
if (options().length > 0) {
menu.move(-1)
}
return
}
if (name === "down" || (ctrl && name === "n")) {
event.preventDefault()
if (options().length > 0) {
menu.move(1)
}
return
}
if (name === "escape") {
event.preventDefault()
cancelAutocomplete()
return
}
if (name === "return") {
if (mode() === "slash" && options().length === 0) {
hide()
return
}
event.preventDefault()
select()
return
}
if (name === "tab") {
if (mode() === "slash" && options().length === 0) {
hide()
return
}
event.preventDefault()
const item = options()[menu.selected()]
if (item?.kind === "mention" && item.directory) {
expand()
return
}
select()
return
}
}
if (promptHit(keys().clear, key)) {
const handled = requestExit()
if (handled) {
event.preventDefault()
}
return
}
if (promptHit(keys().interrupts, key)) {
if (input.onInterrupt()) {
event.preventDefault()
return
}
}
if (cycle(event)) {
return
}
const up = promptHit(keys().previous, key)
const down = promptHit(keys().next, key)
if (!up && !down) {
return
}
if (!area || area.isDestroyed) {
return
}
const dir = up ? -1 : 1
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
move(dir, event)
return
}
if (dir === -1 && area.visualCursor.visualRow === 0) {
area.cursorOffset = 0
}
const end =
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
? area.height - 1
: Math.max(0, (area.virtualLineCount ?? 1) - 1)
if (dir === 1 && area.visualCursor.visualRow === end) {
area.cursorOffset = area.plainText.length
}
}
useKeyboard((event) => {
if (input.prompt()) {
return
}
if (input.view() === "command" || input.view() === "model" || input.view() === "variant") {
return
}
if (promptHit(keys().clear, promptInfo(event))) {
const handled = requestExit()
if (handled) {
event.preventDefault()
}
}
})
const submitPrompt = (next: RunPrompt) => {
if (!area || area.isDestroyed) {
draft = clonePrompt(next)
}
if (visible()) {
if (mode() !== "slash" || options().length > 0) {
select()
return
}
hide()
}
if (!next.text.trim()) {
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
return
}
if (isExitCommand(next.text)) {
input.onExit()
return
}
const parsed = isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
if (parsed?.type === "pending") {
input.onStatus("loading commands")
return
}
const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next
resetDraft()
queueMicrotask(async () => {
if (await input.onSubmit(submit)) {
push(next)
return
}
restore(next)
})
}
const onSubmit = () => {
syncDraft()
submitPrompt(clonePrompt(draft))
}
const submitText = (text: string) => {
submitPrompt({ text, parts: [] })
}
onCleanup(() => {
clear()
if (area && !area.isDestroyed) {
area.off("line-info-change", scheduleRows)
}
})
createEffect(() => {
input.width()
popup()
if (input.prompt()) {
scheduleRows()
}
})
createEffect(() => {
query()
menu.reset()
})
createEffect(() => {
input.state().phase
if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
return
}
queueMicrotask(() => {
if (!area || area.isDestroyed) {
return
}
area.focus()
})
})
createEffect(() => {
const kind = input.view()
if (kind === prev) {
return
}
if (prev === "prompt") {
syncDraft()
}
clear()
hide()
prev = kind
if (kind !== "prompt") {
return
}
queueMicrotask(() => {
restore(draft)
})
})
return {
placeholder,
bindings,
visible,
options,
selected: menu.selected,
offset: menu.offset,
rows: menu.rows,
requestExit,
onSubmit,
submitText,
onKeyDown,
onContentChange: () => {
syncDraft()
refresh()
scheduleRows()
},
replaceDraft,
bind,
}
}
@@ -0,0 +1,591 @@
// 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 { 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"
type Area = {
isDestroyed: boolean
plainText: string
cursorOffset: number
setText(text: string): void
focus(): void
}
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: Area | 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}
marginBottom={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 as Area
}}
/>
</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}
paddingBottom={1}
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 * as Keybind from "@/util/keybind"
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, 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(() => Keybind.parse(props.keybinds.commandList))
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}
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>
)
}
+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,277 @@
// 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 * as Keybind from "@/util/keybind"
import type { FooterKeybinds, RunPrompt } from "./types"
const HISTORY_LIMIT = 200
export type PromptHistoryState = {
items: RunPrompt[]
index: number | null
draft: string
}
export type PromptKeys = {
leaders: Keybind.Info[]
cycles: Keybind.Info[]
interrupts: Keybind.Info[]
previous: Keybind.Info[]
next: Keybind.Info[]
clear: Keybind.Info[]
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 mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
return Keybind.parse(binding).map((item) => ({
name: item.name,
ctrl: item.ctrl || undefined,
meta: item.meta || undefined,
shift: item.shift || undefined,
super: item.super || undefined,
action,
}))
}
function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
...mapInputBindings(keybinds.inputSubmit, "submit"),
...mapInputBindings(keybinds.inputNewline, "newline"),
]
}
export function promptKeys(keybinds: FooterKeybinds): PromptKeys {
return {
leaders: Keybind.parse(keybinds.leader),
cycles: Keybind.parse(keybinds.variantCycle),
interrupts: Keybind.parse(keybinds.interrupt),
previous: Keybind.parse(keybinds.historyPrevious),
next: Keybind.parse(keybinds.historyNext),
clear: Keybind.parse(keybinds.inputClear),
bindings: textareaBindings(keybinds),
}
}
export function printableBinding(binding: string, leader: string): string {
const first = Keybind.parse(binding).at(0)
if (!first) {
return ""
}
let text = Keybind.toString(first)
const lead = Keybind.parse(leader).at(0)
if (lead) {
text = text.replace("<leader>", Keybind.toString(lead))
}
return text.replace(/escape/g, "esc")
}
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 promptInfo(event: {
name: string
ctrl?: boolean
meta?: boolean
shift?: boolean
super?: boolean
}): Keybind.Info {
return {
name: event.name === " " ? "space" : event.name,
ctrl: !!event.ctrl,
meta: !!event.meta,
shift: !!event.shift,
super: !!event.super,
leader: false,
}
}
export function promptHit(bindings: Keybind.Info[], event: Keybind.Info): boolean {
return bindings.some((item) => Keybind.match(item, event))
}
export function promptCycle(
armed: boolean,
event: Keybind.Info,
leaders: Keybind.Info[],
cycles: Keybind.Info[],
): 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,228 @@
// 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",
commandList: "ctrl+p",
variantCycle: "ctrl+t,<leader>t",
interrupt: "escape",
historyPrevious: "up",
historyNext: "down",
inputClear: "ctrl+c",
inputSubmit: "return",
inputNewline: "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 {
const leader = config?.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader
const command = config?.keybinds?.command_list?.trim() || DEFAULT_KEYBINDS.commandList
const cycle = config?.keybinds?.variant_cycle?.trim() || "ctrl+t"
const interrupt = config?.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt
const previous = config?.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious
const next = config?.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext
const clear = config?.keybinds?.input_clear?.trim() || DEFAULT_KEYBINDS.inputClear
const submit = config?.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit
const newline = config?.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline
const bindings = cycle
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
if (!bindings.some((binding) => binding.toLowerCase() === "<leader>t")) {
bindings.push("<leader>t")
}
return {
leader,
commandList: command,
variantCycle: bindings.join(","),
interrupt,
historyPrevious: previous,
historyNext: next,
inputClear: clear,
inputSubmit: submit,
inputNewline: 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.
// Always ensures <leader>t is present in the variant cycle binding.
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,360 @@
import { createScrollbackWriter } from "@opentui/solid"
import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core"
import { 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
})
const text_body = text()
if (text_body) {
return (
<text width="100%" wrapMode="word" fg={style().fg} attributes={style().attrs}>
{text_body.content}
</text>
)
}
const code_body = code()
if (code_body) {
return (
<code
width="100%"
wrapMode="word"
filetype={code_body.filetype}
drawUnstyledText={false}
streaming={streaming()}
syntaxStyle={syntax()}
content={code_body.content}
fg={color()}
/>
)
}
const code_snap = code_snapshot()
if (code_snap) {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{code_snap.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_snap.file)}
streaming={false}
syntaxStyle={syntax()}
content={code_snap.content}
fg={theme().block.text}
/>
</line_number>
</box>
</box>
)
}
const diff_snap = diff_snapshot()
if (diff_snap) {
return (
<box width="100%" flexDirection="column" gap={1}>
{diff_snap.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>
)
}
const task_snap = task_snapshot()
if (task_snap) {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{task_snap.title}
</text>
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
{task_snap.rows.map((row) => (
<text width="100%" wrapMode="word" fg={theme().block.text}>
{row}
</text>
))}
{task_snap.tail ? (
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{task_snap.tail}
</text>
) : null}
</box>
</box>
)
}
const todo_snap = todo_snapshot()
if (todo_snap) {
return (
<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_snap.items.map((item) => (
<text width="100%" wrapMode="word" fg={todoColor(theme(), item.status)}>
{todoText(item)}
</text>
))}
{todo_snap.tail ? (
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{todo_snap.tail}
</text>
) : null}
</box>
</box>
)
}
const question_snap = question_snapshot()
if (question_snap) {
return (
<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_snap.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_snap.tail ? (
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{question_snap.tail}
</text>
) : null}
</box>
</box>
)
}
const markdown_body = markdown()
if (markdown_body) {
return (
<markdown
width="100%"
syntaxStyle={syntax()}
streaming={streaming()}
content={markdown_body.content}
fg={color()}
tableOptions={{ widthMode: "content" }}
/>
)
}
return null
}
export function entryWriter(input: {
commit: StreamCommit
body?: RunEntryBody
theme?: RunTheme
opts?: ScrollbackOptions
}): ScrollbackWriter {
return createScrollbackWriter(
// @ts-expect-error OpenTUI JSX element is valid for 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)
}
@@ -0,0 +1,1008 @@
// SDK event subscription and prompt turn coordination.
//
// Creates a long-lived event stream subscription and feeds every event
// through the session-data reducer. The reducer produces scrollback commits
// and footer patches, which get forwarded to the footer through stream.ts.
//
// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the
// SDK, arms a deferred Wait, and resolves when the session becomes idle.
// Prefer session.status idle events, but also poll session.status because some
// transports can miss status events while still delivering message events. If
// the turn is aborted (user interrupt), it flushes any in-progress parts as
// interrupted entries.
//
// The tick counter prevents stale idle events from resolving the wrong turn.
// We also re-check live session status before resolving an idle event so a
// delayed idle from an older turn cannot complete a newer busy turn.
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
import { makeRuntime } from "@/effect/run-service"
import {
blockerStatus,
bootstrapSessionData,
createSessionData,
flushInterrupted,
pickBlockerView,
reduceSessionData,
type SessionData,
} from "./session-data"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
clearFinishedSubagents,
createSubagentData,
listSubagentPermissions,
listSubagentQuestions,
listSubagentTabs,
reduceSubagentData,
sameSubagentTab,
snapshotSelectedSubagentData,
SUBAGENT_BOOTSTRAP_LIMIT,
SUBAGENT_CALL_BOOTSTRAP_LIMIT,
type SubagentData,
} from "./subagent-data"
import { traceFooterOutput, writeSessionOutput } from "./stream"
import type {
FooterApi,
FooterOutput,
FooterPatch,
FooterSubagentState,
FooterSubagentTab,
FooterView,
RunFilePart,
RunInput,
RunPrompt,
RunPromptPart,
StreamCommit,
} from "./types"
type Trace = {
write(type: string, data?: unknown): void
}
type StreamInput = {
sdk: OpencodeClient
sessionID: string
thinking: boolean
limits: () => Record<string, number>
footer: FooterApi
trace?: Trace
signal?: AbortSignal
}
type Wait = {
tick: number
armed: boolean
live: boolean
done: Deferred.Deferred<void, unknown>
}
export type SessionTurnInput = {
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
prompt: RunPrompt
files: RunFilePart[]
includeFiles: boolean
signal?: AbortSignal
}
export type SessionTransport = {
runPromptTurn(input: SessionTurnInput): Promise<void>
selectSubagent(sessionID: string | undefined): void
close(): Promise<void>
}
type State = {
data: SessionData
subagent: SubagentData
wait?: Wait
tick: number
fault?: unknown
footerView: FooterView
blockerTick: number
selectedSubagent?: string
blockers: Map<string, number>
}
type TransportService = {
readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect<void, unknown>
readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect<void>
readonly close: () => Effect.Effect<void>
}
class Service extends Context.Service<Service, TransportService>()("@opencode/RunStreamTransport") {}
function sid(event: Event): string | undefined {
if (event.type === "message.updated") {
return event.properties.sessionID
}
if (event.type === "message.part.delta") {
return event.properties.sessionID
}
if (event.type === "message.part.updated") {
return event.properties.part.sessionID
}
if (
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"
) {
return event.properties.sessionID
}
return undefined
}
function isEvent(value: unknown): value is Event {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false
}
const type = Reflect.get(value, "type")
const properties = Reflect.get(value, "properties")
return typeof type === "string" && !!properties && typeof properties === "object"
}
function active(event: Event, sessionID: string): boolean {
if (sid(event) !== sessionID) {
return false
}
if (event.type === "message.updated") {
return event.properties.info.role === "assistant"
}
if (event.type === "message.part.delta" || event.type === "message.part.updated") {
return false
}
if (event.type !== "session.status") {
return true
}
return event.properties.status.type !== "idle"
}
// Races the turn's deferred completion against an abort signal.
function waitTurn(done: Wait["done"], signal: AbortSignal) {
return Effect.raceAll([
Deferred.await(done).pipe(Effect.as("idle" as const), Effect.exit),
Effect.callback<"abort">((resume) => {
if (signal.aborted) {
resume(Effect.succeed("abort"))
return Effect.void
}
const onAbort = () => {
signal.removeEventListener("abort", onAbort)
resume(Effect.succeed("abort"))
}
signal.addEventListener("abort", onAbort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
}).pipe(Effect.exit),
]).pipe(
Effect.flatMap((exit) => (Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.succeed(exit.value))),
)
}
export function formatUnknownError(error: unknown): string {
if (typeof error === "string") {
return error
}
if (error instanceof Error) {
return error.message || error.name
}
if (error && typeof error === "object") {
const value = error as { message?: unknown; name?: unknown }
if (typeof value.message === "string" && value.message.trim()) {
return value.message
}
if (typeof value.name === "string" && value.name.trim()) {
return value.name
}
}
return "unknown error"
}
function sameView(a: FooterView, b: FooterView) {
if (a.type !== b.type) {
return false
}
if (a.type === "prompt" && b.type === "prompt") {
return true
}
if (a.type === "prompt" || b.type === "prompt") {
return false
}
return a.request === b.request
}
function blockerOrder(order: Map<string, number>, id: string) {
return order.get(id) ?? Number.MAX_SAFE_INTEGER
}
function firstByOrder<T extends { id: string }>(left: T[], right: T[], order: Map<string, number>) {
return [...left, ...right].sort((a, b) => {
const next = blockerOrder(order, a.id) - blockerOrder(order, b.id)
if (next !== 0) {
return next
}
return a.id.localeCompare(b.id)
})[0]
}
function pickView(data: SessionData, subagent: SubagentData, order: Map<string, number>): FooterView {
return pickBlockerView({
permission: firstByOrder(data.permissions, listSubagentPermissions(subagent), order),
question: firstByOrder(data.questions, listSubagentQuestions(subagent), order),
})
}
function composeFooter(input: {
patch?: FooterPatch
subagent?: FooterSubagentState
current: FooterView
previous: FooterView
}) {
let footer: FooterOutput | undefined
if (input.subagent) {
footer = {
...footer,
subagent: input.subagent,
}
}
if (!sameView(input.previous, input.current)) {
footer = {
...footer,
view: input.current,
}
}
if (input.current.type !== "prompt") {
footer = {
...footer,
patch: {
...input.patch,
status: blockerStatus(input.current),
},
}
return footer
}
if (input.patch) {
footer = {
...footer,
patch: input.patch,
}
return footer
}
if (input.previous.type !== "prompt") {
footer = {
...footer,
patch: {
status: "",
},
}
}
return footer
}
function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: FooterSubagentTab[]) {
const before = new Map(prev.map((item) => [item.sessionID, item]))
const after = new Map(next.map((item) => [item.sessionID, item]))
for (const [sessionID, tab] of after) {
if (sameSubagentTab(before.get(sessionID), tab)) {
continue
}
trace?.write("subagent.tab", {
sessionID,
tab,
})
}
for (const sessionID of before.keys()) {
if (after.has(sessionID)) {
continue
}
trace?.write("subagent.tab", {
sessionID,
cleared: true,
})
}
}
function createLayer(input: StreamInput) {
return Layer.fresh(
Layer.effect(
Service,
Effect.gen(function* () {
const scope = yield* Scope.make()
const abort = yield* Scope.provide(scope)(
Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(abort) => Effect.sync(() => abort.abort()),
),
)
let closed = false
let closeStream = () => {}
const halt = () => {
abort.abort()
}
const stop = () => {
input.signal?.removeEventListener("abort", halt)
abort.abort()
closeStream()
}
const closeScope = () => {
if (closed) {
return Effect.void
}
closed = true
stop()
return Scope.close(scope, Exit.void)
}
input.signal?.addEventListener("abort", halt, { once: true })
yield* Effect.addFinalizer(() => closeScope())
const events = yield* Scope.provide(scope)(
Effect.acquireRelease(
Effect.promise(() =>
input.sdk.event.subscribe(undefined, {
signal: abort.signal,
}),
),
(events) =>
Effect.sync(() => {
void events.stream.return(undefined).catch(() => {})
}),
),
)
closeStream = () => {
void events.stream.return(undefined).catch(() => {})
}
input.trace?.write("recv.subscribe", {
sessionID: input.sessionID,
})
const state: State = {
data: createSessionData(),
subagent: createSubagentData(),
tick: 0,
footerView: { type: "prompt" },
blockerTick: 0,
blockers: new Map(),
}
const recovering = new Set<string>()
const currentSubagentState = () => {
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
state.selectedSubagent = undefined
}
return snapshotSelectedSubagentData(state.subagent, state.selectedSubagent)
}
const seedBlocker = (id: string) => {
if (state.blockers.has(id)) {
return
}
state.blockerTick += 1
state.blockers.set(id, state.blockerTick)
}
const trackBlocker = (event: Event) => {
if (event.type !== "permission.asked" && event.type !== "question.asked") {
return
}
if (
event.properties.sessionID !== input.sessionID &&
!state.subagent.tabs.has(event.properties.sessionID)
) {
return
}
seedBlocker(event.properties.id)
}
const releaseBlocker = (event: Event) => {
if (
event.type !== "permission.replied" &&
event.type !== "question.replied" &&
event.type !== "question.rejected"
) {
return
}
state.blockers.delete(event.properties.requestID)
}
const syncFooter = (commits: StreamCommit[], patch?: FooterPatch, nextSubagent?: FooterSubagentState) => {
const current = pickView(state.data, state.subagent, state.blockers)
const footer = composeFooter({
patch,
subagent: nextSubagent,
current,
previous: state.footerView,
})
if (commits.length === 0 && !footer) {
state.footerView = current
return
}
input.trace?.write("reduce.output", {
commits,
footer: traceFooterOutput(footer),
})
writeSessionOutput(
{
footer: input.footer,
trace: input.trace,
},
{
commits,
footer,
},
)
state.footerView = current
}
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) {
if (recovering.has(partID)) {
return
}
recovering.add(partID)
try {
while (!closed && !abort.signal.aborted && !input.footer.isClosed) {
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
return
}
const questions = yield* Effect.promise(() => input.sdk.question.list()).pipe(
Effect.map((item) => (item.data ?? []).filter((request) => request.sessionID === input.sessionID)),
Effect.orElseSucceed(() => []),
)
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
return
}
if (questions.length > 0) {
bootstrapSessionData({
data: state.data,
messages: [],
permissions: [],
questions,
})
for (const request of questions) {
seedBlocker(request.id)
}
input.trace?.write("question.recover", {
sessionID: input.sessionID,
requests: questions.map((request) => request.id),
})
syncFooter([])
return
}
yield* Effect.sleep("250 millis")
}
} finally {
recovering.delete(partID)
}
})
const messages = (sessionID: string, limit: number) =>
Effect.promise(() =>
input.sdk.session.messages({
sessionID,
limit,
}),
).pipe(
Effect.map((item) => item.data ?? []),
Effect.orElseSucceed(() => []),
)
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
const [messagesList, children, permissions, questions] = yield* Effect.all(
[
messages(input.sessionID, SUBAGENT_BOOTSTRAP_LIMIT),
Effect.promise(() =>
input.sdk.session.children({
sessionID: input.sessionID,
}),
).pipe(
Effect.map((item) => item.data ?? []),
Effect.orElseSucceed(() => []),
),
Effect.promise(() => input.sdk.permission.list()).pipe(
Effect.map((item) => item.data ?? []),
Effect.orElseSucceed(() => []),
),
Effect.promise(() => input.sdk.question.list()).pipe(
Effect.map((item) => item.data ?? []),
Effect.orElseSucceed(() => []),
),
],
{
concurrency: "unbounded",
},
)
bootstrapSessionData({
data: state.data,
messages: messagesList,
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
questions: questions.filter((item) => item.sessionID === input.sessionID),
})
bootstrapSubagentData({
data: state.subagent,
messages: messagesList,
children,
permissions,
questions,
})
const sessions = [
...new Set(
listSubagentPermissions(state.subagent)
.filter((item) => item.tool && item.metadata?.input === undefined)
.map((item) => item.sessionID),
),
]
yield* Effect.forEach(
sessions,
(sessionID) =>
messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe(
Effect.tap((messagesList) =>
Effect.sync(() => {
bootstrapSubagentCalls({
data: state.subagent,
sessionID,
messages: messagesList,
})
}),
),
),
{
concurrency: "unbounded",
discard: true,
},
)
for (const request of [
...state.data.permissions,
...listSubagentPermissions(state.subagent),
...state.data.questions,
...listSubagentQuestions(state.subagent),
].sort((a, b) => a.id.localeCompare(b.id))) {
seedBlocker(request.id)
}
const snapshot = currentSubagentState()
traceTabs(input.trace, [], snapshot.tabs)
syncFooter([], undefined, snapshot)
})
const idle = Effect.fn("RunStreamTransport.idle")((fallback: boolean) =>
Effect.promise(() => input.sdk.session.status()).pipe(
Effect.map((out) => {
const item = out.data?.[input.sessionID]
return !item || item.type === "idle"
}),
Effect.orElseSucceed(() => fallback),
),
)
const fail = Effect.fn("RunStreamTransport.fail")(function* (error: unknown) {
if (state.fault) {
return
}
state.fault = error
const next = state.wait
state.wait = undefined
if (!next) {
return
}
yield* Deferred.fail(next.done, error).pipe(Effect.ignore)
})
const touch = (event: Event) => {
const next = state.wait
if (!next || !active(event, input.sessionID)) {
return
}
next.live = true
}
const complete = Effect.fn("RunStreamTransport.complete")(function* (next: Wait, fallback: boolean) {
if (state.wait !== next || !next.armed || !next.live) {
return
}
if (!(yield* idle(fallback)) || state.wait !== next) {
return
}
state.tick = next.tick + 1
state.wait = undefined
yield* Deferred.succeed(next.done, undefined).pipe(Effect.ignore)
})
const mark = Effect.fn("RunStreamTransport.mark")(function* (event: Event) {
if (
event.type !== "session.status" ||
event.properties.sessionID !== input.sessionID ||
event.properties.status.type !== "idle"
) {
return
}
const next = state.wait
if (!next) {
return
}
yield* complete(next, true)
})
const poll = Effect.fn("RunStreamTransport.poll")(function* (next: Wait, signal: AbortSignal) {
while (state.wait === next && !signal.aborted && !input.footer.isClosed && !closed) {
yield* Effect.sleep("250 millis")
yield* complete(next, false)
}
})
const flush = (type: "turn.abort" | "turn.cancel") => {
const commits: StreamCommit[] = []
flushInterrupted(state.data, commits)
syncFooter(commits)
input.trace?.write(type, {
sessionID: input.sessionID,
})
}
const watch = Effect.fn("RunStreamTransport.watch")(() =>
Stream.fromAsyncIterable(events.stream as AsyncIterable<unknown>, (error) =>
error instanceof Error ? error : new Error(String(error)),
).pipe(
Stream.takeUntil(() => input.footer.isClosed || abort.signal.aborted),
Stream.runForEach(
Effect.fn("RunStreamTransport.event")(function* (item: unknown) {
if (input.footer.isClosed) {
abort.abort()
return
}
if (!isEvent(item)) {
return
}
const event = item
input.trace?.write("recv.event", event)
trackBlocker(event)
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
const next = reduceSessionData({
data: state.data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
state.data = next.data
if (
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
event.properties.part.tool === "question" &&
event.properties.part.state.status === "running" &&
state.data.questions.length === 0
) {
yield* recoverQuestion(event.properties.part.id).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
}
const changed = reduceSubagentData({
data: state.subagent,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
if (changed && prev) {
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
}
releaseBlocker(event)
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
touch(event)
yield* mark(event)
}),
),
Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
Effect.ensuring(
Effect.gen(function* () {
if (!abort.signal.aborted && !state.fault) {
yield* fail(new Error("session event stream closed"))
}
closeStream()
}),
),
),
)
yield* bootstrap()
yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
if (closed || next.signal?.aborted || input.footer.isClosed) {
return
}
if (state.fault) {
yield* Effect.fail(state.fault)
return
}
if (state.wait) {
yield* Effect.fail(new Error("prompt already running"))
return
}
const prev = listSubagentTabs(state.subagent)
if (clearFinishedSubagents(state.subagent)) {
const snapshot = currentSubagentState()
traceTabs(input.trace, prev, snapshot.tabs)
syncFooter([], undefined, snapshot)
}
const item: Wait = {
tick: state.tick,
armed: false,
live: false,
done: yield* Deferred.make<void, unknown>(),
}
state.wait = item
state.data.announced = false
const turn = new AbortController()
const stop = () => {
turn.abort()
}
next.signal?.addEventListener("abort", stop, { once: true })
abort.signal.addEventListener("abort", stop, { once: true })
yield* poll(item, turn.signal).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
const req = {
sessionID: input.sessionID,
agent: next.agent,
model: next.model,
variant: next.variant,
parts: [
...(next.includeFiles ? next.files : []),
{ type: "text" as const, text: next.prompt.text },
...next.prompt.parts,
],
}
const command = next.prompt.command
const send = command
? Effect.sync(() => {
input.trace?.write("send.command", { sessionID: input.sessionID, command: command.name })
}).pipe(
Effect.andThen(
Effect.promise(() =>
input.sdk.session.command(
{
sessionID: input.sessionID,
agent: next.agent,
model: next.model ? `${next.model.providerID}/${next.model.modelID}` : undefined,
variant: next.variant,
command: command.name,
arguments: command.arguments,
parts: [
...(next.includeFiles ? next.files : []),
...next.prompt.parts.filter(
(item): item is Extract<RunPromptPart, { type: "file" }> => item.type === "file",
),
],
},
{ signal: turn.signal },
),
).pipe(
Effect.tap(() =>
Effect.sync(() => {
input.trace?.write("send.command.ok", {
sessionID: input.sessionID,
command: command.name,
})
item.armed = true
item.live = true
}),
),
Effect.flatMap(() => Deferred.succeed(item.done, undefined).pipe(Effect.ignore)),
Effect.catch((error) => Deferred.fail(item.done, error).pipe(Effect.ignore)),
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
),
),
)
: Effect.sync(() => {
input.trace?.write("send.prompt", req)
}).pipe(
Effect.andThen(
Effect.promise(() =>
input.sdk.session.promptAsync(req, {
signal: turn.signal,
}),
),
),
Effect.tap(() =>
Effect.sync(() => {
input.trace?.write("send.prompt.ok", {
sessionID: input.sessionID,
})
item.armed = true
}),
),
)
yield* send.pipe(
Effect.flatMap(() => {
if (turn.signal.aborted || next.signal?.aborted || input.footer.isClosed || closed) {
if (state.wait === item) {
state.wait = undefined
}
flush("turn.abort")
return Effect.void
}
if (!input.footer.isClosed && !state.data.announced) {
input.trace?.write("ui.patch", {
phase: "running",
status: "waiting for assistant",
})
input.footer.event({
type: "turn.wait",
})
}
if (state.tick > item.tick) {
if (state.wait === item) {
state.wait = undefined
}
return Effect.void
}
return waitTurn(item.done, turn.signal).pipe(
Effect.flatMap((status) =>
Effect.sync(() => {
if (state.wait === item) {
state.wait = undefined
}
if (status === "abort") {
flush("turn.abort")
}
}),
),
)
}),
Effect.catch((error) => {
if (state.wait === item) {
state.wait = undefined
}
const canceled = turn.signal.aborted || next.signal?.aborted === true || input.footer.isClosed || closed
if (canceled) {
flush("turn.cancel")
return Effect.void
}
if (error === state.fault) {
return Effect.fail(error)
}
input.trace?.write("send.prompt.error", {
sessionID: input.sessionID,
error: formatUnknownError(error),
})
return Effect.fail(error)
}),
Effect.ensuring(
Effect.sync(() => {
input.trace?.write("turn.end", {
sessionID: input.sessionID,
})
next.signal?.removeEventListener("abort", stop)
abort.signal.removeEventListener("abort", stop)
}),
),
)
return
})
const selectSubagent = Effect.fn("RunStreamTransport.selectSubagent")((sessionID: string | undefined) =>
Effect.sync(() => {
if (closed) {
return
}
const next = sessionID && state.subagent.tabs.has(sessionID) ? sessionID : undefined
if (state.selectedSubagent === next) {
return
}
state.selectedSubagent = next
syncFooter([], undefined, currentSubagentState())
}),
)
const close = Effect.fn("RunStreamTransport.close")(function* () {
yield* closeScope()
})
return Service.of({
runPromptTurn,
selectSubagent,
close,
})
}),
),
)
}
// Opens an SDK event subscription and returns a SessionTransport.
//
// The background `watch` loop consumes every SDK event, runs it through the
// reducer, and writes output to the footer. When a session.status idle
// event arrives, it resolves the current turn's Wait so runPromptTurn()
// can return.
//
// The transport is single-turn: only one runPromptTurn() call can be active
// at a time. The prompt queue enforces this from above.
export async function createSessionTransport(input: StreamInput): Promise<SessionTransport> {
const runtime = makeRuntime(Service, createLayer(input))
await runtime.runPromise(() => Effect.void)
return {
runPromptTurn: (next) => runtime.runPromise((svc) => svc.runPromptTurn(next)),
selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)),
close: () => runtime.runPromise((svc) => svc.close()),
}
}
+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
}
}
+1457
View File
@@ -0,0 +1,1457 @@
// Per-tool display rules shared across `opencode run` output paths.
//
// Each known tool (bash, edit, write, task, etc.) has a ToolRule that controls
// five display hooks:
//
// view → visibility policy for progress/final scrollback entries and
// whether completed finals can render as structured snapshots
// run → inline summary for the non-interactive `run` command output
// scroll → text formatting for start/progress/final scrollback entries
// permission → display info for the permission UI (icon, title, diff)
// snap → structured snapshot (code block, diff, task card) for rich
// scrollback entries
//
// Tools not in TOOL_RULES get fallback formatting.
import os from "os"
import path from "path"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import type * as Tool from "@/tool/tool"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { ShellTool as BashTool } from "@/tool/shell"
import type { EditTool } from "@/tool/edit"
import type { GlobTool } from "@/tool/glob"
import type { GrepTool } from "@/tool/grep"
import type { InvalidTool } from "@/tool/invalid"
import type { LspTool } from "@/tool/lsp"
import type { PlanExitTool } from "@/tool/plan"
import type { QuestionTool } from "@/tool/question"
import type { ReadTool } from "@/tool/read"
import type { SkillTool } from "@/tool/skill"
import type { TaskTool } from "@/tool/task"
import type { TodoWriteTool } from "@/tool/todo"
import type { WebFetchTool } from "@/tool/webfetch"
import type { WebSearchTool } from "@/tool/websearch"
import type { WriteTool } from "@/tool/write"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import * as Locale from "@/util/locale"
import type { RunDiffStyle, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type ToolView = {
output: boolean
final: boolean
snap?: "code" | "diff" | "structured"
}
export type ToolPhase = "start" | "progress" | "final"
export type ToolDict = Record<string, unknown>
export type ToolFrame = {
raw: string
name: string
input: ToolDict
meta: ToolDict
state: ToolDict
status: string
error: string
}
export type ToolInline = {
icon: string
title: string
description?: string
mode?: "inline" | "block"
body?: string
}
export type ToolPermissionInfo = {
icon: string
title: string
lines: string[]
diff?: string
file?: string
}
export type ToolProps<T = Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
frame: ToolFrame
}
type ToolPermissionProps<T = Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
patterns: string[]
}
type ToolPermissionCtx = {
input: ToolDict
meta: ToolDict
patterns: string[]
}
type ToolDefs = {
invalid: typeof InvalidTool
bash: typeof BashTool
write: typeof WriteTool
edit: typeof EditTool
apply_patch: typeof ApplyPatchTool
batch: Tool.Info
task: typeof TaskTool
todowrite: typeof TodoWriteTool
question: typeof QuestionTool
read: typeof ReadTool
glob: typeof GlobTool
grep: typeof GrepTool
list: Tool.Info
lsp: typeof LspTool
webfetch: typeof WebFetchTool
websearch: typeof WebSearchTool
skill: typeof SkillTool
plan_exit: typeof PlanExitTool
}
type ToolName = keyof ToolDefs
type ToolRule<T = Tool.Info> = {
view: ToolView
run: (props: ToolProps<T>) => ToolInline
scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
permission?: (props: ToolPermissionProps<T>) => ToolPermissionInfo
snap?: (props: ToolProps<T>) => ToolSnapshot | undefined
}
type ToolRegistry = {
[K in ToolName]: ToolRule<ToolDefs[K]>
}
type AnyToolRule = ToolRule
function dict(v: unknown): ToolDict {
if (!v || typeof v !== "object" || Array.isArray(v)) {
return {}
}
return { ...v }
}
function props<T = Tool.Info>(frame: ToolFrame): ToolProps<T> {
return {
input: Object.assign(Object.create(null), frame.input),
metadata: Object.assign(Object.create(null), frame.meta),
frame,
}
}
function permission<T = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
return {
input: Object.assign(Object.create(null), ctx.input),
metadata: Object.assign(Object.create(null), ctx.meta),
patterns: ctx.patterns,
}
}
function text(v: unknown): string {
return typeof v === "string" ? v : ""
}
function num(v: unknown): number | undefined {
if (typeof v !== "number" || !Number.isFinite(v)) {
return undefined
}
return v
}
function list<T>(v: unknown): T[] {
if (!Array.isArray(v)) {
return []
}
return v
}
function info(data: ToolDict, skip: string[] = []): string {
const list = Object.entries(data).filter(([key, val]) => {
if (skip.includes(key)) {
return false
}
return typeof val === "string" || typeof val === "number" || typeof val === "boolean"
})
if (list.length === 0) {
return ""
}
return `[${list.map(([key, val]) => `${key}=${String(val)}`).join(", ")}]`
}
function span(state: ToolDict): string {
const time = dict(state.time)
const start = num(time.start)
const end = num(time.end)
if (start === undefined || end === undefined || end <= start) {
return ""
}
return Locale.duration(end - start)
}
function fail(ctx: ToolFrame): string {
const error = toolError(ctx)
if (error) {
return `${ctx.name} failed: ${error}`
}
return `${ctx.name} failed`
}
function toolError(ctx: ToolFrame): string {
if (ctx.error) {
return ctx.error
}
const state = text(ctx.state.error).trim()
if (state) {
return state
}
return ctx.raw.trim()
}
function fallbackStart(ctx: ToolFrame): string {
const extra = info(ctx.input)
if (!extra) {
return `${ctx.name}`
}
return `${ctx.name} ${extra}`
}
function fallbackFinal(ctx: ToolFrame): string {
if (ctx.status === "error") {
return fail(ctx)
}
if (ctx.status && ctx.status !== "completed") {
return ctx.raw.trim()
}
const time = span(ctx.state)
if (!time) {
return `${ctx.name} completed`
}
return `${ctx.name} completed · ${time}`
}
export function toolPath(input?: string, opts: { home?: boolean } = {}): string {
if (!input) {
return ""
}
const cwd = process.cwd()
const home = os.homedir()
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input)
const rel = path.relative(cwd, abs)
if (!rel) {
return "."
}
if (!rel.startsWith("..")) {
return rel.replaceAll("\\", "/")
}
if (opts.home && home && (abs === home || abs.startsWith(home + path.sep))) {
return abs.replace(home, "~").replaceAll("\\", "/")
}
return abs.replaceAll("\\", "/")
}
function fallbackInline(ctx: ToolFrame): ToolInline {
const title = text(ctx.state.title) || (Object.keys(ctx.input).length > 0 ? JSON.stringify(ctx.input) : "Unknown")
return {
icon: "⚙",
title: `${ctx.name} ${title}`,
}
}
function count(n: number, label: string): string {
return `${n} ${label}${n === 1 ? "" : "es"}`
}
function runGlob(p: ToolProps<typeof GlobTool>): ToolInline {
const root = p.input.path ?? ""
const title = `Glob "${p.input.pattern ?? ""}"`
const suffix = root ? `in ${toolPath(root)}` : ""
const matches = p.metadata.count
const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
return {
icon: "✱",
title,
...(description && { description }),
}
}
function runGrep(p: ToolProps<typeof GrepTool>): ToolInline {
const root = p.input.path ?? ""
const title = `Grep "${p.input.pattern ?? ""}"`
const suffix = root ? `in ${toolPath(root)}` : ""
const matches = p.metadata.matches
const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
return {
icon: "✱",
title,
...(description && { description }),
}
}
function runList(p: ToolProps): ToolInline {
const dir = text(dict(p.input).path)
return {
icon: "→",
title: dir ? `List ${toolPath(dir)}` : "List",
}
}
function runRead(p: ToolProps<typeof ReadTool>): ToolInline {
const file = toolPath(p.input.filePath)
const description = info(p.frame.input, ["filePath"]) || undefined
return {
icon: "→",
title: `Read ${file}`,
...(description && { description }),
}
}
function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
return {
icon: "←",
title: `Write ${toolPath(p.input.filePath)}`,
mode: "block",
body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
}
}
function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
const url = p.input.url ?? ""
return {
icon: "%",
title: url ? `WebFetch ${url}` : "WebFetch",
}
}
function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
return {
icon: "←",
title: `Edit ${toolPath(p.input.filePath)}`,
mode: "block",
body: p.metadata.diff,
}
}
function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
return {
icon: "◈",
title: `Web Search "${p.input.query ?? ""}"`,
}
}
function runTask(p: ToolProps<typeof TaskTool>): ToolInline {
const kind = Locale.titlecase(p.input.subagent_type || "unknown")
const desc = p.input.description
const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓"
return {
icon,
title: desc || `${kind} Task`,
description: desc ? `${kind} Agent` : undefined,
}
}
function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
return {
icon: "#",
title: "Todos",
mode: "block",
body: list<{ status?: string; content?: string }>(p.frame.input.todos)
.flatMap((item) => {
const body = typeof item?.content === "string" ? item.content : ""
if (!body) {
return []
}
const mark = item.status === "completed" ? "[✓]" : item.status === "in_progress" ? "[•]" : "[ ]"
return [`${mark} ${body}`]
})
.join("\n"),
}
}
function runSkill(p: ToolProps<typeof SkillTool>): ToolInline {
return {
icon: "→",
title: `Skill "${p.input.name ?? ""}"`,
}
}
function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline {
const files = p.metadata.files?.length ?? 0
if (files === 0) {
return {
icon: "%",
title: "Patch",
}
}
return {
icon: "%",
title: `Patch ${files} file${files === 1 ? "" : "s"}`,
}
}
function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
const total = list(p.frame.input.questions).length
return {
icon: "→",
title: `Asked ${total} question${total === 1 ? "" : "s"}`,
}
}
function runInvalid(p: ToolProps<typeof InvalidTool>): ToolInline {
return {
icon: "✗",
title: text(p.frame.state.title) || "Invalid Tool",
mode: "block",
body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
}
}
function runBatch(p: ToolProps): ToolInline {
const calls = list(dict(p.input).tool_calls).length
return {
icon: "#",
title: text(p.frame.state.title) || (calls > 0 ? `Batch ${calls} tool${calls === 1 ? "" : "s"}` : "Batch"),
mode: "block",
body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
}
}
function lspTitle(
input: {
operation?: string
filePath?: string
line?: number
character?: number
},
opts: { home?: boolean } = {},
): string {
const op = input.operation || "request"
const file = input.filePath ? toolPath(input.filePath, opts) : ""
const line = typeof input.line === "number" ? input.line : undefined
const char = typeof input.character === "number" ? input.character : undefined
const pos = line !== undefined && char !== undefined ? `:${line}:${char}` : ""
if (!file) {
return `LSP ${op}`
}
return `LSP ${op} ${file}${pos}`
}
function runLsp(p: ToolProps<typeof LspTool>): ToolInline {
return {
icon: "→",
title: text(p.frame.state.title) || lspTitle(p.input),
}
}
function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline {
return {
icon: "→",
title: text(p.frame.state.title) || "Switching to build agent",
mode: "block",
body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
}
}
type PatchFile = Tool.InferMetadata<typeof ApplyPatchTool>["files"][number]
function patchTitle(file: PatchFile): string {
const rel = file.relativePath
const from = file.filePath
if (file.type === "add") {
return `# Created ${rel || toolPath(from)}`
}
if (file.type === "delete") {
return `# Deleted ${rel || toolPath(from)}`
}
if (file.type === "move") {
return `# Moved ${toolPath(from)} -> ${rel || toolPath(file.movePath)}`
}
return `# Patched ${rel || toolPath(from)}`
}
function snapWrite(p: ToolProps<typeof WriteTool>): ToolSnapshot | undefined {
const file = p.input.filePath || ""
const content = p.input.content || ""
if (!file && !content) {
return undefined
}
return {
kind: "code",
title: `# Wrote ${toolPath(file)}`,
content,
file,
}
}
function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined {
const file = p.input.filePath || ""
const diff = p.metadata.diff || ""
if (!file || !diff.trim()) {
return undefined
}
return {
kind: "diff",
items: [
{
title: `# Edited ${toolPath(file)}`,
diff,
file,
},
],
}
}
function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefined {
const files = list<PatchFile>(p.frame.meta.files)
if (files.length === 0) {
return undefined
}
const items = files.flatMap((file) => {
if (!file || typeof file !== "object") {
return []
}
const diff = typeof file.patch === "string" ? file.patch : ""
if (!diff.trim()) {
return []
}
const name = file.movePath || file.filePath || file.relativePath
return [
{
title: patchTitle(file),
diff,
file: name,
deletions: typeof file.deletions === "number" ? file.deletions : 0,
},
]
})
if (items.length === 0) {
return undefined
}
return {
kind: "diff",
items,
}
}
function snapTask(p: ToolProps<typeof TaskTool>): ToolSnapshot {
const kind = Locale.titlecase(p.input.subagent_type || "general")
const desc = p.input.description
const title = text(p.frame.state.title)
const rows = [desc || title].filter((item): item is string => Boolean(item))
return {
kind: "task",
title: `# ${kind} Task`,
rows,
tail: "",
}
}
function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => {
const content = typeof item?.content === "string" ? item.content : ""
if (!content) {
return []
}
return [
{
status: typeof item.status === "string" ? item.status : "",
content,
},
]
})
return {
kind: "todo",
items,
tail: "",
}
}
function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
const answers = list<unknown[]>(p.frame.meta.answers)
const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
const answer = list<string>(answers[i]).filter((entry) => typeof entry === "string")
return {
question: item.question || `Question ${i + 1}`,
answer: answer.length > 0 ? answer.join(", ") : "(no answer)",
}
})
return {
kind: "question",
items,
tail: "",
}
}
function scrollBashStart(p: ToolProps<typeof BashTool>): string {
const cmd = p.input.command ?? ""
const desc = p.input.description || "Shell"
const wd = p.input.workdir ?? ""
const dir = wd && wd !== "." ? toolPath(wd) : ""
const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc
if (!cmd) {
return `# ${title}`
}
return `# ${title}\n$ ${cmd}`
}
function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
const out = stripAnsi(p.frame.raw)
const cmd = (p.input.command ?? "").trim()
const fmt = (text: string) => {
const body = text.replace(/^\n+/, "").replace(/\n+$/, "")
return body ? `\n${body}` : ""
}
if (!cmd) {
return out.replace(/\n+$/, "")
}
const wdRaw = (p.input.workdir ?? "").trim()
const wd = wdRaw ? toolPath(wdRaw) : ""
const lines = out.split("\n")
const first = (lines[0] || "").trim()
const second = (lines[1] || "").trim()
if (wd && (first === wd || first === wdRaw) && second === cmd) {
return fmt(lines.slice(2).join("\n"))
}
if (first === cmd || first === `$ ${cmd}`) {
return fmt(lines.slice(1).join("\n"))
}
if (wd && (first === `${wd} ${cmd}` || first === `${wdRaw} ${cmd}`)) {
return fmt(lines.slice(1).join("\n"))
}
return fmt(out)
}
function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
const code = p.metadata.exit ?? num(p.frame.meta.exitCode) ?? num(p.frame.meta.exit_code)
const time = span(p.frame.state)
if (code === undefined) {
if (!time) {
return "bash completed"
}
return `bash completed · ${time}`
}
return `bash completed (exit ${code})${time ? ` · ${time}` : ""}`
}
function scrollReadStart(p: ToolProps<typeof ReadTool>): string {
const file = toolPath(p.input.filePath)
const extra = info(p.frame.input, ["filePath"])
const tail = extra ? ` ${extra}` : ""
return `→ Read ${file}${tail}`.trim()
}
function scrollWriteStart(_: ToolProps<typeof WriteTool>): string {
return ""
}
function scrollEditStart(_: ToolProps<typeof EditTool>): string {
return ""
}
function scrollPatchStart(_: ToolProps<typeof ApplyPatchTool>): string {
return ""
}
function patchLine(file: PatchFile): string {
const type = file.type
const rel = file.relativePath
const from = file.filePath
if (type === "add") {
return `+ Created ${rel || toolPath(from)}`
}
if (type === "delete") {
return `- Deleted ${rel || toolPath(from)}`
}
if (type === "move") {
return `→ Moved ${toolPath(from)}${rel || toolPath(file.movePath)}`
}
return `~ Patched ${rel || toolPath(from)}`
}
function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
const files = list<PatchFile>(p.frame.meta.files)
if (files.length === 0) {
const time = span(p.frame.state)
if (!time) {
return "patch"
}
return `patch · ${time}`
}
const show_updates = !files.some((file) => file?.type && file.type !== "update")
const shown = files.filter((file) => show_updates || file.type !== "update")
const rows = shown.slice(0, 6).map(patchLine)
if (shown.length > 6) {
rows.push(`... and ${shown.length - 6} more`)
}
if (rows.length > 0) {
return rows.join("\n")
}
return patchLine(files[0]!)
}
function scrollTaskStart(_: ToolProps<typeof TaskTool>): string {
return ""
}
function taskResult(output: string): string | undefined {
if (!output.trim()) {
return undefined
}
const match = output.match(/<task_result>\s*([\s\S]*?)\s*<\/task_result>/)
if (match) {
return match[1].trim() || undefined
}
const next = output
.split("\n")
.filter((line) => !line.startsWith("task_id:"))
.join("\n")
.trim()
return next || undefined
}
function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
const kind = Locale.titlecase(p.input.subagent_type || "general")
const row = p.input.description || text(p.frame.state.title)
if (!row) {
return `# ${kind} Task`
}
return `# ${kind} Task\n${row}`
}
function scrollTodoStart(_: ToolProps<typeof TodoWriteTool>): string {
return ""
}
function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
const items = list<{ status?: string }>(p.input.todos)
const time = span(p.frame.state)
if (items.length === 0) {
if (!time) {
return "0 todos"
}
return `0 todos · ${time}`
}
const doneN = items.filter((item) => item.status === "completed").length
const runN = items.filter((item) => item.status === "in_progress").length
const left = items.length - doneN - runN
const tail = [`${items.length} total`]
if (doneN > 0) {
tail.push(`${doneN} done`)
}
if (runN > 0) {
tail.push(`${runN} active`)
}
if (left > 0) {
tail.push(`${left} pending`)
}
if (time) {
tail.push(time)
}
return tail.join(" · ")
}
function scrollQuestionStart(_: ToolProps<typeof QuestionTool>): string {
return ""
}
function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
const q = p.input.questions ?? []
const a = p.metadata.answers ?? []
const time = span(p.frame.state)
if (q.length === 0) {
if (!time) {
return "0 questions"
}
return `0 questions · ${time}`
}
const rows: string[] = []
for (const [i, item] of q.slice(0, 4).entries()) {
const prompt = item.question
const reply = a[i] ?? []
rows.push(`? ${prompt || `Question ${i + 1}`}`)
rows.push(` ${reply.length > 0 ? reply.join(", ") : "(no answer)"}`)
}
if (q.length > 4) {
rows.push(`... and ${q.length - 4} more`)
}
return rows.join("\n")
}
function scrollLspStart(p: ToolProps<typeof LspTool>): string {
return `${lspTitle(p.input)}`
}
function scrollSkillStart(p: ToolProps<typeof SkillTool>): string {
return `→ Skill "${p.input.name ?? ""}"`
}
function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
const pattern = p.input.pattern ?? ""
const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob"
const dir = p.input.path ?? ""
if (!dir) {
return head
}
return `${head} in ${toolPath(dir)}`
}
function scrollGlobFinal(p: ToolProps<typeof GlobTool>): string {
return toolError(p.frame) || fail(p.frame)
}
function scrollGrepStart(p: ToolProps<typeof GrepTool>): string {
const pattern = p.input.pattern ?? ""
const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep"
const dir = p.input.path ?? ""
if (!dir) {
return head
}
return `${head} in ${toolPath(dir)}`
}
function scrollListStart(p: ToolProps): string {
const dir = text(dict(p.input).path)
if (!dir) {
return "→ List"
}
return `→ List ${toolPath(dir)}`
}
function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
const url = p.input.url ?? ""
if (!url) {
return "% WebFetch"
}
return `% WebFetch ${url}`
}
function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
const query = p.input.query ?? ""
if (!query) {
return "◈ Web Search"
}
return `◈ Web Search "${query}"`
}
function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo {
const input = p.input as { filePath?: string; filepath?: string; diff?: string }
const file = input.filePath || input.filepath || p.patterns[0] || ""
return {
icon: "→",
title: `Edit ${toolPath(file, { home: true })}`,
lines: [],
diff: p.metadata.diff ?? input.diff,
file,
}
}
function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
const file = p.input.filePath || p.patterns[0] || ""
return {
icon: "→",
title: `Read ${toolPath(file, { home: true })}`,
lines: file ? [`Path: ${toolPath(file, { home: true })}`] : [],
}
}
function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
const pattern = p.input.pattern || p.patterns[0] || ""
return {
icon: "✱",
title: `Glob "${pattern}"`,
lines: pattern ? [`Pattern: ${pattern}`] : [],
}
}
function permGrep(p: ToolPermissionProps<typeof GrepTool>): ToolPermissionInfo {
const pattern = p.input.pattern || p.patterns[0] || ""
return {
icon: "✱",
title: `Grep "${pattern}"`,
lines: pattern ? [`Pattern: ${pattern}`] : [],
}
}
function permList(p: ToolPermissionProps): ToolPermissionInfo {
const dir = text(dict(p.input).path) || p.patterns[0] || ""
return {
icon: "→",
title: `List ${toolPath(dir, { home: true })}`,
lines: dir ? [`Path: ${toolPath(dir, { home: true })}`] : [],
}
}
function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
const title = p.input.description || "Shell command"
const cmd = p.input.command || ""
return {
icon: "#",
title,
lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`),
}
}
function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
const type = p.input.subagent_type || "general"
const desc = p.input.description
return {
icon: "#",
title: `${Locale.titlecase(type)} Task`,
lines: desc ? [`${desc}`] : [],
}
}
function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissionInfo {
const url = p.input.url || ""
return {
icon: "%",
title: `WebFetch ${url}`,
lines: url ? [`URL: ${url}`] : [],
}
}
function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermissionInfo {
const query = p.input.query || ""
return {
icon: "◈",
title: `Web Search "${query}"`,
lines: query ? [`Query: ${query}`] : [],
}
}
function permLsp(p: ToolPermissionProps<typeof LspTool>): ToolPermissionInfo {
const file = p.input.filePath || ""
const line = typeof p.input.line === "number" ? p.input.line : undefined
const char = typeof p.input.character === "number" ? p.input.character : undefined
const pos = line !== undefined && char !== undefined ? `${line}:${char}` : undefined
return {
icon: "→",
title: lspTitle(p.input, { home: true }),
lines: [
...(p.input.operation ? [`Operation: ${p.input.operation}`] : []),
...(file ? [`Path: ${toolPath(file, { home: true })}`] : []),
...(pos ? [`Position: ${pos}`] : []),
],
}
}
const TOOL_RULES = {
invalid: {
view: {
output: true,
final: false,
},
run: runInvalid,
scroll: {
start: () => "",
},
},
bash: {
view: {
output: true,
final: false,
},
run: runBash,
scroll: {
start: scrollBashStart,
progress: scrollBashProgress,
final: scrollBashFinal,
},
permission: permBash,
},
write: {
view: {
output: false,
final: true,
snap: "code",
},
run: runWrite,
snap: snapWrite,
scroll: {
start: scrollWriteStart,
},
},
edit: {
view: {
output: false,
final: true,
snap: "diff",
},
run: runEdit,
snap: snapEdit,
scroll: {
start: scrollEditStart,
},
permission: permEdit,
},
apply_patch: {
view: {
output: false,
final: true,
snap: "diff",
},
run: runPatch,
snap: snapPatch,
scroll: {
start: scrollPatchStart,
final: scrollPatchFinal,
},
},
batch: {
view: {
output: true,
final: false,
},
run: runBatch,
scroll: {
start: () => "",
},
},
task: {
view: {
output: false,
final: true,
snap: "structured",
},
run: runTask,
snap: snapTask,
scroll: {
start: scrollTaskStart,
final: scrollTaskFinal,
},
permission: permTask,
},
todowrite: {
view: {
output: false,
final: true,
snap: "structured",
},
run: runTodo,
snap: snapTodo,
scroll: {
start: scrollTodoStart,
final: scrollTodoFinal,
},
},
question: {
view: {
output: false,
final: true,
snap: "structured",
},
run: runQuestion,
snap: snapQuestion,
scroll: {
start: scrollQuestionStart,
final: scrollQuestionFinal,
},
},
read: {
view: {
output: false,
final: false,
},
run: runRead,
scroll: {
start: scrollReadStart,
},
permission: permRead,
},
glob: {
view: {
output: false,
final: false,
},
run: runGlob,
scroll: {
start: scrollGlobStart,
final: scrollGlobFinal,
},
permission: permGlob,
},
grep: {
view: {
output: false,
final: false,
},
run: runGrep,
scroll: {
start: scrollGrepStart,
},
permission: permGrep,
},
list: {
view: {
output: false,
final: false,
},
run: runList,
scroll: {
start: scrollListStart,
},
permission: permList,
},
lsp: {
view: {
output: false,
final: false,
},
run: runLsp,
scroll: {
start: scrollLspStart,
},
permission: permLsp,
},
webfetch: {
view: {
output: false,
final: false,
},
run: runWebfetch,
scroll: {
start: scrollWebfetchStart,
},
permission: permWebfetch,
},
websearch: {
view: {
output: false,
final: false,
},
run: runWebSearch,
scroll: {
start: scrollWebSearchStart,
},
permission: permWebSearch,
},
skill: {
view: {
output: false,
final: false,
},
run: runSkill,
scroll: {
start: scrollSkillStart,
},
},
plan_exit: {
view: {
output: true,
final: false,
},
run: runPlanExit,
scroll: {
start: () => "",
},
},
} as const satisfies ToolRegistry
function key(name: string): name is ToolName {
return Object.prototype.hasOwnProperty.call(TOOL_RULES, name)
}
function rule(name?: string): AnyToolRule | undefined {
if (!name || !key(name)) {
return undefined
}
return TOOL_RULES[name]
}
function frame(part: ToolPart): ToolFrame {
const state = dict(part.state)
return {
raw: "",
name: part.tool,
input: dict(state.input),
meta: dict(state.metadata),
state,
status: text(state.status),
error: text(state.error),
}
}
export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
const state = dict(commit.part?.state)
return {
raw,
name: commit.tool || commit.part?.tool || "tool",
input: dict(state.input),
meta: dict(state.metadata),
state,
status: commit.toolState ?? text(state.status),
error: (commit.toolError ?? "").trim(),
}
}
function runBash(p: ToolProps<typeof BashTool>): ToolInline {
return {
icon: "$",
title: p.input.command || "",
mode: "block",
body: p.frame.status === "completed" ? text(p.frame.state.output).trim() : undefined,
}
}
export function toolView(name?: string): ToolView {
return (
rule(name)?.view ?? {
output: true,
final: true,
}
)
}
export function toolStructuredFinal(commit: StreamCommit): boolean {
const state = commit.toolState ?? commit.part?.state.status
return (
commit.kind === "tool" &&
commit.phase === "final" &&
state === "completed" &&
Boolean(toolView(commit.tool ?? commit.part?.tool).snap)
)
}
export function toolInlineInfo(part: ToolPart): ToolInline {
const ctx = frame(part)
const draw = rule(ctx.name)?.run
try {
if (draw) {
return draw(props(ctx))
}
} catch {
return fallbackInline(ctx)
}
return fallbackInline(ctx)
}
export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string {
const draw = rule(ctx.name)?.scroll?.[phase]
try {
if (draw) {
return draw(props(ctx))
}
} catch {
if (phase === "start") {
return fallbackStart(ctx)
}
if (phase === "progress") {
return ctx.raw
}
return fallbackFinal(ctx)
}
if (phase === "start") {
return fallbackStart(ctx)
}
if (phase === "progress") {
return ctx.raw
}
return fallbackFinal(ctx)
}
export function toolPermissionInfo(
name: string,
input: ToolDict,
meta: ToolDict,
patterns: string[],
): ToolPermissionInfo | undefined {
const draw = rule(name)?.permission
if (!draw) {
return undefined
}
try {
return draw(permission({ input, meta, patterns }))
} catch {
return undefined
}
}
export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
const ctx = toolFrame(commit, raw)
const draw = rule(ctx.name)?.snap
if (!draw) {
return undefined
}
try {
return draw(props(ctx))
} catch {
return undefined
}
}
function textBody(content: string): RunEntryBody | undefined {
if (!content) {
return undefined
}
return {
type: "text",
content,
}
}
function markdownBody(content: string): RunEntryBody | undefined {
if (!content) {
return undefined
}
return {
type: "markdown",
content,
}
}
function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
const snap = toolSnapshot(commit, raw)
if (!snap) {
return undefined
}
return {
type: "structured",
snapshot: snap,
}
}
export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
const ctx = toolFrame(commit, raw)
const view = toolView(ctx.name)
if (ctx.name === "task") {
if (commit.phase === "start") {
return undefined
}
if (commit.phase === "final" && ctx.status === "completed") {
const result = taskResult(text(ctx.state.output))
if (result) {
return markdownBody(result)
}
}
}
if (commit.phase === "progress" && !view.output) {
return undefined
}
if (commit.phase === "final") {
if (ctx.status === "error") {
return textBody(toolScroll("final", ctx))
}
if (!view.final) {
return undefined
}
if (ctx.status && ctx.status !== "completed") {
return textBody(ctx.raw.trim())
}
if (toolStructuredFinal(commit)) {
return structuredBody(commit, raw) ?? textBody(toolScroll("final", ctx))
}
}
return textBody(toolScroll(commit.phase, ctx))
}
export function toolFiletype(input?: string): string | undefined {
if (!input) {
return undefined
}
const ext = path.extname(input)
const lang = LANGUAGE_EXTENSIONS[ext]
if (["typescriptreact", "javascriptreact", "javascript"].includes(lang)) {
return "typescript"
}
return lang
}
@@ -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
}
+312
View File
@@ -0,0 +1,312 @@
// 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 { 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]
export type FooterKeybinds = {
leader: string
commandList: string
variantCycle: string
interrupt: string
historyPrevious: string
historyNext: string
inputClear: string
inputSubmit: string
inputNewline: string
}
// 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() {
@@ -0,0 +1,483 @@
import { describe, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body"
import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input
}
function toolPart(tool: string, state: ToolPart["state"], id = `${tool}-1`, messageID = `msg-${tool}`): ToolPart {
return {
id,
sessionID: "session-1",
messageID,
type: "tool",
callID: `call-${id}`,
tool,
state,
} as ToolPart
}
function toolCommit(input: {
tool: string
state: ToolPart["state"]
phase?: StreamCommit["phase"]
toolState?: StreamCommit["toolState"]
text?: string
id?: string
messageID?: string
}) {
return commit({
kind: "tool",
text: input.text ?? "",
phase: input.phase ?? "final",
source: "tool",
tool: input.tool,
toolState: input.toolState ?? "completed",
part: toolPart(input.tool, input.state, input.id, input.messageID),
})
}
function structured(next: StreamCommit) {
const body = entryBody(next)
expect(body.type).toBe("structured")
if (body.type !== "structured") {
throw new Error("expected structured body")
}
return body.snapshot
}
describe("run entry body", () => {
test("renders assistant, reasoning, and user entries in their display formats", () => {
expect(
entryBody(
commit({
kind: "assistant",
text: "# Title\n\nHello **world**",
phase: "progress",
source: "assistant",
partID: "part-1",
}),
),
).toEqual({
type: "markdown",
content: "# Title\n\nHello **world**",
})
const reasoning = entryBody(
commit({
kind: "reasoning",
text: "Thinking: plan next steps",
phase: "progress",
source: "reasoning",
partID: "reason-1",
}),
)
expect(reasoning).toEqual({
type: "code",
filetype: "markdown",
content: "_Thinking:_ plan next steps",
})
expect(
entryCanStream(
commit({
kind: "reasoning",
text: "Thinking: plan next steps",
phase: "progress",
source: "reasoning",
}),
reasoning,
),
).toBe(true)
expect(
entryBody(
commit({
kind: "user",
text: "Inspect footer tabs",
phase: "start",
source: "system",
}),
),
).toEqual({
type: "text",
content: " Inspect footer tabs",
})
})
for (const item of [
{
name: "keeps completed write tool finals structured",
commit: toolCommit({
tool: "write",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
content: "const x = 1\n",
},
output: "",
title: "",
metadata: {},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "code",
title: "# Wrote src/a.ts",
content: "const x = 1\n",
file: "src/a.ts",
},
},
{
name: "keeps completed edit tool finals structured",
commit: toolCommit({
tool: "edit",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
},
output: "",
title: "",
metadata: {
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "diff",
items: [
{
title: "# Edited src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
file: "src/a.ts",
},
],
},
},
{
name: "keeps completed apply_patch tool finals structured",
commit: toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
},
],
},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "diff",
items: [
{
title: "# Patched src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
file: "src/a.ts",
deletions: 0,
},
],
},
},
] satisfies Array<{ name: string; commit: StreamCommit; snapshot: ToolSnapshot }>) {
test(item.name, () => {
expect(structured(item.commit)).toEqual(item.snapshot)
})
}
test("keeps running task tool state out of scrollback", () => {
expect(
entryBody(
toolCommit({
tool: "task",
phase: "start",
toolState: "running",
text: "running inspect reducer",
state: {
status: "running",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
time: { start: 1 },
},
}),
),
).toEqual({
type: "none",
})
})
test("promotes task results to markdown and falls back to structured task summaries", () => {
expect(
entryBody(
toolCommit({
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
title: "",
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"# Findings\n\n- Footer stays live",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "markdown",
content: "# Findings\n\n- Footer stays live",
})
expect(
structured(
toolCommit({
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
title: "",
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
kind: "task",
title: "# Explore Task",
rows: ["Inspect reducer"],
tail: "",
})
})
test("streams tool progress text and treats completed progress as done", () => {
const body = entryBody(
commit({
kind: "tool",
text: "partial output",
phase: "progress",
source: "tool",
tool: "bash",
partID: "tool-2",
}),
)
expect(body).toEqual({
type: "text",
content: "partial output",
})
expect(
entryCanStream(
commit({
kind: "tool",
text: "partial output",
phase: "progress",
source: "tool",
tool: "bash",
}),
body,
),
).toBe(true)
expect(
entryDone(
commit({
kind: "tool",
text: "output",
phase: "progress",
source: "tool",
tool: "bash",
toolState: "completed",
}),
),
).toBe(true)
})
test("formats completed bash output with a blank line after the command and no trailing blank row", () => {
expect(
entryBody(
toolCommit({
tool: "bash",
phase: "progress",
toolState: "completed",
text: [
"/tmp/demo",
"git status",
"On branch demo",
"nothing to commit, working tree clean",
"",
].join("\n"),
state: {
status: "completed",
input: {
command: "git status",
workdir: "/tmp/demo",
},
output: [
"/tmp/demo",
"git status",
"On branch demo",
"nothing to commit, working tree clean",
"",
].join("\n"),
title: "git status",
metadata: {
exitCode: 0,
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "\nOn branch demo\nnothing to commit, working tree clean",
})
})
test("falls back to patch summary when apply_patch has no visible diff items", () => {
expect(
entryBody(
toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {
patchText: "*** Begin Patch\n*** End Patch",
},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
],
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "~ Patched src/a.ts",
})
})
test("suppresses redundant patched rows when apply_patch also created a file", () => {
expect(
entryBody(
toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {
patchText: "*** Begin Patch\n*** End Patch",
},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
{
type: "add",
filePath: "README-demo.md",
relativePath: "README-demo.md",
},
],
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "+ Created README-demo.md",
})
})
test("renders glob failures as the raw error under the existing header", () => {
expect(
entryBody(
toolCommit({
tool: "glob",
phase: "final",
toolState: "error",
state: {
status: "error",
input: {
pattern: "**/*tool*",
path: "/tmp/demo/run",
},
error: "No such file or directory: '/tmp/demo/run'",
metadata: {},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "No such file or directory: '/tmp/demo/run'",
})
})
test("renders interrupted assistant finals as text", () => {
expect(
entryBody(
commit({
kind: "assistant",
text: "",
phase: "final",
source: "assistant",
interrupted: true,
partID: "part-1",
}),
),
).toEqual({
type: "text",
content: "assistant interrupted",
})
})
})
@@ -0,0 +1,43 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu"
function mount(count: number, limit = FOOTER_MENU_ROWS) {
let dispose!: () => void
let menu!: ReturnType<typeof createFooterMenuState>
createRoot((nextDispose) => {
dispose = nextDispose
menu = createFooterMenuState({ count: () => count, limit })
return null
})
return { menu, dispose }
}
test("footer menu scrolls before the selected row hits the bottom edge", () => {
const state = mount(20)
try {
Array.from({ length: 6 }).forEach(() => state.menu.move(1))
expect(state.menu.selected()).toBe(6)
expect(state.menu.offset()).toBe(1)
} finally {
state.dispose()
}
})
test("footer menu scrolls before the selected row hits the top edge", () => {
const state = mount(20)
try {
Array.from({ length: 13 }).forEach(() => state.menu.move(1))
Array.from({ length: 4 }).forEach(() => state.menu.move(-1))
expect(state.menu.selected()).toBe(9)
expect(state.menu.offset()).toBe(7)
} finally {
state.dispose()
}
})
@@ -0,0 +1,250 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { RUN_COMMAND_PANEL_ROWS, RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "@/cli/cmd/run/footer.command"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import type { RunCommand, RunInput, RunProvider, StreamCommit } from "@/cli/cmd/run/types"
function command(input: { name: string; description: string; source?: "command" | "mcp" | "skill" }) {
return {
name: input.name,
description: input.description,
source: input.source,
template: "",
hints: [],
} satisfies RunCommand
}
function model(input: {
id: string
name: string
status?: "active" | "deprecated"
cost?: number
variants?: Record<string, Record<string, never>>
}) {
return {
id: input.id,
providerID: "opencode",
api: {
id: "opencode",
url: "https://opencode.ai",
npm: "@ai-sdk/openai-compatible",
},
name: input.name,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: true,
video: false,
pdf: true,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: input.cost ?? 1,
output: 1,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: input.status ?? "active",
options: {},
headers: {},
release_date: "2026-01-01",
variants: input.variants,
} satisfies RunProvider["models"][string]
}
function provider() {
return {
id: "opencode",
name: "opencode",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model({ id: "gpt-5", name: "GPT-5", variants: { high: {}, minimal: {} } }),
"gpt-free": model({ id: "gpt-free", name: "GPT Free", cost: 0 }),
old: model({ id: "old", name: "Old Model", status: "deprecated" }),
},
} satisfies RunProvider
}
test("run entry content updates when live commit text changes", async () => {
const [commit, setCommit] = createSignal<StreamCommit>({
kind: "tool",
text: "I",
phase: "progress",
source: "tool",
messageID: "msg-1",
partID: "part-1",
tool: "bash",
})
const app = await testRender(() => (
<box width={80} height={4}>
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} width={80} />
</box>
), {
width: 80,
height: 4,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("I")
setCommit({
kind: "tool",
text: "I need to inspect the codebase",
phase: "progress",
source: "tool",
messageID: "msg-1",
partID: "part-1",
tool: "bash",
})
await app.renderOnce()
expect(app.captureCharFrame()).toContain("I need to inspect the codebase")
} finally {
app.renderer.destroy()
}
})
test("direct command panel renders grouped command palette", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([
command({ name: "review", description: "Review code" }),
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
command({ name: "internal", description: "Skill command", source: "skill" }),
])
const [variants] = createSignal(["high", "minimal"])
const app = await testRender(() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunCommandMenuBody
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
variants={variants}
onClose={() => {}}
onModel={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onCommand={() => {}}
onNew={() => {}}
onExit={() => {}}
/>
</box>
), {
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Commands")
expect(frame).toContain("Search")
expect(frame).toContain("Suggested")
expect(frame).toContain("Switch model")
expect(frame).toContain("Variant cycle")
expect(frame).toContain("Switch model variant")
expect(frame).toContain("Session")
expect(frame).toContain("New session")
expect(frame).toContain("Project Commands")
expect(frame).toContain("review")
expect(frame).not.toContain("/review")
expect(frame).not.toContain("/internal")
expect(frame).not.toContain("Commands 8")
} finally {
app.renderer.destroy()
}
})
test("direct model panel renders current model selector", async () => {
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
const app = await testRender(() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunModelSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
providers={providers}
current={current}
onClose={() => {}}
onSelect={() => {}}
/>
</box>
), {
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Select model")
expect(frame).toContain("Search")
expect(frame).toContain("opencode")
expect(frame).toContain("GPT-5")
expect(frame).toContain("current")
expect(frame).toContain("GPT Free")
expect(frame).toContain("Free")
expect(frame).not.toContain("Old Model")
} finally {
app.renderer.destroy()
}
})
test("direct variant panel renders current variant selector", async () => {
const [variants] = createSignal(["high", "minimal"])
const [current] = createSignal<string | undefined>("high")
const app = await testRender(() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunVariantSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
variants={variants}
current={current}
onClose={() => {}}
onSelect={() => {}}
/>
</box>
), {
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Select variant")
expect(frame).toContain("Default")
expect(frame).toContain("high")
expect(frame).toContain("minimal")
expect(frame).toContain("current")
} finally {
app.renderer.destroy()
}
})
@@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import {
createPermissionBodyState,
permissionAlwaysLines,
permissionCancel,
permissionEscape,
permissionInfo,
permissionReject,
permissionRun,
} from "@/cli/cmd/run/permission.shared"
function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
return {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: [],
metadata: {},
always: [],
...input,
}
}
describe("run permission shared", () => {
test("replies immediately for allow once", () => {
const out = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "once")
expect(out.reply).toEqual({
requestID: "perm-1",
reply: "once",
})
})
test("requires confirmation for allow always", () => {
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "always")
expect(next.state.stage).toBe("always")
expect(next.state.selected).toBe("confirm")
expect(next.reply).toBeUndefined()
expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({
requestID: "perm-1",
reply: "always",
})
expect(permissionRun(next.state, "perm-1", "cancel").state).toMatchObject({
stage: "permission",
selected: "always",
})
})
test("builds trimmed reject replies and stage transitions", () => {
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "reject")
expect(next.state.stage).toBe("reject")
const out = permissionReject({ ...next.state, message: " use rg " }, "perm-1")
expect(out).toEqual({
requestID: "perm-1",
reply: "reject",
message: "use rg",
})
expect(permissionCancel(next.state)).toMatchObject({
stage: "permission",
selected: "reject",
})
expect(permissionEscape(createPermissionBodyState("perm-1"))).toMatchObject({
stage: "reject",
selected: "reject",
})
expect(permissionEscape({ ...next.state, stage: "always", selected: "confirm" })).toMatchObject({
stage: "permission",
selected: "always",
})
})
test("maps supported permission types into display info", () => {
expect(
permissionInfo(
req({
permission: "bash",
metadata: {
input: {
command: "git status --short",
},
},
}),
),
).toMatchObject({
title: "Shell command",
lines: ["$ git status --short"],
})
expect(
permissionInfo(
req({
permission: "task",
metadata: {
description: "investigate stream",
subagent_type: "general",
},
}),
),
).toMatchObject({
title: "General Task",
lines: ["◉ investigate stream"],
})
expect(
permissionInfo(
req({
permission: "external_directory",
patterns: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"],
}),
),
).toMatchObject({
title: "Access external directory /tmp/work",
lines: ["- /tmp/work/**/*.ts", "- /tmp/work/**/*.tsx"],
})
expect(permissionInfo(req({ permission: "doom_loop" }))).toMatchObject({
title: "Continue after repeated failures",
})
expect(permissionInfo(req({ permission: "custom_tool" }))).toMatchObject({
title: "Call tool custom_tool",
lines: ["Tool: custom_tool"],
})
})
test("formats always-allow copy for wildcard and explicit patterns", () => {
expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([
"This will allow bash until OpenCode is restarted.",
])
expect(permissionAlwaysLines(req({ always: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([
"This will allow the following patterns until OpenCode is restarted.",
"- src/**/*.ts",
"- src/**/*.tsx",
])
})
})
@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test"
import {
createPromptHistory,
isExitCommand,
isNewCommand,
movePromptHistory,
printableBinding,
promptCycle,
promptHit,
promptInfo,
promptKeys,
pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared"
import type { FooterKeybinds, RunPrompt } from "@/cli/cmd/run/types"
const keybinds: FooterKeybinds = {
leader: "ctrl+x",
commandList: "ctrl+p",
variantCycle: "ctrl+t,<leader>t",
interrupt: "escape",
historyPrevious: "up",
historyNext: "down",
inputClear: "ctrl+c",
inputSubmit: "return",
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
}
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
return { text, parts }
}
describe("run prompt shared", () => {
test("filters blank prompts and dedupes consecutive history", () => {
const out = createPromptHistory([prompt(" "), prompt("one"), prompt("one"), prompt("two"), prompt("one")])
expect(out.items.map((item) => item.text)).toEqual(["one", "two", "one"])
expect(out.index).toBeNull()
expect(out.draft).toBe("")
})
test("push ignores blanks and dedupes only the latest item", () => {
const base = createPromptHistory([prompt("one")])
expect(pushPromptHistory(base, prompt(" ")).items.map((item) => item.text)).toEqual(["one"])
expect(pushPromptHistory(base, prompt("one")).items.map((item) => item.text)).toEqual(["one"])
expect(pushPromptHistory(base, prompt("two")).items.map((item) => item.text)).toEqual(["one", "two"])
})
test("moves through history only at input boundaries and restores draft", () => {
const base = createPromptHistory([prompt("one"), prompt("two")])
expect(movePromptHistory(base, -1, "draft", 1)).toEqual({
state: base,
apply: false,
})
const up = movePromptHistory(base, -1, "draft", 0)
expect(up.apply).toBe(true)
expect(up.text).toBe("two")
expect(up.cursor).toBe(0)
expect(up.state.index).toBe(1)
expect(up.state.draft).toBe("draft")
const older = movePromptHistory(up.state, -1, "two", 0)
expect(older.apply).toBe(true)
expect(older.text).toBe("one")
expect(older.cursor).toBe(0)
expect(older.state.index).toBe(0)
const newer = movePromptHistory(older.state, 1, "one", 3)
expect(newer.apply).toBe(true)
expect(newer.text).toBe("two")
expect(newer.cursor).toBe(3)
expect(newer.state.index).toBe(1)
const draft = movePromptHistory(newer.state, 1, "two", 3)
expect(draft.apply).toBe(true)
expect(draft.text).toBe("draft")
expect(draft.cursor).toBe(5)
expect(draft.state.index).toBeNull()
})
test("handles direct and leader-based variant cycling", () => {
const keys = promptKeys(keybinds)
expect(promptHit(keys.clear, promptInfo({ name: "c", ctrl: true }))).toBe(true)
expect(promptCycle(false, promptInfo({ name: "x", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: true,
clear: false,
cycle: false,
consume: true,
})
expect(promptCycle(true, promptInfo({ name: "t" }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: true,
cycle: true,
consume: true,
})
expect(promptCycle(false, promptInfo({ name: "t", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: false,
cycle: true,
consume: true,
})
})
test("prints bindings with leader substitution and esc normalization", () => {
expect(printableBinding("<leader>t", "ctrl+x")).toBe("ctrl+x t")
expect(printableBinding("escape", "ctrl+x")).toBe("esc")
expect(printableBinding("", "ctrl+x")).toBe("")
})
test("recognizes exit commands", () => {
expect(isExitCommand("/exit")).toBe(true)
expect(isExitCommand(" /Quit ")).toBe(true)
expect(isExitCommand("/quit now")).toBe(false)
})
test("recognizes the new-session command", () => {
expect(isNewCommand("/new")).toBe(true)
expect(isNewCommand(" /NEW ")).toBe(true)
expect(isNewCommand("/new now")).toBe(false)
})
})
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import {
createQuestionBodyState,
questionConfirm,
questionReject,
questionSave,
questionSelect,
questionSetSelected,
questionStoreCustom,
questionSubmit,
questionSync,
} from "@/cli/cmd/run/question.shared"
function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
return {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
],
...input,
}
}
describe("run question shared", () => {
test("replies immediately for a single-select question", () => {
const out = questionSelect(createQuestionBodyState("question-1"), req())
expect(out.reply).toEqual({
requestID: "question-1",
answers: [["chunked"]],
})
})
test("advances multi-question flows and submits from confirm", () => {
const ask = req({
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
{
question: "Output?",
header: "Output",
options: [
{ label: "yes", description: "Show tool output" },
{ label: "no", description: "Hide tool output" },
],
multiple: false,
},
],
})
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
expect(state.tab).toBe(1)
state = questionSetSelected(state, 1)
state = questionSelect(state, ask).state
expect(questionConfirm(ask, state)).toBe(true)
expect(questionSubmit(ask, state)).toEqual({
requestID: "question-1",
answers: [["chunked"], ["no"]],
})
})
test("toggles answers for multiple-choice questions", () => {
const ask = req({
questions: [
{
question: "Tags?",
header: "Tags",
options: [{ label: "bug", description: "Bug fix" }],
multiple: true,
},
],
})
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
expect(state.answers).toEqual([["bug"]])
state = questionSelect(state, ask).state
expect(state.answers).toEqual([[]])
})
test("stores and submits custom answers", () => {
let state = questionSetSelected(createQuestionBodyState("question-1"), 1)
let next = questionSelect(state, req())
expect(next.state.editing).toBe(true)
state = questionStoreCustom(next.state, 0, " custom mode ")
next = questionSave(state, req())
expect(next.reply).toEqual({
requestID: "question-1",
answers: [["custom mode"]],
})
})
test("resets state when the request id changes and builds reject payloads", () => {
const state = questionSetSelected(createQuestionBodyState("question-1"), 1)
expect(questionSync(state, "question-1")).toBe(state)
expect(questionSync(state, "question-2")).toEqual(createQuestionBodyState("question-2"))
expect(questionReject(req())).toEqual({
requestID: "question-1",
})
})
})
@@ -0,0 +1,239 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import {
resolveDiffStyle,
resolveFooterKeybinds,
resolveModelInfo,
} from "@/cli/cmd/run/runtime.boot"
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
return {
id,
providerID,
api: {
id: providerID,
url: `https://${providerID}.test`,
npm: `@ai-sdk/${providerID}`,
},
name: id,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context,
output: 8192,
},
status: "active" as const,
options: {},
headers: {},
release_date: "2026-01-01",
variants,
}
}
describe("run runtime boot", () => {
afterEach(() => {
mock.restore()
})
test("merges footer keybind config and injects leader cycle once", async () => {
spyOn(TuiConfig, "get").mockResolvedValue({
keybinds: {
leader: " ctrl+g ",
variant_cycle: " ctrl+t, <leader>t , alt+t ",
session_interrupt: " ctrl+c ",
command_list: " ctrl+p ",
history_previous: " k ",
history_next: " j ",
input_clear: " ctrl+l ",
input_submit: " ctrl+s ",
input_newline: " alt+return ",
},
})
await expect(resolveFooterKeybinds()).resolves.toEqual({
leader: "ctrl+g",
commandList: "ctrl+p",
variantCycle: "ctrl+t,<leader>t,alt+t",
interrupt: "ctrl+c",
historyPrevious: "k",
historyNext: "j",
inputClear: "ctrl+l",
inputSubmit: "ctrl+s",
inputNewline: "alt+return",
})
})
test("falls back to default keybinds when config load fails", async () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
await expect(resolveFooterKeybinds()).resolves.toEqual({
leader: "ctrl+x",
commandList: "ctrl+p",
variantCycle: "ctrl+t,<leader>t",
interrupt: "escape",
historyPrevious: "up",
historyNext: "down",
inputClear: "ctrl+c",
inputSubmit: "return",
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
})
})
test("prefers configured providers for model selector data", async () => {
const sdk = new OpencodeClient()
const data: {
all: Provider[]
default: Record<string, string>
connected: string[]
} = {
all: [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model("gpt-5", "openai", 128000, {
high: {},
minimal: {},
}),
},
},
{
id: "anthropic",
name: "Anthropic",
source: "api",
env: [],
options: {},
models: {
sonnet: model("sonnet", "anthropic", 200000),
},
},
],
default: {},
connected: [],
}
const configured = {
providers: [data.all[0]!],
default: {},
}
const list = spyOn(sdk.provider, "list").mockImplementation(() =>
Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
spyOn(sdk.config, "providers").mockImplementation(() =>
Promise.resolve({
data: configured,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: configured.providers,
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,
},
})
expect(list).not.toHaveBeenCalled()
})
test("falls back to provider list when configured providers are unavailable", async () => {
const sdk = new OpencodeClient()
const data: {
all: Provider[]
default: Record<string, string>
connected: string[]
} = {
all: [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model("gpt-5", "openai", 128000, {
high: {},
minimal: {},
}),
},
},
{
id: "anthropic",
name: "Anthropic",
source: "api",
env: [],
options: {},
models: {
sonnet: model("sonnet", "anthropic", 200000),
},
},
],
default: {},
connected: [],
}
spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom"))
spyOn(sdk.provider, "list").mockImplementation(() =>
Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: data.all,
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,
"anthropic/sonnet": 200000,
},
})
})
test("reads diff style and falls back to auto", async () => {
spyOn(TuiConfig, "get").mockResolvedValue({ diff_style: "stacked" })
await expect(resolveDiffStyle()).resolves.toBe("stacked")
mock.restore()
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
await expect(resolveDiffStyle()).resolves.toBe("auto")
})
})
@@ -0,0 +1,318 @@
import { describe, expect, test } from "bun:test"
import { runPromptQueue } from "@/cli/cmd/run/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()
const closes = new Set<() => void>()
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt(fn) {
prompts.add(fn)
return () => {
prompts.delete(fn)
}
},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event(next) {
events.push(next)
},
append(next) {
commits.push(next)
},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
for (const fn of [...closes]) {
fn()
}
},
destroy() {
api.close()
prompts.clear()
closes.clear()
},
}
return {
api,
events,
commits,
submit(text: string) {
const next = { text, parts: [] as RunPrompt["parts"] }
for (const fn of [...prompts]) {
fn(next)
}
},
}
}
describe("run runtime queue", () => {
test("ignores empty prompts", async () => {
const ui = footer()
let calls = 0
const task = runPromptQueue({
footer: ui.api,
run: async () => {
calls += 1
},
})
ui.submit(" ")
ui.api.close()
await task
expect(calls).toBe(0)
})
test("treats /exit as a close command", async () => {
const ui = footer()
let calls = 0
const task = runPromptQueue({
footer: ui.api,
run: async () => {
calls += 1
},
})
ui.submit("/exit")
await task
expect(calls).toBe(0)
})
test("treats /new as a local session command", async () => {
const ui = footer()
const seen: string[] = []
let created = 0
const task = runPromptQueue({
footer: ui.api,
onNewSession: async () => {
created += 1
},
run: async (input) => {
seen.push(input.text)
ui.api.close()
},
})
ui.submit("/new")
ui.submit("hello")
await task
expect(created).toBe(1)
expect(seen).toEqual(["hello"])
expect(ui.commits).toEqual([
{
kind: "user",
text: "hello",
phase: "start",
source: "system",
},
])
})
test("preserves whitespace for initial input", async () => {
const ui = footer()
const seen: string[] = []
await runPromptQueue({
footer: ui.api,
initialInput: " hello ",
run: async (input) => {
seen.push(input.text)
ui.api.close()
},
})
expect(seen).toEqual([" hello "])
expect(ui.commits).toEqual([
{
kind: "user",
text: " hello ",
phase: "start",
source: "system",
},
])
})
test("passes prompts to onSend", async () => {
const ui = footer()
const seen: string[] = []
await runPromptQueue({
footer: ui.api,
initialInput: " hello ",
onSend: (input) => {
seen.push(input.text)
},
run: async () => {
ui.api.close()
},
})
expect(seen).toEqual([" hello "])
})
test("appends the user row before the turn starts", async () => {
const ui = footer()
await runPromptQueue({
footer: ui.api,
initialInput: "/fmt bash",
run: async () => {
expect(ui.commits).toEqual([
{
kind: "user",
text: "/fmt bash",
phase: "start",
source: "system",
},
])
ui.api.close()
},
})
})
test("runs queued prompts in order", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
},
})
ui.submit("one")
ui.submit("two")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await task
expect(seen).toEqual(["one", "two"])
})
test("drains a prompt queued during an in-flight turn", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
},
})
ui.submit("one")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await Promise.resolve()
ui.submit("two")
await task
expect(seen).toEqual(["one", "two"])
})
test("close aborts the active run and drops pending queued work", async () => {
const ui = footer()
const seen: string[] = []
let hit = false
const task = runPromptQueue({
footer: ui.api,
run: async (input, signal) => {
seen.push(input.text)
await new Promise<void>((resolve) => {
if (signal.aborted) {
hit = true
resolve()
return
}
signal.addEventListener(
"abort",
() => {
hit = true
resolve()
},
{ once: true },
)
})
},
})
ui.submit("one")
await Promise.resolve()
ui.submit("two")
ui.api.close()
await task
expect(hit).toBe(true)
expect(seen).toEqual(["one"])
})
test("propagates run errors", async () => {
const ui = footer()
const task = runPromptQueue({
footer: ui.api,
run: async () => {
throw new Error("boom")
},
})
ui.submit("one")
await expect(task).rejects.toThrow("boom")
})
})
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { Readable } from "node:stream"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin"
function stream(isTTY: boolean) {
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
}
describe("run interactive stdin", () => {
test("reuses stdin when it is already a tty", () => {
const stdin = stream(true)
const seen: string[] = []
const result = resolveInteractiveStdin(
stdin,
(path) => {
seen.push(path)
return stream(true)
},
"linux",
)
expect(result.stdin).toBe(stdin)
expect(result.cleanup).toBeUndefined()
expect(seen).toEqual([])
})
test("opens the controlling terminal when stdin is piped", () => {
const tty = stream(true)
const seen: string[] = []
const result = resolveInteractiveStdin(
stream(false),
(path) => {
seen.push(path)
return tty
},
"linux",
)
expect(result.stdin).toBe(tty)
expect(seen).toEqual(["/dev/tty"])
result.cleanup?.()
expect(tty.destroyed).toBe(true)
})
test("uses CONIN$ on windows", () => {
const seen: string[] = []
resolveInteractiveStdin(
stream(false),
(path) => {
seen.push(path)
return stream(true)
},
"win32",
)
expect(seen).toEqual(["CONIN$"])
})
test("throws a clear error when no controlling terminal is available", () => {
expect(() =>
resolveInteractiveStdin(
stream(false),
() => {
throw new Error("open failed")
},
"linux",
),
).toThrow(INTERACTIVE_INPUT_ERROR)
})
})
@@ -0,0 +1,879 @@
import { afterEach, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import type { StreamCommit } from "@/cli/cmd/run/types"
type ClaimedCommit = {
snapshot: {
height: number
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
destroy(): void
}
trailingNewline: boolean
}
type QueueRenderer = {
externalOutputQueue: {
claim(): ClaimedCommit[]
}
}
const decoder = new TextDecoder()
const active: TestRenderer[] = []
afterEach(() => {
for (const renderer of active.splice(0)) {
renderer.destroy()
}
})
function claim(renderer: TestRenderer) {
return (renderer as unknown as QueueRenderer).externalOutputQueue.claim()
}
function renderCommit(commit: ClaimedCommit) {
return decoder.decode(commit.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n")
}
function render(commits: ClaimedCommit[]) {
return commits.map(renderCommit).join("")
}
function renderRows(commit: ClaimedCommit, width = 80) {
const raw = decoder.decode(commit.snapshot.getRealCharBytes(true))
return Array.from({ length: commit.snapshot.height }, (_, index) =>
raw.slice(index * width, (index + 1) * width).trimEnd(),
)
}
function destroy(commits: ClaimedCommit[]) {
for (const commit of commits) {
commit.snapshot.destroy()
}
}
async function setup(input: {
width?: number
wrote?: boolean
} = {}) {
const out = await createTestRenderer({
width: input.width ?? 80,
screenMode: "split-footer",
footerHeight: 6,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
active.push(out.renderer)
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
treeSitterClient.setMockResult({ highlights: [] })
return {
renderer: out.renderer,
scrollback: new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
treeSitterClient,
wrote: input.wrote ?? false,
}),
}
}
function assistant(text: string, phase: StreamCommit["phase"] = "progress"): StreamCommit {
return {
kind: "assistant",
text,
phase,
source: "assistant",
messageID: "msg-1",
partID: "part-1",
}
}
function user(text: string): StreamCommit {
return {
kind: "user",
text,
phase: "start",
source: "system",
}
}
function error(text: string): StreamCommit {
return {
kind: "error",
text,
phase: "start",
source: "system",
}
}
function toolPart(tool: string, state: Record<string, unknown>, id: string, messageID: string): ToolPart {
return {
id,
sessionID: "session-1",
messageID,
type: "tool",
callID: `call-${id}`,
tool,
state,
} as ToolPart
}
function toolCommit(input: {
tool: string
phase: StreamCommit["phase"]
toolState?: StreamCommit["toolState"]
text?: string
state?: Record<string, unknown>
id?: string
messageID?: string
}): StreamCommit {
const id = input.id ?? `${input.tool}-1`
const messageID = input.messageID ?? `msg-${input.tool}`
return {
kind: "tool",
text: input.text ?? "",
phase: input.phase,
source: "tool",
partID: id,
messageID,
tool: input.tool,
...(input.toolState ? { toolState: input.toolState } : {}),
...(input.state ? { part: toolPart(input.tool, input.state, id, messageID) } : {}),
}
}
test("finalizes markdown tables for streamed and coalesced input", async () => {
const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
for (const chunks of [[text], [...text]]) {
const out = await setup()
try {
for (const chunk of chunks) {
await out.scrollback.append(assistant(chunk))
}
await out.scrollback.complete()
const commits = claim(out.renderer)
try {
const output = render(commits)
expect(output).toContain("Column 1")
expect(output).toContain("Row 2")
expect(output).toContain("Value 4")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
}
})
test("holds markdown code blocks until final commit and keeps newline ownership", async () => {
const out = await setup()
try {
await out.scrollback.append(
assistant('# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = "Hello, markdown"\nconsole.log(message)\n```'),
)
const progress = claim(out.renderer)
try {
expect(progress).toHaveLength(1)
expect(render(progress)).toContain("Markdown Sample")
expect(render(progress)).toContain("Item 2")
expect(render(progress)).not.toContain("console.log(message)")
} finally {
destroy(progress)
}
await out.scrollback.complete()
const final = claim(out.renderer)
try {
expect(final).toHaveLength(1)
expect(final[0]!.trailingNewline).toBe(false)
expect(render(final)).toContain('const message = "Hello, markdown"')
expect(render(final)).toContain("console.log(message)")
} finally {
destroy(final)
}
} finally {
out.scrollback.destroy()
}
})
test("renders todo and question summaries without boilerplate footer copy", async () => {
const cases = [
{
title: "# Todos",
include: [
"[✓] List files under `run/`",
"[•] Count functions in each `run/` file",
"[ ] Mark each tracking item complete",
],
exclude: ["Updating", "todos completed"],
start: toolCommit({
tool: "todowrite",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
time: { start: 1 },
},
}),
final: toolCommit({
tool: "todowrite",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
metadata: {},
time: { start: 1, end: 4 },
},
}),
},
{
title: "# Questions",
include: ["What should I work on in the codebase next?", "Bug fix"],
exclude: ["Asked", "questions completed"],
start: toolCommit({
tool: "question",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
questions: [
{
question: "What should I work on in the codebase next?",
header: "Next work",
options: [{ label: "bug", description: "Bug fix" }],
multiple: false,
},
],
},
time: { start: 1 },
},
}),
final: toolCommit({
tool: "question",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
questions: [
{
question: "What should I work on in the codebase next?",
header: "Next work",
options: [{ label: "bug", description: "Bug fix" }],
multiple: false,
},
],
},
metadata: {
answers: [["Bug fix"]],
},
time: { start: 1, end: 2100 },
},
}),
},
]
for (const item of cases) {
const out = await setup()
try {
await out.scrollback.append(item.start)
expect(claim(out.renderer)).toHaveLength(0)
await out.scrollback.append(item.final)
const commits = claim(out.renderer)
try {
expect(commits).toHaveLength(1)
const rows = renderRows(commits[0]!)
const output = rows.join("\n")
expect(output).toContain(item.title)
for (const line of item.include) {
expect(output).toContain(line)
}
for (const line of item.exclude) {
expect(output).not.toContain(line)
}
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
}
})
test("inserts spacers for new visible groups", async () => {
const prior = await setup({ wrote: true })
try {
await prior.scrollback.append(user("use subagent to explore run.ts"))
const commits = claim(prior.renderer)
try {
expect(commits).toHaveLength(2)
expect(renderCommit(commits[0]!).trim()).toBe("")
expect(renderCommit(commits[1]!).trim()).toBe(" use subagent to explore run.ts")
} finally {
destroy(commits)
}
} finally {
prior.scrollback.destroy()
}
const grouped = await setup()
try {
await grouped.scrollback.append(assistant("hello"))
await grouped.scrollback.complete()
destroy(claim(grouped.renderer))
await grouped.scrollback.append(
toolCommit({
tool: "glob",
phase: "start",
text: "running glob",
toolState: "running",
state: {
status: "running",
input: {
pattern: "**/run.ts",
},
time: { start: 1 },
},
}),
)
const commits = claim(grouped.renderer)
try {
expect(commits).toHaveLength(2)
expect(renderCommit(commits[0]!).trim()).toBe("")
expect(renderCommit(commits[1]!).replace(/ +/g, " ").trim()).toBe('✱ Glob "**/run.ts"')
} finally {
destroy(commits)
}
} finally {
grouped.scrollback.destroy()
}
})
test("coalesces same-line tool progress into one snapshot", async () => {
const out = await setup()
try {
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "abc" }))
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "def" }))
await out.scrollback.append(toolCommit({ tool: "bash", phase: "final", text: "", toolState: "completed" }))
const commits = claim(out.renderer)
try {
expect(commits).toHaveLength(1)
expect(render(commits)).toContain("abcdef")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
test("renders completed bash output with one blank line after the command and before the next group", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = () => {
const commits = claim(out.renderer)
try {
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(user("/fmt bash"))
take()
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
command: "git status",
workdir: "/tmp/demo",
description: "Show git status",
},
time: { start: 1 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "progress",
toolState: "completed",
text: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join("\n"),
state: {
status: "completed",
input: {
command: "git status",
workdir: "/tmp/demo",
description: "Show git status",
},
time: { start: 1, end: 2 },
},
}),
)
take()
await out.scrollback.append(assistant("oc-run-dev ahead 1"))
await out.scrollback.complete()
take()
const output = lines.join("\n")
expect(output).toContain("$ git status\n\nOn branch demo")
expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1")
expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1")
} finally {
out.scrollback.destroy()
}
})
test("inserts a spacer before the next tool after completed multiline bash output", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = () => {
const commits = claim(out.renderer)
try {
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
command: "pwd; ls -la",
workdir: "/tmp/demo",
description: "Lists current directory files",
},
time: { start: 1 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "progress",
toolState: "completed",
text: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"),
state: {
status: "completed",
input: {
command: "pwd; ls -la",
workdir: "/tmp/demo",
description: "Lists current directory files",
},
output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"),
title: "pwd; ls -la",
metadata: {
exitCode: 0,
},
time: { start: 1, end: 2 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "glob",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
pattern: "**/*tool*",
path: "src/cli/cmd",
},
time: { start: 3 },
},
}),
)
take()
const output = lines.join("\n")
expect(output).toContain("total 4\n\n✱ Glob \"**/*tool*\" in src/cli/cmd")
} finally {
out.scrollback.destroy()
}
})
test("does not double-space before completed bash output when inline tool headers intervene", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = () => {
const commits = claim(out.renderer)
try {
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
command: "ls",
workdir: "src/cli/cmd/run",
description: "Lists files in run directory",
},
time: { start: 1 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "glob",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
pattern: "**/*tool*",
path: "src/cli/cmd/run",
},
time: { start: 2 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "grep",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
pattern: "tool",
path: "src/cli/cmd/run",
},
time: { start: 3 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "progress",
toolState: "completed",
text: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
state: {
status: "completed",
input: {
command: "ls",
workdir: "src/cli/cmd/run",
description: "Lists files in run directory",
},
output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
title: "ls",
metadata: {
exitCode: 0,
},
time: { start: 1, end: 4 },
},
}),
)
take()
const output = lines.join("\n")
expect(output).toContain('✱ Grep "tool" in src/cli/cmd/run\n\ndemo.ts')
expect(output).not.toContain('✱ Grep "tool" in src/cli/cmd/run\n\n\ndemo.ts')
} finally {
out.scrollback.destroy()
}
})
test("does not emit blank patch snapshots between edit and task", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = () => {
const commits = claim(out.renderer)
try {
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(
toolCommit({
tool: "edit",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
filePath: "src/demo-format.ts",
},
output: "",
title: "edit",
metadata: {
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
},
time: { start: 1, end: 2 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "apply_patch",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
patchText: "*** Begin Patch\n*** End Patch",
},
output: "",
title: "apply_patch",
metadata: {
files: [
{
type: "update",
filePath: "src/demo-format.ts",
relativePath: "src/demo-format.ts",
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
deletions: 1,
},
{
type: "add",
filePath: "README-demo.md",
relativePath: "README-demo.md",
},
],
},
time: { start: 2, end: 3 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "task",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
description: "Scan run/* for reducer touchpoints",
subagent_type: "explore",
},
output: "",
title: "task",
metadata: {
sessionId: "sub_demo_1",
},
time: { start: 3, end: 4 },
},
}),
)
take()
const output = lines.join("\n")
expect(output).toContain("+ Created README-demo.md")
expect(output).not.toContain("~ Patched src/demo-format.ts")
expect(output).toContain("+ Created README-demo.md\n\n# Explore Task")
expect(output).not.toContain("+ Created README-demo.md\n\n\n# Explore Task")
} finally {
out.scrollback.destroy()
}
})
test("renders plain errors with one blank line before and after the error block", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = (check?: (commits: ClaimedCommit[]) => void) => {
const commits = claim(out.renderer)
try {
check?.(commits)
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(user("/fmt error"))
take()
await out.scrollback.append(error("demo error event"))
take((commits) => {
expect(commits.at(-1)?.trailingNewline).toBe(false)
})
await out.scrollback.append(assistant("next line"))
await out.scrollback.complete()
take()
const output = lines.join("\n")
expect(output).toContain(" /fmt error\n\ndemo error event")
expect(output).toContain("demo error event\n\nnext line")
expect(output).not.toContain("demo error event\n\n\nnext line")
} finally {
out.scrollback.destroy()
}
})
test("renders structured write finals once as code blocks", async () => {
const out = await setup()
try {
await out.scrollback.append(
toolCommit({
tool: "write",
phase: "start",
toolState: "running",
id: "tool-2",
messageID: "msg-2",
state: {
status: "running",
input: {
filePath: "src/a.ts",
content: "const x = 1\nconst y = 2\n",
},
time: { start: 1 },
},
}),
)
expect(claim(out.renderer)).toHaveLength(0)
await out.scrollback.append(
toolCommit({
tool: "write",
phase: "final",
toolState: "completed",
id: "tool-2",
messageID: "msg-2",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
content: "const x = 1\nconst y = 2\n",
},
metadata: {},
time: { start: 1, end: 2 },
},
}),
)
const commits = claim(out.renderer)
try {
expect(commits).toHaveLength(1)
const output = render(commits[0] ? [commits[0]] : [])
expect(output).toContain("# Wrote src/a.ts")
expect(output).toMatch(/1\s+const x = 1/)
expect(output).toMatch(/2\s+const y = 2/)
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
test("renders promoted task markdown without a leading blank row", async () => {
const out = await setup()
try {
await out.scrollback.append(
toolCommit({
tool: "task",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
description: "Explore run.ts",
subagent_type: "explore",
},
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"Location: `/tmp/run.ts`",
"",
"Summary:",
"- Local interactive mode",
"- Attach mode",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
)
const commits = claim(out.renderer)
try {
const output = render(commits)
expect(output.startsWith("\n")).toBe(false)
expect(output).toContain("Summary:")
expect(output).toContain("Local interactive mode")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
@@ -0,0 +1,422 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
import type { StreamCommit } from "@/cli/cmd/run/types"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
return reduceSessionData({
data,
event: event as Event,
sessionID: "session-1",
thinking,
limits: {},
})
}
function assistant(id: string, extra: Record<string, unknown> = {}) {
return {
type: "message.updated",
properties: {
sessionID: "session-1",
info: {
id,
role: "assistant",
providerID: "openai",
modelID: "gpt-5",
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: { read: 0, write: 0 },
},
...extra,
},
},
}
}
function user(id: string) {
return {
type: "message.updated",
properties: {
sessionID: "session-1",
info: {
id,
role: "user",
},
},
}
}
function text(input: {
id: string
messageID: string
text: string
time?: Record<string, number>
}) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "text",
text: input.text,
...(input.time ? { time: input.time } : {}),
},
},
}
}
function reasoning(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "reasoning",
text: input.text,
...(input.time ? { time: input.time } : {}),
},
},
}
}
function delta(messageID: string, partID: string, value: string) {
return {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID,
partID,
field: "text",
delta: value,
},
}
}
function tool(input: {
id: string
messageID: string
tool: string
state: Record<string, unknown>
callID?: string
}) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "tool",
tool: input.tool,
...(input.callID ? { callID: input.callID } : {}),
state: input.state,
},
},
}
}
describe("run session data", () => {
test("buffers delayed assistant text until the role is known", () => {
let data = createSessionData()
data = reduce(data, delta("msg-1", "txt-1", "hello")).data
data = reduce(data, assistant("msg-1")).data
const out = reduce(
data,
text({
id: "txt-1",
messageID: "msg-1",
text: "",
time: { end: 1 },
}),
)
expect(out.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "hello",
partID: "txt-1",
}),
])
})
test("keeps leading whitespace buffered until real assistant content arrives", () => {
let data = createSessionData()
data = reduce(data, assistant("msg-1")).data
data = reduce(data, text({ id: "txt-1", messageID: "msg-1", text: "", time: { start: 1 } })).data
let out = reduce(data, delta("msg-1", "txt-1", " "))
expect(out.commits).toEqual([])
out = reduce(out.data, delta("msg-1", "txt-1", "Found"))
expect(out.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: " Found",
}),
])
})
test("drops delayed text once the message resolves to a user role", () => {
let data = createSessionData()
data = reduce(data, text({ id: "txt-user-1", messageID: "msg-user-1", text: "HELLO", time: { end: 1 } })).data
const out = reduce(data, user("msg-user-1"))
expect(out.commits).toEqual([])
expect(out.data.ids.has("txt-user-1")).toBe(true)
})
test("suppresses reasoning commits when thinking is disabled", () => {
const out = reduce(
createSessionData(),
reasoning({
id: "reason-1",
messageID: "msg-1",
text: "hidden",
time: { end: 1 },
}),
false,
)
expect(out.commits).toEqual([])
expect(out.data.ids.has("reason-1")).toBe(true)
})
test("keeps permission precedence over queued questions", () => {
let data = createSessionData()
data = reduce(data, {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: ["/tmp/file.txt"],
metadata: {},
always: [],
},
}).data
const ask = reduce(data, {
type: "question.asked",
properties: {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
],
},
})
expect(ask.footer).toEqual({
patch: { status: "awaiting permission" },
view: {
type: "permission",
request: expect.objectContaining({ id: "perm-1" }),
},
})
expect(
reduce(ask.data, {
type: "permission.replied",
properties: {
sessionID: "session-1",
requestID: "perm-1",
reply: "reject",
},
}).footer,
).toEqual({
patch: { status: "awaiting answer" },
view: {
type: "question",
request: expect.objectContaining({ id: "question-1" }),
},
})
})
test("refreshes the active permission view when tool input arrives later", () => {
const data = reduce(createSessionData(), {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "bash",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
tool: {
messageID: "msg-1",
callID: "call-1",
},
},
}).data
const out = reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "git status --short",
},
},
}),
)
expect(out.footer).toEqual({
view: {
type: "permission",
request: expect.objectContaining({
id: "perm-1",
metadata: expect.objectContaining({
input: {
command: "git status --short",
},
}),
}),
},
})
})
test("strips bash echo only from the first assistant flush", () => {
let data = createSessionData()
data = reduce(data, assistant("msg-1")).data
data = reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
tool: "bash",
state: {
status: "completed",
input: {
command: "printf hi",
},
output: "echoed\n",
time: { start: 1, end: 2 },
},
}),
).data
const first = reduce(
data,
text({
id: "txt-1",
messageID: "msg-1",
text: "echoed\nanswer",
}),
)
expect(first.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "answer",
}),
])
expect(reduce(first.data, delta("msg-1", "txt-1", "\nechoed\nagain")).commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "\nechoed\nagain",
}),
])
})
test("synthesizes a glob start before an error when the running update is missed", () => {
expect(
reduce(
createSessionData(),
tool({
id: "tool-1",
messageID: "msg-1",
tool: "glob",
state: {
status: "error",
input: {
pattern: "**/*tool*",
path: "/tmp/demo/run",
},
error: "No such file or directory: '/tmp/demo/run'",
},
}),
).commits,
).toEqual([
expect.objectContaining({
kind: "tool",
tool: "glob",
phase: "start",
partID: "tool-1",
text: "running glob",
toolState: "running",
}),
expect.objectContaining({
kind: "tool",
tool: "glob",
phase: "final",
partID: "tool-1",
text: "No such file or directory: '/tmp/demo/run'",
toolState: "error",
toolError: "No such file or directory: '/tmp/demo/run'",
}),
])
})
test("flushInterrupted emits one interrupted final per live part", () => {
const data = reduce(
createSessionData(),
text({
id: "txt-1",
messageID: "msg-1",
text: "unfinished",
}),
).data
const first: StreamCommit[] = []
flushInterrupted(data, first)
expect(first).toEqual([
expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }),
expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }),
])
const next: StreamCommit[] = []
flushInterrupted(data, next)
expect(next).toEqual([])
})
test("surfaces session errors as error commits", () => {
const out = reduce(createSessionData(), {
type: "session.error",
properties: {
sessionID: "session-1",
error: {
name: "UnknownError",
data: {
message: "permission denied",
},
},
},
})
expect(out.commits).toEqual([
expect.objectContaining({
kind: "error",
text: "permission denied",
}),
])
})
})
@@ -0,0 +1,247 @@
import { describe, expect, test } from "bun:test"
import {
createSession,
sessionHistory,
sessionVariant,
type RunSession,
type SessionMessages,
} from "@/cli/cmd/run/session.shared"
type Message = SessionMessages[number]
type Part = Message["parts"][number]
type TextPart = Extract<Part, { type: "text" }>
type AgentPart = Extract<Part, { type: "agent" }>
type FilePart = Extract<Part, { type: "file" }>
const model = {
providerID: "openai",
modelID: "gpt-5",
}
function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
...model,
variant,
},
},
parts,
}
}
function assistantMessage(id: string, parts: Message["parts"]): Message {
return {
info: {
id,
sessionID: "session-1",
role: "assistant",
time: {
created: 1,
},
parentID: "msg-user-1",
modelID: "gpt-5",
providerID: "openai",
mode: "chat",
agent: "build",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
},
parts,
}
}
function textPart(id: string, messageID: string, text: string, input: Partial<TextPart> = {}): TextPart {
return {
id,
sessionID: "session-1",
messageID,
type: "text",
text,
synthetic: input.synthetic,
}
}
function agentPart(id: string, messageID: string, name: string, source?: AgentPart["source"]): AgentPart {
return {
id,
sessionID: "session-1",
messageID,
type: "agent",
name,
source,
}
}
function filePart(id: string, messageID: string, url: string, input: Partial<FilePart> = {}): FilePart {
return {
id,
sessionID: "session-1",
messageID,
type: "file",
mime: input.mime ?? "text/plain",
filename: input.filename,
url,
source: input.source,
}
}
describe("run session shared", () => {
test("builds user prompt text from text, file, and agent parts", () => {
const msgs: SessionMessages = [
assistantMessage("msg-assistant-1", [textPart("txt-assistant-1", "msg-assistant-1", "ignore me")]),
userMessage("msg-user-1", [
textPart("txt-user-1", "msg-user-1", "look @scan"),
textPart("txt-user-2", "msg-user-1", "hidden", { synthetic: true }),
agentPart("agent-user-1", "msg-user-1", "scan", {
start: 5,
end: 10,
value: "@scan",
}),
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
]),
]
const out = createSession(msgs)
expect(out.first).toBe(false)
expect(out.turns).toHaveLength(1)
expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
expect(out.turns[0]?.prompt.parts).toEqual([
{
type: "agent",
name: "scan",
source: {
start: 5,
end: 10,
value: "@scan",
},
},
{
type: "file",
mime: "text/plain",
filename: undefined,
url: "file:///tmp/note.ts",
source: {
type: "file",
path: "file:///tmp/note.ts",
text: {
start: 11,
end: 19,
value: "@note.ts",
},
},
},
])
})
test("reuses existing mentions when file and agent parts have no source", () => {
const out = createSession([
userMessage("msg-user-1", [
textPart("txt-user-1", "msg-user-1", "look @scan @note.ts"),
agentPart("agent-user-1", "msg-user-1", "scan"),
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
]),
])
expect(out.turns[0]?.prompt).toEqual({
text: "look @scan @note.ts",
parts: [
{
type: "agent",
name: "scan",
source: {
start: 5,
end: 10,
value: "@scan",
},
},
{
type: "file",
mime: "text/plain",
filename: undefined,
url: "file:///tmp/note.ts",
source: {
type: "file",
path: "file:///tmp/note.ts",
text: {
start: 11,
end: 19,
value: "@note.ts",
},
},
},
],
})
})
test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
const parts = [
{
type: "agent" as const,
name: "scan",
source: {
start: 0,
end: 5,
value: "@scan",
},
},
]
const session: RunSession = {
first: false,
turns: [
{ prompt: { text: "one", parts }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "one", parts: structuredClone(parts) }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: " ", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "two", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
],
}
const out = sessionHistory(session)
expect(out.map((item) => item.text)).toEqual(["one", "two"])
expect(out[0]?.parts).toEqual(parts)
expect(out[0]?.parts).not.toBe(parts)
expect(out[0]?.parts[0]).not.toBe(parts[0])
})
test("returns the latest matching variant for the active model", () => {
const session: RunSession = {
first: false,
turns: [
{ prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" },
{ prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
],
}
expect(sessionVariant(session, model)).toBeUndefined()
session.turns.push({
prompt: { text: "four", parts: [] },
provider: "openai",
model: "gpt-5",
variant: "minimal",
})
expect(sessionVariant(session, model)).toBe("minimal")
})
})
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "@/cli/cmd/run/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
const api: FooterApi = {
isClosed: false,
onPrompt: () => () => {},
onClose: () => () => {},
event: (next) => {
events.push(next)
},
append: (next) => {
commits.push(next)
},
idle: () => Promise.resolve(),
close: () => {},
destroy: () => {},
}
return { api, events, commits }
}
describe("run stream bridge", () => {
test("defaults status patches to running phase", () => {
const out = footer()
writeSessionOutput(
{
footer: out.api,
},
{
commits: [],
footer: {
patch: {
status: "assistant responding",
},
},
},
)
expect(out.events).toEqual([
{
type: "stream.patch",
patch: {
phase: "running",
status: "assistant responding",
},
},
])
})
})
@@ -0,0 +1,1061 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { createSessionTransport } from "@/cli/cmd/run/stream.transport"
import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
type EventStream = Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>>["stream"]
type SdkEvent = EventStream extends AsyncGenerator<infer T, unknown, unknown> ? T : never
type SessionMessage = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["data"]>[number]
type SessionChild = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["children"]>>["data"]>[number]
type SessionToolPart = Extract<SessionMessage["parts"][number], { type: "tool" }>
type SessionStatusMap = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["status"]>>["data"]>
type TextPart = Extract<SessionMessage["parts"][number], { type: "text" }>
afterEach(() => {
mock.restore()
})
function defer<T = void>() {
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 }
}
async function waitFor<T>(check: () => T | undefined, timeout = 1_000): Promise<T> {
const end = Date.now() + timeout
while (Date.now() < end) {
const value = check()
if (value !== undefined) {
return value
}
await Bun.sleep(10)
}
throw new Error("timed out waiting for value")
}
function busy(sessionID = "session-1") {
return {
id: `evt-${sessionID}-busy`,
type: "session.status",
properties: {
sessionID,
status: {
type: "busy",
},
},
} satisfies SdkEvent
}
function idle(sessionID = "session-1") {
return {
id: `evt-${sessionID}-idle`,
type: "session.status",
properties: {
sessionID,
status: {
type: "idle",
},
},
} satisfies SdkEvent
}
function assistant(id: string) {
return {
id: `evt-${id}`,
type: "message.updated",
properties: {
sessionID: "session-1",
info: assistantMessage({
sessionID: "session-1",
id,
parts: [],
}).info,
},
} satisfies SdkEvent
}
function feed() {
const list: SdkEvent[] = []
let done = false
let wake: (() => void) | undefined
const stream: EventStream = (async function* () {
while (!done || list.length > 0) {
if (list.length === 0) {
await new Promise<void>((resolve) => {
wake = resolve
})
continue
}
const next = list.shift()
if (!next) {
continue
}
yield next
}
})()
return {
stream,
push(value: SdkEvent) {
list.push(value)
wake?.()
wake = undefined
},
close() {
done = true
wake?.()
wake = undefined
},
}
}
function emptyStream(): EventStream {
return (async function* (): AsyncGenerator<SdkEvent> {})()
}
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
}
function sse(stream: EventStream) {
return Promise.resolve({ stream })
}
function statusMap(busy: boolean): SessionStatusMap {
if (busy) {
return { "session-1": { type: "busy" } }
}
return {}
}
function assistantMessage(input: {
sessionID: string
id: string
parts: SessionMessage["parts"]
}): SessionMessage {
return {
info: {
id: input.id,
sessionID: input.sessionID,
role: "assistant",
time: {
created: 1,
},
parentID: "msg-user-1",
modelID: "gpt-5",
providerID: "openai",
mode: "chat",
agent: "build",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
},
parts: input.parts,
}
}
function runningTool(input: {
sessionID: string
messageID: string
id: string
callID: string
tool: string
body: Record<string, unknown>
metadata?: Record<string, unknown>
}): SessionToolPart {
return {
id: input.id,
sessionID: input.sessionID,
messageID: input.messageID,
type: "tool",
callID: input.callID,
tool: input.tool,
state: {
status: "running",
input: input.body,
...(input.metadata ? { metadata: input.metadata } : {}),
time: {
start: 1,
},
},
}
}
function completedTool(input: {
sessionID: string
messageID: string
id: string
callID: string
tool: string
body: Record<string, unknown>
output?: string
metadata?: Record<string, unknown>
}): SessionToolPart {
return {
id: input.id,
sessionID: input.sessionID,
messageID: input.messageID,
type: "tool",
callID: input.callID,
tool: input.tool,
state: {
status: "completed",
input: input.body,
output: input.output ?? "",
...(input.metadata ? { metadata: input.metadata } : {}),
time: {
start: 1,
end: 2,
},
},
}
}
function textPart(id: string, messageID: string, text: string): TextPart {
return {
id,
sessionID: "session-1",
messageID,
type: "text",
text,
}
}
function textUpdated(part: TextPart): SdkEvent {
return {
id: `evt-${part.id}-updated`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
part,
time: 1,
},
}
}
function toolUpdated(part: SessionToolPart): SdkEvent {
return {
id: `evt-${part.id}-updated`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
part,
time: 1,
},
}
}
function textDelta(messageID: string, partID: string, delta: string): SdkEvent {
return {
id: `evt-${partID}-delta`,
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID,
partID,
field: "text",
delta,
},
}
}
function child(id: string): SessionChild {
return {
id,
slug: id,
projectID: "project-1",
directory: "/tmp",
title: id,
version: "1",
time: {
created: 1,
updated: 1,
},
}
}
function footer(fn?: (commit: StreamCommit) => void) {
const commits: StreamCommit[] = []
const events: FooterEvent[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt: () => () => {},
onClose: () => () => {},
event(next) {
events.push(next)
},
append(next) {
commits.push(next)
fn?.(next)
},
idle() {
return Promise.resolve()
},
close() {
closed = true
},
destroy() {
closed = true
},
}
return { api, commits, events }
}
function sdk(input: {
stream?: EventStream
subscribe?: OpencodeClient["event"]["subscribe"]
promptAsync?: OpencodeClient["session"]["promptAsync"]
status?: OpencodeClient["session"]["status"]
messages?: OpencodeClient["session"]["messages"]
children?: OpencodeClient["session"]["children"]
permissions?: OpencodeClient["permission"]["list"]
questions?: OpencodeClient["question"]["list"]
} = {}) {
const client = new OpencodeClient()
const subscribe: OpencodeClient["event"]["subscribe"] = input.subscribe ?? (() => sse(input.stream ?? emptyStream()))
const promptAsync: OpencodeClient["session"]["promptAsync"] = input.promptAsync ?? (() => ok(undefined))
const status: OpencodeClient["session"]["status"] = input.status ?? (() => ok({}))
const messages: OpencodeClient["session"]["messages"] = input.messages ?? (() => ok([]))
const children: OpencodeClient["session"]["children"] = input.children ?? (() => ok([]))
const permissions: OpencodeClient["permission"]["list"] = input.permissions ?? (() => ok([]))
const questions: OpencodeClient["question"]["list"] = input.questions ?? (() => ok([]))
spyOn(client.event, "subscribe").mockImplementation(subscribe)
spyOn(client.session, "promptAsync").mockImplementation(promptAsync)
spyOn(client.session, "status").mockImplementation(status)
spyOn(client.session, "messages").mockImplementation(messages)
spyOn(client.session, "children").mockImplementation(children)
spyOn(client.permission, "list").mockImplementation(permissions)
spyOn(client.question, "list").mockImplementation(questions)
return client
}
describe("run stream transport", () => {
test("bootstraps child tabs and resumed blocker input", async () => {
const src = feed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
messages: async ({ sessionID }) => {
if (sessionID === "session-1") {
return ok([
assistantMessage({
sessionID: "session-1",
id: "msg-1",
parts: [
runningTool({
sessionID: "session-1",
messageID: "msg-1",
id: "task-1",
callID: "call-1",
tool: "task",
body: {
description: "Explore run folder",
subagent_type: "explore",
},
metadata: {
sessionId: "child-1",
},
}),
],
}),
])
}
return ok([
assistantMessage({
sessionID: "child-1",
id: "msg-child-1",
parts: [
runningTool({
sessionID: "child-1",
messageID: "msg-child-1",
id: "edit-1",
callID: "call-edit-1",
tool: "edit",
body: {
filePath: "src/run/subagent-data.ts",
diff: "@@ -1 +1 @@",
},
}),
],
}),
])
},
children: async () => ok([child("child-1")]),
permissions: async () => ok([
{
id: "perm-1",
sessionID: "child-1",
permission: "edit",
patterns: ["src/run/subagent-data.ts"],
metadata: {},
always: [],
tool: {
messageID: "msg-child-1",
callID: "call-edit-1",
},
},
]),
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
expect(ui.events).toContainEqual({
type: "stream.subagent",
state: {
tabs: [
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
description: "Explore run folder",
status: "running",
}),
],
details: {},
permissions: [
expect.objectContaining({
id: "perm-1",
sessionID: "child-1",
metadata: {
input: {
filePath: "src/run/subagent-data.ts",
diff: "@@ -1 +1 @@",
},
},
}),
],
questions: [],
},
})
transport.selectSubagent("child-1")
expect(ui.events).toContainEqual({
type: "stream.subagent",
state: {
tabs: [
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
}),
],
details: {
"child-1": {
sessionID: "child-1",
commits: [],
},
},
permissions: [
expect.objectContaining({
id: "perm-1",
}),
],
questions: [],
},
})
expect(ui.events).toContainEqual({
type: "stream.view",
view: {
type: "permission",
request: expect.objectContaining({
id: "perm-1",
metadata: {
input: {
filePath: "src/run/subagent-data.ts",
diff: "@@ -1 +1 @@",
},
},
}),
},
})
} finally {
src.close()
await transport.close()
}
})
test("recovers pending questions from question.list when question.asked is missed", async () => {
const src = feed()
const ui = footer()
let questionCalls = 0
const request = {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Which area should I inspect first?",
header: "Area",
options: [{ label: "CLI", description: "Look at the direct run flow." }],
multiple: false,
},
],
tool: {
messageID: "msg-1",
callID: "call-question-1",
},
}
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
questions: async () => {
questionCalls += 1
return ok(questionCalls > 1 ? [request] : [])
},
promptAsync: async () => {
queueMicrotask(() => {
src.push(busy())
src.push(assistant("msg-1"))
src.push(
toolUpdated(
runningTool({
sessionID: "session-1",
messageID: "msg-1",
id: "question-tool-1",
callID: "call-question-1",
tool: "question",
body: {
questions: request.questions,
},
}),
),
)
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
const ctrl = new AbortController()
try {
const run = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
signal: ctrl.signal,
})
const view = await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined
})
expect(view).toEqual({
type: "question",
request,
})
expect(ui.events).toContainEqual({
type: "stream.patch",
patch: {
phase: "running",
status: "awaiting answer",
},
})
src.push(
toolUpdated(
completedTool({
sessionID: "session-1",
messageID: "msg-1",
id: "question-tool-1",
callID: "call-question-1",
tool: "question",
body: {
questions: request.questions,
},
output: "User has answered your questions.",
metadata: {
answers: [["CLI"]],
},
}),
),
)
expect(
await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "prompt" ? item : undefined
}),
).toEqual({
type: "stream.view",
view: { type: "prompt" },
})
ctrl.abort()
await run
} finally {
src.close()
await transport.close()
}
})
test("does not resurrect questions if question.list resolves after tool completion", async () => {
const src = feed()
const ui = footer()
const started = defer()
const request = {
id: "question-race-1",
sessionID: "session-1",
questions: [
{
question: "Which area should I inspect first?",
header: "Area",
options: [{ label: "CLI", description: "Look at the direct run flow." }],
multiple: false,
},
],
tool: {
messageID: "msg-1",
callID: "call-question-race-1",
},
}
const pending = defer<Awaited<ReturnType<typeof ok<typeof request>>>>()
let questionCalls = 0
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
questions: async () => {
questionCalls += 1
if (questionCalls === 1) {
return ok([])
}
if (questionCalls === 2) {
started.resolve()
return pending.promise
}
return ok([])
},
promptAsync: async () => {
queueMicrotask(() => {
src.push(busy())
src.push(assistant("msg-1"))
src.push(
toolUpdated(
runningTool({
sessionID: "session-1",
messageID: "msg-1",
id: "question-race-tool-1",
callID: "call-question-race-1",
tool: "question",
body: {
questions: request.questions,
},
}),
),
)
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
const ctrl = new AbortController()
try {
const run = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
signal: ctrl.signal,
})
await started.promise
src.push(
toolUpdated(
completedTool({
sessionID: "session-1",
messageID: "msg-1",
id: "question-race-tool-1",
callID: "call-question-race-1",
tool: "question",
body: {
questions: request.questions,
},
output: "User has answered your questions.",
metadata: {
answers: [["CLI"]],
},
}),
),
)
pending.resolve(ok([request]))
await Bun.sleep(50)
expect(
ui.events.some(
(event) => event.type === "stream.view" && event.view.type === "question" && event.view.request.id === request.id,
),
).toBe(false)
ctrl.abort()
await run
} finally {
src.close()
await transport.close()
}
})
test("respects the includeFiles flag when building prompt payloads", async () => {
const src = feed()
const ui = footer()
const seen: unknown[] = []
const file: RunFilePart = {
type: "file",
url: "file:///tmp/a.ts",
filename: "a.ts",
mime: "text/plain",
}
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
promptAsync: async (input) => {
seen.push(input)
queueMicrotask(() => {
src.push(busy())
src.push(idle())
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
await transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [file],
includeFiles: true,
})
await transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "again", parts: [] },
files: [file],
includeFiles: false,
})
expect(seen).toEqual([
expect.objectContaining({
parts: [file, { type: "text", text: "hello" }],
}),
expect.objectContaining({
parts: [{ type: "text", text: "again" }],
}),
])
} finally {
src.close()
await transport.close()
}
})
test("falls back to session status polling when idle events are missing", async () => {
const src = feed()
const ui = footer()
let busy = true
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
promptAsync: async () => {
queueMicrotask(() => {
src.push(assistant("msg-1"))
busy = false
})
return ok(undefined)
},
status: async () => ok(statusMap(busy)),
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
await Promise.race([
transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
}),
new Promise((_, reject) => setTimeout(() => reject(new Error("turn timed out")), 1_000)),
])
} finally {
src.close()
await transport.close()
}
})
test("flushes interrupted output when the active turn aborts", async () => {
const src = feed()
const seen = defer()
const ui = footer((commit) => {
if (commit.kind === "assistant" && commit.phase === "progress") {
seen.resolve()
}
})
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
promptAsync: async () => {
queueMicrotask(() => {
src.push(busy())
src.push(assistant("msg-1"))
src.push(textUpdated(textPart("txt-1", "msg-1", "")))
src.push(textDelta("msg-1", "txt-1", "unfinished"))
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
const ctrl = new AbortController()
try {
const task = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
signal: ctrl.signal,
})
await seen.promise
ctrl.abort()
await task
expect(ui.commits).toEqual([
{
kind: "assistant",
text: "unfinished",
phase: "progress",
source: "assistant",
messageID: "msg-1",
partID: "txt-1",
},
{
kind: "assistant",
text: "",
phase: "final",
source: "assistant",
messageID: "msg-1",
partID: "txt-1",
interrupted: true,
},
])
} finally {
src.close()
await transport.close()
}
})
test("closes an active turn without rejecting it", async () => {
const src = feed()
const ui = footer()
const ready = defer()
let aborted = false
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
promptAsync: async (_input, opt) => {
ready.resolve()
await new Promise<void>((resolve) => {
const onAbort = () => {
aborted = true
opt?.signal?.removeEventListener("abort", onAbort)
resolve()
}
opt?.signal?.addEventListener("abort", onAbort, { once: true })
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
const task = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
})
await ready.promise
await transport.close()
await task
expect(aborted).toBe(true)
} finally {
src.close()
await transport.close()
}
})
test("rejects the active turn when the event stream faults", async () => {
const ui = footer()
const ready = defer()
const transport = await createSessionTransport({
sdk: sdk({
subscribe: () => sse((async function* (): AsyncGenerator<SdkEvent> {
await ready.promise
yield busy()
throw new Error("boom")
})()),
promptAsync: async () => {
ready.resolve()
return ok(undefined)
},
status: async () => ok({ "session-1": { type: "busy" } }),
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
try {
await expect(
transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
}),
).rejects.toThrow("boom")
} finally {
await transport.close()
}
})
test("rejects concurrent turns", async () => {
const src = feed()
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
const ctrl = new AbortController()
try {
const task = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "one", parts: [] },
files: [],
includeFiles: false,
signal: ctrl.signal,
})
await expect(
transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "two", parts: [] },
files: [],
includeFiles: false,
}),
).rejects.toThrow("prompt already running")
ctrl.abort()
await task
} finally {
src.close()
await transport.close()
}
})
})
@@ -0,0 +1,328 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { entryBody } from "@/cli/cmd/run/entry.body"
import {
bootstrapSubagentData,
clearFinishedSubagents,
createSubagentData,
reduceSubagentData,
snapshotSubagentData,
} from "@/cli/cmd/run/subagent-data"
type SessionMessage = Parameters<typeof bootstrapSubagentData>[0]["messages"][number]
function visible(commits: Array<Parameters<typeof entryBody>[0]>) {
return commits.flatMap((item) => {
const body = entryBody(item)
if (body.type === "none") {
return []
}
if (body.type === "structured") {
if (body.snapshot.kind === "code" || body.snapshot.kind === "task") {
return [body.snapshot.title]
}
if (body.snapshot.kind === "diff") {
return body.snapshot.items.map((item) => item.title)
}
if (body.snapshot.kind === "todo") {
return ["# Todos"]
}
return ["# Questions"]
}
return [body.content]
})
}
function reduce(data: ReturnType<typeof createSubagentData>, event: unknown) {
return reduceSubagentData({
data,
event: event as Event,
sessionID: "parent-1",
thinking: true,
limits: {},
})
}
function taskMessage(sessionID: string, status: "running" | "completed" = "completed"): SessionMessage {
if (status === "running") {
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "running",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
title: "Reducer touchpoints",
metadata: {
sessionId: sessionID,
toolcalls: 4,
},
time: { start: 1 },
},
},
],
}
}
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "completed",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
output: "",
title: "Reducer touchpoints",
metadata: {
sessionId: sessionID,
toolcalls: 4,
},
time: { start: 1, end: 2 },
},
},
],
}
}
function question(id: string, sessionID: string) {
return {
id,
sessionID,
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "Fast", description: "Quick pass" }],
multiple: false,
},
],
}
}
describe("run subagent data", () => {
test("bootstraps tabs and child blockers from parent task parts", () => {
const data = createSubagentData()
expect(
bootstrapSubagentData({
data,
messages: [taskMessage("child-1")],
children: [{ id: "child-1" }, { id: "child-2" }],
permissions: [
{
id: "perm-1",
sessionID: "child-1",
permission: "read",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
},
{
id: "perm-2",
sessionID: "other",
permission: "read",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
},
],
questions: [question("question-1", "child-1"), question("question-2", "other")],
}),
).toBe(true)
const snapshot = snapshotSubagentData(data)
expect(snapshot.tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
description: "Scan reducer paths",
title: "Reducer touchpoints",
status: "completed",
toolCalls: 4,
}),
])
expect(snapshot.details).toEqual({
"child-1": {
sessionID: "child-1",
commits: [],
},
})
expect(snapshot.permissions.map((item) => item.id)).toEqual(["perm-1"])
expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"])
})
test("captures child activity and blocker metadata in the footer detail state", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "running")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-user-1",
messageID: "msg-user-1",
sessionID: "child-1",
type: "text",
text: "Inspect footer tabs",
},
},
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-user-1",
role: "user",
},
},
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-assistant-1",
role: "assistant",
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "reason-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "reasoning",
text: "planning next steps",
time: { start: 1 },
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "tool",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "git status --short",
},
time: { start: 1 },
},
},
},
})
reduce(data, {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "child-1",
permission: "bash",
patterns: ["git status --short"],
metadata: {},
always: [],
tool: {
messageID: "msg-assistant-1",
callID: "call-1",
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "text",
text: "hello",
},
},
})
reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "child-1",
messageID: "msg-assistant-1",
partID: "txt-1",
field: "text",
delta: " world",
},
})
const snapshot = snapshotSubagentData(data)
expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })])
expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
" Inspect footer tabs",
"_Thinking:_ planning next steps",
"# Shell\n$ git status --short",
"hello world",
])
expect(snapshot.permissions).toEqual([
expect.objectContaining({
id: "perm-1",
metadata: {
input: {
command: "git status --short",
},
},
}),
])
expect(snapshot.questions).toEqual([])
})
test("clears finished tabs on the next parent prompt", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "completed"), taskMessage("child-2", "running")],
children: [{ id: "child-1" }, { id: "child-2" }],
permissions: [],
questions: [],
})
expect(clearFinishedSubagents(data)).toBe(true)
expect(snapshotSubagentData(data).tabs).toEqual([
expect.objectContaining({ sessionID: "child-2", status: "running" }),
])
})
})
@@ -0,0 +1,122 @@
import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
const palette = [
"#15161e",
"#f7768e",
"#9ece6a",
"#e0af68",
"#7aa2f7",
"#bb9af7",
"#7dcfff",
"#c0caf5",
] as const
function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
return {
palette: Array.from({ length: 256 }, (_, index) => input.palette?.[index] ?? palette[index % palette.length]!),
defaultBackground: input.defaultBackground ?? "#1a1b26",
defaultForeground: input.defaultForeground ?? "#c0caf5",
cursorColor: input.cursorColor ?? "#ff9e64",
mouseForeground: input.mouseForeground ?? null,
mouseBackground: input.mouseBackground ?? null,
tekForeground: input.tekForeground ?? null,
tekBackground: input.tekBackground ?? null,
highlightBackground: input.highlightBackground ?? "#33467c",
highlightForeground: input.highlightForeground ?? "#c0caf5",
}
}
function renderer(input: {
themeMode?: "dark" | "light"
colors?: TerminalColors
fail?: boolean
} = {}) {
return {
themeMode: input.themeMode,
getPalette: async () => {
if (input.fail) {
throw new Error("boom")
}
return input.colors ?? terminalColors()
},
} as CliRenderer
}
function expectRgba(color: unknown) {
expect(color).toBeInstanceOf(RGBA)
if (!(color instanceof RGBA)) {
throw new Error("expected RGBA")
}
return color
}
function expectIndexed(color: unknown) {
const rgba = expectRgba(color)
expect(rgba.intent).toBe("indexed")
expect(rgba.slot).toBeLessThan(256)
}
function spread(color: RGBA) {
const [r, g, b] = color.toInts()
return Math.max(r, g, b) - Math.min(r, g, b)
}
test("falls back when palette lookup fails", async () => {
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
})
test("returns syntax styles and indexed splash colors", async () => {
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
try {
expect(theme.block.syntax).toBeDefined()
expect(theme.block.subtleSyntax).toBeDefined()
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
expect([...theme.block.subtleSyntax!.getAllStyles()].length).toBeGreaterThan(0)
expectIndexed(theme.splash.left)
expectIndexed(theme.splash.right)
expectIndexed(theme.splash.leftShadow)
expectIndexed(theme.splash.rightShadow)
expectRgba(theme.footer.highlight)
expectRgba(theme.footer.surface)
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
test("keeps dark surfaces neutral on saturated backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#0000ff",
defaultForeground: "#ffffff",
}),
"dark",
),
"dark",
)
expect(spread(theme.backgroundPanel)).toBeLessThan(10)
expect(spread(theme.backgroundElement)).toBeLessThan(10)
})
test("keeps light surfaces close to neutral on warm backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#fbf1c7",
defaultForeground: "#3c3836",
}),
"light",
),
"light",
)
expect(spread(theme.backgroundPanel)).toBeLessThan(60)
expect(spread(theme.backgroundElement)).toBeLessThan(60)
})
@@ -0,0 +1,214 @@
import path from "path"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { describe, expect, test } from "bun:test"
import { Effect, FileSystem, Layer } from "effect"
import { Global } from "@opencode-ai/core/global"
import {
createVariantRuntime,
cycleVariant,
formatModelLabel,
pickVariant,
resolveVariant,
} from "@/cli/cmd/run/variant.shared"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types"
import { testEffect } from "../../lib/effect"
const model = {
providerID: "openai",
modelID: "gpt-5",
}
const providers: RunProvider[] = [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
api: {
id: "gpt-5",
url: "https://openai.test",
npm: "@ai-sdk/openai",
},
name: "GPT-5",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
},
},
},
]
function userMessage(id: string, input: { providerID: string; modelID: string; variant?: string }): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: input,
},
parts: [],
}
}
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, NodeFileSystem.layer))
function remap(root: string, file: string) {
if (file === Global.Path.state) {
return root
}
if (file.startsWith(Global.Path.state + path.sep)) {
return path.join(root, path.relative(Global.Path.state, file))
}
return file
}
function remappedFs(root: string) {
return Layer.effect(
AppFileSystem.Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return AppFileSystem.Service.of({
...fs,
readJson: (file) => fs.readJson(remap(root, file)),
writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
})
}),
).pipe(Layer.provide(AppFileSystem.defaultLayer))
}
describe("run variant shared", () => {
test("prefers cli then session then saved variants", () => {
expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max")
expect(resolveVariant(undefined, "high", "low", ["low", "high"])).toBe("high")
expect(resolveVariant(undefined, "missing", "low", ["low", "high"])).toBe("low")
})
test("cycles through variants and back to default", () => {
expect(cycleVariant(undefined, ["low", "high"])).toBe("low")
expect(cycleVariant("low", ["low", "high"])).toBe("high")
expect(cycleVariant("high", ["low", "high"])).toBeUndefined()
expect(cycleVariant(undefined, [])).toBeUndefined()
})
test("formats model labels", () => {
expect(formatModelLabel(model, undefined)).toBe("gpt-5 · openai")
expect(formatModelLabel(model, "high")).toBe("gpt-5 · openai · high")
expect(formatModelLabel(model, undefined, providers)).toBe("GPT-5 · OpenAI")
expect(formatModelLabel(model, "high", providers)).toBe("GPT-5 · OpenAI · high")
})
test("picks the latest matching variant from raw session messages", () => {
const msgs: SessionMessages = [
userMessage("msg-1", { providerID: "openai", modelID: "gpt-5", variant: "high" }),
userMessage("msg-2", { providerID: "anthropic", modelID: "sonnet", variant: "max" }),
userMessage("msg-3", { providerID: "openai", modelID: "gpt-5", variant: "minimal" }),
]
expect(pickVariant(model, msgs)).toBe("minimal")
})
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* fs.writeJson(file, {
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
"openai/gpt-5": "high",
},
})
yield* Effect.promise(() => svc.saveVariant(model, undefined))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBeUndefined()
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
}),
)
it.live("repairs malformed saved variant state on the next write", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* filesys.writeFileString(file, "{")
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
variant: {
"openai/gpt-5": "high",
},
})
}),
)
})