mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-01 22:44:21 +08:00
Compare commits
1 Commits
dev
...
oc-run-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a102a8309f |
@@ -198,7 +198,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
|
||||
| `project` | `bridged` | list, current, git init, update |
|
||||
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
||||
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||
| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore |
|
||||
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
|
||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
||||
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
|
||||
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
|
||||
@@ -290,7 +290,7 @@ This checklist tracks bridge parity only. Checked routes are available through t
|
||||
|
||||
### Workspace Routes
|
||||
|
||||
- [x] `GET /experimental/workspace/adapter` - list workspace adapters.
|
||||
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
|
||||
- [x] `POST /experimental/workspace` - create workspace.
|
||||
- [x] `GET /experimental/workspace` - list workspaces.
|
||||
- [x] `GET /experimental/workspace/status` - workspace status.
|
||||
|
||||
@@ -353,7 +353,7 @@ piecewise.
|
||||
- [ ] `src/cli/cmd/tui/event.ts`
|
||||
- [ ] `src/cli/ui.ts`
|
||||
- [ ] `src/command/index.ts`
|
||||
- [x] `src/control-plane/adapters/worktree.ts`
|
||||
- [x] `src/control-plane/adaptors/worktree.ts`
|
||||
- [x] `src/control-plane/types.ts`
|
||||
- [x] `src/control-plane/workspace.ts`
|
||||
- [ ] `src/file/index.ts`
|
||||
|
||||
@@ -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"
|
||||
@@ -8,38 +21,28 @@ import { bootstrap } from "../bootstrap"
|
||||
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 { BashTool } from "../../tool/bash"
|
||||
import { TodoWriteTool } from "../../tool/todo"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import type { RunDemo } from "./run/types"
|
||||
|
||||
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,
|
||||
}
|
||||
type FilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type Inline = {
|
||||
@@ -48,6 +51,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)
|
||||
@@ -61,145 +70,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 bash(info: ToolProps<typeof BashTool>) {
|
||||
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: "⚙",
|
||||
title: part.tool,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const RunCommand = cmd({
|
||||
@@ -284,6 +170,11 @@ export const RunCommand = cmd({
|
||||
.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", {
|
||||
@@ -291,30 +182,87 @@ export const RunCommand = cmd({
|
||||
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
|
||||
default: false,
|
||||
})
|
||||
.option("demo", {
|
||||
type: "string",
|
||||
choices: ["on", "permission", "question", "mix", "text"],
|
||||
describe: "enable direct interactive demo slash commands",
|
||||
})
|
||||
.option("demo-text", {
|
||||
type: "string",
|
||||
describe: "text used with --demo text",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
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)
|
||||
}
|
||||
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
|
||||
if (args.interactive && args.command) {
|
||||
die("--interactive cannot be used with --command")
|
||||
}
|
||||
|
||||
if (args.demo && !args.interactive) {
|
||||
die("--demo requires --interactive")
|
||||
}
|
||||
|
||||
if (args.demoText && args.demo !== "text") {
|
||||
die("--demo-text requires --demo text")
|
||||
}
|
||||
|
||||
if (args.interactive && args.format === "json") {
|
||||
die("--interactive cannot be used with --format json")
|
||||
}
|
||||
|
||||
if (args.interactive && !process.stdin.isTTY) {
|
||||
die("--interactive requires a TTY")
|
||||
}
|
||||
|
||||
if (args.interactive && !process.stdout.isTTY) {
|
||||
die("--interactive requires a TTY stdout")
|
||||
}
|
||||
|
||||
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
if (!args.dir) return args.attach ? undefined : root
|
||||
if (args.attach) return args.dir
|
||||
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
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 = (() => {
|
||||
if (!args.attach) return undefined
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
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)
|
||||
@@ -333,7 +281,7 @@ export const RunCommand = cmd({
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -343,23 +291,25 @@ export const RunCommand = cmd({
|
||||
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
|
||||
@@ -367,19 +317,83 @@ export const RunCommand = cmd({
|
||||
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) {
|
||||
@@ -397,43 +411,131 @@ export const RunCommand = cmd({
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(sdk: OpencodeClient) {
|
||||
function tool(part: ToolPart) {
|
||||
try {
|
||||
if (part.tool === "bash") return bash(props<typeof BashTool>(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 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 AppRuntime.runPromise(Agent.Service.use((svc) => svc.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
|
||||
@@ -451,7 +553,7 @@ export const RunCommand = cmd({
|
||||
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({
|
||||
@@ -468,7 +570,7 @@ export const RunCommand = cmd({
|
||||
args.format !== "json"
|
||||
) {
|
||||
if (toggles.get(part.id) === true) continue
|
||||
task(props<typeof TaskTool>(part))
|
||||
await tool(part)
|
||||
toggles.set(part.id, true)
|
||||
}
|
||||
|
||||
@@ -533,7 +635,7 @@ export const RunCommand = cmd({
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
|
||||
if (args["dangerously-skip-permissions"]) {
|
||||
await sdk.permission.reply({
|
||||
await client.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "once",
|
||||
})
|
||||
@@ -543,7 +645,7 @@ export const RunCommand = cmd({
|
||||
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",
|
||||
})
|
||||
@@ -551,121 +653,106 @@ export const RunCommand = cmd({
|
||||
}
|
||||
}
|
||||
}
|
||||
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 AppRuntime.runPromise(Agent.Service.use((svc) => svc.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
|
||||
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: rawMessage.trim().length > 0 ? rawMessage : undefined,
|
||||
thinking,
|
||||
demo: args.demo as RunDemo | undefined,
|
||||
demoText: args.demoText,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
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
|
||||
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
|
||||
|
||||
return await runInteractiveLocalMode({
|
||||
directory: directory ?? root,
|
||||
fetch: fetchFn,
|
||||
resolveAgent: localAgent,
|
||||
session,
|
||||
share,
|
||||
agent: args.agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
files,
|
||||
initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined,
|
||||
thinking,
|
||||
demo: args.demo as RunDemo | undefined,
|
||||
demoText: args.demoText,
|
||||
})
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
const sdk = attachSDK(directory)
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
await bootstrap(directory ?? root, async () => {
|
||||
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,
|
||||
directory,
|
||||
})
|
||||
await execute(sdk)
|
||||
})
|
||||
},
|
||||
|
||||
1302
packages/opencode/src/cli/cmd/run/demo.ts
Normal file
1302
packages/opencode/src/cli/cmd/run/demo.ts
Normal file
File diff suppressed because it is too large
Load Diff
183
packages/opencode/src/cli/cmd/run/entry.body.ts
Normal file
183
packages/opencode/src/cli/cmd/run/entry.body.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return commit.kind === "assistant" || commit.kind === "reasoning" || commit.kind === "tool"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
487
packages/opencode/src/cli/cmd/run/footer.permission.tsx
Normal file
487
packages/opencode/src/cli/cmd/run/footer.permission.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
// 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 { toolDiffView, 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 view = createMemo(() => toolDiffView(dims().width, props.diffStyle))
|
||||
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={view()}
|
||||
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>
|
||||
)
|
||||
}
|
||||
977
packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Normal file
977
packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Normal file
@@ -0,0 +1,977 @@
|
||||
// Prompt textarea component and its state machine for direct interactive mode.
|
||||
//
|
||||
// createPromptState() wires keybinds, history navigation, leader-key sequences,
|
||||
// and direct-mode `@` autocomplete for files, subagents, and MCP resources.
|
||||
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
|
||||
// while RunPromptAutocomplete renders a fixed-height suggestion list 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 {
|
||||
Index,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import * as Locale from "@/util/locale"
|
||||
import {
|
||||
createPromptHistory,
|
||||
isExitCommand,
|
||||
movePromptHistory,
|
||||
promptCycle,
|
||||
promptHit,
|
||||
promptInfo,
|
||||
promptKeys,
|
||||
pushPromptHistory,
|
||||
} from "./prompt.shared"
|
||||
import type { FooterKeybinds, FooterState, RunAgent, RunPrompt, RunPromptPart, RunResource } from "./types"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
|
||||
const LEADER_TIMEOUT_MS = 2000
|
||||
const AUTOCOMPLETE_ROWS = 6
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
export const TEXTAREA_MIN_ROWS = 1
|
||||
export const TEXTAREA_MAX_ROWS = 6
|
||||
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1
|
||||
|
||||
export const HINT_BREAKPOINTS = {
|
||||
send: 50,
|
||||
newline: 66,
|
||||
history: 80,
|
||||
variant: 95,
|
||||
}
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||
|
||||
type Auto = {
|
||||
display: string
|
||||
value: string
|
||||
part: Mention
|
||||
description?: string
|
||||
directory?: boolean
|
||||
}
|
||||
|
||||
type PromptInput = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: Accessor<RunAgent[]>
|
||||
resources: Accessor<RunResource[]>
|
||||
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
|
||||
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<Auto[]>
|
||||
selected: Accessor<number>
|
||||
onSubmit: () => void
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onContentChange: () => 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 } }
|
||||
}
|
||||
|
||||
export function hintFlags(width: number) {
|
||||
return {
|
||||
send: width >= HINT_BREAKPOINTS.send,
|
||||
newline: width >= HINT_BREAKPOINTS.newline,
|
||||
history: width >= HINT_BREAKPOINTS.history,
|
||||
variant: width >= HINT_BREAKPOINTS.variant,
|
||||
}
|
||||
}
|
||||
|
||||
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 RunPromptAutocomplete(props: {
|
||||
theme: () => RunFooterTheme
|
||||
options: () => Auto[]
|
||||
selected: () => number
|
||||
}) {
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-complete"
|
||||
width="100%"
|
||||
height={AUTOCOMPLETE_ROWS}
|
||||
border={["left"]}
|
||||
borderColor={props.theme().border}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-complete-fill"
|
||||
width="100%"
|
||||
height={AUTOCOMPLETE_ROWS}
|
||||
flexDirection="column"
|
||||
backgroundColor={props.theme().pane}
|
||||
>
|
||||
<Index
|
||||
each={props.options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={props.theme().muted}>No matching items</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={index === props.selected() ? props.theme().highlight : undefined}
|
||||
>
|
||||
<text
|
||||
fg={index === props.selected() ? props.theme().surface : props.theme().text}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{item().display}
|
||||
</text>
|
||||
<Show when={item().description}>
|
||||
<text
|
||||
fg={index === props.selected() ? props.theme().surface : props.theme().muted}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{item().description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Index>
|
||||
</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 [visible, setVisible] = createSignal(false)
|
||||
const [at, setAt] = createSignal(0)
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
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) => ({
|
||||
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) => ({
|
||||
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()) {
|
||||
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 {
|
||||
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 options = createMemo(() => {
|
||||
const mixed = [...agents(), ...files(), ...resources()]
|
||||
if (!query()) {
|
||||
return mixed.slice(0, AUTOCOMPLETE_ROWS)
|
||||
}
|
||||
|
||||
return fuzzysort
|
||||
.go(removeLineRange(query()), mixed, {
|
||||
keys: [(item) => (item.value || item.display).trimEnd(), "description"],
|
||||
limit: AUTOCOMPLETE_ROWS,
|
||||
})
|
||||
.map((item) => item.obj)
|
||||
})
|
||||
const popup = createMemo(() => {
|
||||
return visible() ? AUTOCOMPLETE_ROWS - 1 : 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 = () => {
|
||||
setVisible(false)
|
||||
setQuery("")
|
||||
setSelected(0)
|
||||
}
|
||||
|
||||
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 refresh = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
const text = area.plainText
|
||||
if (visible()) {
|
||||
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)
|
||||
setSelected(0)
|
||||
setVisible(true)
|
||||
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 select = (item?: Auto) => {
|
||||
const next = item ?? options()[selected()]
|
||||
if (!next || !area || area.isDestroyed) {
|
||||
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()[selected()]
|
||||
if (!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) => {
|
||||
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) {
|
||||
setSelected((selected() - 1 + options().length) % options().length)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "down" || (ctrl && name === "n")) {
|
||||
event.preventDefault()
|
||||
if (options().length > 0) {
|
||||
setSelected((selected() + 1) % options().length)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "escape") {
|
||||
event.preventDefault()
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "return") {
|
||||
event.preventDefault()
|
||||
select()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "tab") {
|
||||
event.preventDefault()
|
||||
if (options()[selected()]?.directory) {
|
||||
expand()
|
||||
return
|
||||
}
|
||||
|
||||
select()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.ctrl && event.name === "c") {
|
||||
const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const key = promptInfo(event)
|
||||
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)
|
||||
if (dir === 1 && area.visualCursor.visualRow === end) {
|
||||
area.cursorOffset = area.plainText.length
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (input.prompt()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.ctrl && event.name === "c") {
|
||||
const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (visible()) {
|
||||
select()
|
||||
return
|
||||
}
|
||||
|
||||
syncDraft()
|
||||
const next = clonePrompt(draft)
|
||||
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
|
||||
}
|
||||
|
||||
area.setText("")
|
||||
clearParts()
|
||||
hide()
|
||||
draft = { text: "", parts: [] }
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
queueMicrotask(async () => {
|
||||
if (await input.onSubmit(next)) {
|
||||
push(next)
|
||||
return
|
||||
}
|
||||
|
||||
restore(next)
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clear()
|
||||
if (area && !area.isDestroyed) {
|
||||
area.off("line-info-change", scheduleRows)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.width()
|
||||
popup()
|
||||
if (input.prompt()) {
|
||||
scheduleRows()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
setSelected(0)
|
||||
})
|
||||
|
||||
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,
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
onContentChange: () => {
|
||||
syncDraft()
|
||||
refresh()
|
||||
scheduleRows()
|
||||
},
|
||||
bind,
|
||||
}
|
||||
}
|
||||
591
packages/opencode/src/cli/cmd/run/footer.question.tsx
Normal file
591
packages/opencode/src/cli/cmd/run/footer.question.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
192
packages/opencode/src/cli/cmd/run/footer.subagent.tsx
Normal file
192
packages/opencode/src/cli/cmd/run/footer.subagent.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
705
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
705
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
@@ -0,0 +1,705 @@
|
||||
// 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.
|
||||
//
|
||||
// Interrupt and exit use a two-press pattern: first press shows a hint,
|
||||
// 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 { 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 {
|
||||
RunAgent,
|
||||
FooterApi,
|
||||
FooterEvent,
|
||||
FooterKeybinds,
|
||||
FooterPatch,
|
||||
FooterPromptRoute,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterView,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunDiffStyle,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type RunFooterOptions = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
wrote?: boolean
|
||||
sessionID: () => string | undefined
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
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
|
||||
onInterrupt?: () => void
|
||||
onExit?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
treeSitterClient?: TreeSitterClient
|
||||
}
|
||||
|
||||
const PERMISSION_ROWS = 12
|
||||
const QUESTION_ROWS = 14
|
||||
|
||||
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 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 interruptTimeout: NodeJS.Timeout | undefined
|
||||
private exitTimeout: NodeJS.Timeout | undefined
|
||||
private interruptHint: string
|
||||
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 [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,
|
||||
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,
|
||||
onExitRequest: this.handleExit,
|
||||
onExit: () => this.close(),
|
||||
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)
|
||||
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.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 })
|
||||
}
|
||||
|
||||
// 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 height =
|
||||
type === "permission"
|
||||
? this.base + PERMISSION_ROWS
|
||||
: type === "question"
|
||||
? this.base + QUESTION_ROWS
|
||||
: this.promptRoute.type === "subagent"
|
||||
? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
|
||||
: Math.max(
|
||||
this.base + TEXTAREA_MIN_ROWS,
|
||||
Math.min(this.base + tabs + PROMPT_MAX_ROWS, this.base + tabs + 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 }): void => {
|
||||
this.promptRoute = next.route
|
||||
this.tabsVisible = next.tabs
|
||||
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 (result.modelLabel) {
|
||||
patch.model = result.modelLabel
|
||||
}
|
||||
|
||||
this.patch(patch)
|
||||
}
|
||||
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
516
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
516
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
// 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
|
||||
// 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 } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||
import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
|
||||
import { RunPromptAutocomplete, RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
||||
import { RunPermissionBody } from "./footer.permission"
|
||||
import { RunQuestionBody } from "./footer.question"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
import type {
|
||||
FooterKeybinds,
|
||||
FooterPromptRoute,
|
||||
RunAgent,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterView,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunDiffStyle,
|
||||
} 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[]
|
||||
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
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onRows: (rows: number) => void
|
||||
onLayout: (input: { route: FooterPromptRoute; tabs: 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 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 variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
|
||||
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
|
||||
const hints = createMemo(() => hintFlags(term().width))
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const queue = createMemo(() => props.state().queue)
|
||||
const duration = createMemo(() => props.state().duration)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const interruptKey = createMemo(() => interrupt() || "/exit")
|
||||
const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const theme = createMemo(() => runTheme().footer)
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const spin = createMemo(() => {
|
||||
return {
|
||||
frames: createFrames({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
color: createColors({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
}
|
||||
})
|
||||
const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
|
||||
const view = active()
|
||||
return view.type === "permission" ? view : undefined
|
||||
})
|
||||
const question = createMemo<Extract<FooterView, { type: "question" }> | undefined>(() => {
|
||||
const view = active()
|
||||
return view.type === "question" ? view : undefined
|
||||
})
|
||||
const promptView = createMemo(() => {
|
||||
if (active().type !== "prompt") {
|
||||
return active().type
|
||||
}
|
||||
|
||||
return route().type === "composer" ? "prompt" : "subagent"
|
||||
})
|
||||
|
||||
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,
|
||||
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,
|
||||
onExitRequest: props.onExitRequest,
|
||||
onExit: props.onExit,
|
||||
onRows: props.onRows,
|
||||
onStatus: props.onStatus,
|
||||
})
|
||||
const menu = createMemo(() => prompt() && composer.visible())
|
||||
|
||||
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 === "composer") {
|
||||
return
|
||||
}
|
||||
|
||||
if (tabs().some((item) => item.sessionID === current.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeTab()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onLayout({
|
||||
route: route(),
|
||||
tabs: tabs().length > 0,
|
||||
})
|
||||
})
|
||||
|
||||
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={["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={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={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>
|
||||
|
||||
<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>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<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={menu() ? theme().shade : "transparent"}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
horizontal: "▀",
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<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={variant().length > 0 && hints().variant}>
|
||||
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
|
||||
{variant()} variant
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
|
||||
</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
packages/opencode/src/cli/cmd/run/otel.ts
Normal file
119
packages/opencode/src/cli/cmd/run/otel.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
256
packages/opencode/src/cli/cmd/run/permission.shared.ts
Normal file
256
packages/opencode/src/cli/cmd/run/permission.shared.ts
Normal file
@@ -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",
|
||||
}
|
||||
}
|
||||
271
packages/opencode/src/cli/cmd/run/prompt.shared.ts
Normal file
271
packages/opencode/src/cli/cmd/run/prompt.shared.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// 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[]
|
||||
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),
|
||||
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 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,
|
||||
}
|
||||
}
|
||||
340
packages/opencode/src/cli/cmd/run/question.shared.ts
Normal file
340
packages/opencode/src/cli/cmd/run/question.shared.ts
Normal file
@@ -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`
|
||||
}
|
||||
202
packages/opencode/src/cli/cmd/run/runtime.boot.ts
Normal file
202
packages/opencode/src/cli/cmd/run/runtime.boot.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// 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 } from "./types"
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
const DEFAULT_KEYBINDS: FooterKeybinds = {
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
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"], 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 {
|
||||
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 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 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,
|
||||
variantCycle: bindings.join(","),
|
||||
interrupt,
|
||||
historyPrevious: previous,
|
||||
historyNext: next,
|
||||
inputSubmit: submit,
|
||||
inputNewline: newline,
|
||||
}
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = Effect.fn("RunBoot.config")(() =>
|
||||
Effect.promise(loadConfig).pipe(
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
|
||||
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (sdk: RunInput["sdk"], model: RunInput["model"]) {
|
||||
const providers = yield* Effect.promise(() => sdk.provider.list()).pipe(
|
||||
Effect.map((item) => item.data?.all ?? []),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
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 {
|
||||
variants: [],
|
||||
limits,
|
||||
}
|
||||
}
|
||||
|
||||
const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]
|
||||
return {
|
||||
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)).pipe(
|
||||
Effect.orElseSucceed(() => 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"], model: RunInput["model"]): Promise<ModelInfo> {
|
||||
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, 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")
|
||||
}
|
||||
290
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
Normal file
290
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
// 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 during a turn triggers the two-press exit
|
||||
// sequence through RunFooter.requestExit().
|
||||
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { withRunSpan } from "./otel"
|
||||
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
|
||||
const DEFAULT_TITLE = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
type SplashState = {
|
||||
entry: boolean
|
||||
exit: boolean
|
||||
}
|
||||
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: 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
|
||||
onInterrupt?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
}
|
||||
|
||||
export type Lifecycle = {
|
||||
footer: FooterApi
|
||||
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string }): 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 && !DEFAULT_TITLE.test(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 renderer = await createCliRenderer({
|
||||
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,
|
||||
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,
|
||||
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 }) => {
|
||||
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, 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
footer,
|
||||
close,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
235
packages/opencode/src/cli/cmd/run/runtime.queue.ts
Normal file
235
packages/opencode/src/cli/cmd/run/runtime.queue.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
// 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 and /quit 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 } 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
|
||||
onPrompt?: () => 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
|
||||
}
|
||||
|
||||
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 {
|
||||
const task = input.run(prompt, ctrl.signal).then(
|
||||
() => ({ type: "done" as const }),
|
||||
(error) => ({ type: "error" as const, error }),
|
||||
)
|
||||
|
||||
await input.footer.idle()
|
||||
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
input.onPrompt?.()
|
||||
state.queue.push(prompt)
|
||||
emit(
|
||||
{
|
||||
type: "queue",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
17
packages/opencode/src/cli/cmd/run/runtime.shared.ts
Normal file
17
packages/opencode/src/cli/cmd/run/runtime.shared.ts
Normal file
@@ -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
|
||||
}
|
||||
586
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
586
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
// 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 } 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 RunRuntimeInput = {
|
||||
boot: () => Promise<BootContext>
|
||||
afterPaint?: (ctx: BootContext) => Promise<void> | void
|
||||
resolveSession?: (
|
||||
ctx: BootContext,
|
||||
) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
demo?: RunInput["demo"]
|
||||
demoText?: RunInput["demoText"]
|
||||
}
|
||||
|
||||
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>
|
||||
agent: RunInput["agent"]
|
||||
model: RunInput["model"]
|
||||
variant: RunInput["variant"]
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
demo?: RunInput["demo"]
|
||||
demoText?: RunInput["demoText"]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type RuntimeState = {
|
||||
shown: boolean
|
||||
aborting: boolean
|
||||
variants: string[]
|
||||
limits: Record<string, number>
|
||||
activeVariant: string | undefined
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
agent: string | undefined
|
||||
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
|
||||
}
|
||||
|
||||
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.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,
|
||||
variants: [],
|
||||
limits: {},
|
||||
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
|
||||
sessionID: ctx.sessionID,
|
||||
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": ctx.model?.providerID,
|
||||
"opencode.model.id": ctx.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.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: ctx.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 (!ctx.model || state.variants.length === 0) {
|
||||
return {
|
||||
status: "no variants available",
|
||||
}
|
||||
}
|
||||
|
||||
state.activeVariant = cycleVariant(state.activeVariant, state.variants)
|
||||
saveVariant(ctx.model, state.activeVariant)
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
})
|
||||
return {
|
||||
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
|
||||
modelLabel: formatModelLabel(ctx.model, state.activeVariant),
|
||||
}
|
||||
},
|
||||
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] = 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(() => []),
|
||||
])
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({
|
||||
type: "catalog",
|
||||
agents,
|
||||
resources,
|
||||
})
|
||||
}
|
||||
|
||||
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({
|
||||
mode: input.demo,
|
||||
text: input.demoText,
|
||||
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.variants = info.variants
|
||||
state.limits = info.limits
|
||||
|
||||
const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
|
||||
if (next === state.activeVariant) {
|
||||
return
|
||||
}
|
||||
|
||||
state.activeVariant = next
|
||||
setRunSpanAttributes(span, {
|
||||
"opencode.model.variant": state.activeVariant,
|
||||
})
|
||||
if (!ctx.model || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({
|
||||
type: "model",
|
||||
model: formatModelLabel(ctx.model, state.activeVariant),
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
await mod.runPromptQueue({
|
||||
footer,
|
||||
initialInput: input.initialInput,
|
||||
trace: log,
|
||||
onPrompt: () => {
|
||||
state.shown = true
|
||||
},
|
||||
run: async (prompt, signal) => {
|
||||
if (state.demo && (await state.demo.prompt(prompt, signal))) {
|
||||
return
|
||||
}
|
||||
|
||||
return withRunSpan(
|
||||
"RunInteractive.turn",
|
||||
{
|
||||
"opencode.agent.name": state.agent,
|
||||
"opencode.model.provider": ctx.model?.providerID,
|
||||
"opencode.model.id": ctx.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.variant": state.activeVariant,
|
||||
"session.id": state.sessionID || undefined,
|
||||
})
|
||||
await next.handle.runPromptTurn({
|
||||
agent: state.agent,
|
||||
model: ctx.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,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
demoText: input.demoText,
|
||||
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
|
||||
},
|
||||
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): 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,
|
||||
demoText: input.demoText,
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
92
packages/opencode/src/cli/cmd/run/scrollback.shared.ts
Normal file
92
packages/opencode/src/cli/cmd/run/scrollback.shared.ts
Normal file
@@ -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
|
||||
}
|
||||
370
packages/opencode/src/cli/cmd/run/scrollback.surface.ts
Normal file
370
packages/opencode/src/cli/cmd/run/scrollback.surface.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
this.writeSpacer(rows || (!this.rendered && this.wrote ? 1 : 0))
|
||||
|
||||
this.renderer.writeToScrollback(
|
||||
entryWriter({
|
||||
commit,
|
||||
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()
|
||||
}
|
||||
}
|
||||
330
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
Normal file
330
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
|
||||
import { createScrollbackWriter } from "@opentui/solid"
|
||||
import { TextRenderable, type ScrollbackWriter } from "@opentui/core"
|
||||
import { Match, Switch, createMemo } from "solid-js"
|
||||
import { entryBody, entryFlags } from "./entry.body"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, 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"
|
||||
}
|
||||
|
||||
return "inline"
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
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
|
||||
theme?: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
width?: number
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const body = createMemo(() => 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 streaming = createMemo(() => props.commit.phase === "progress")
|
||||
const width = createMemo(() => Math.max(1, Math.trunc(props.width ?? 80)))
|
||||
const view = createMemo(() => toolDiffView(width(), props.opts?.diffStyle))
|
||||
const text = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "text" ? next : undefined
|
||||
})
|
||||
const code = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "code" ? next : undefined
|
||||
})
|
||||
const structured = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "structured" ? next.snapshot : undefined
|
||||
})
|
||||
const markdown = createMemo(() => {
|
||||
const next = body()
|
||||
return next.type === "markdown" ? next : undefined
|
||||
})
|
||||
const code_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "code" ? next : undefined
|
||||
})
|
||||
const diff_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "diff" ? next : undefined
|
||||
})
|
||||
const task_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "task" ? next : undefined
|
||||
})
|
||||
const todo_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "todo" ? next : undefined
|
||||
})
|
||||
const question_snapshot = createMemo(() => {
|
||||
const next = structured()
|
||||
return next?.kind === "question" ? next : undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch fallback={null}>
|
||||
<Match when={text()}>
|
||||
{(body) => (
|
||||
<text width="100%" wrapMode="word" fg={style().fg} attributes={style().attrs}>
|
||||
{body().content}
|
||||
</text>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={code()}>
|
||||
{(body) => (
|
||||
<code
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
filetype={body().filetype}
|
||||
drawUnstyledText={false}
|
||||
streaming={streaming()}
|
||||
syntaxStyle={syntax()}
|
||||
content={body().content}
|
||||
fg={color()}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={code_snapshot()}>
|
||||
{(snap) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{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(snap().file)}
|
||||
streaming={false}
|
||||
syntaxStyle={syntax()}
|
||||
content={snap().content}
|
||||
fg={theme().block.text}
|
||||
/>
|
||||
</line_number>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={diff_snapshot()}>
|
||||
{(snap) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
{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={view()}
|
||||
filetype={toolFiletype(item.file)}
|
||||
syntaxStyle={syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme().block.text}
|
||||
addedBg={theme().block.diffAddedBg}
|
||||
removedBg={theme().block.diffRemovedBg}
|
||||
contextBg={theme().block.diffContextBg}
|
||||
addedSignColor={theme().block.diffHighlightAdded}
|
||||
removedSignColor={theme().block.diffHighlightRemoved}
|
||||
lineNumberFg={theme().block.diffLineNumber}
|
||||
lineNumberBg={theme().block.diffContextBg}
|
||||
addedLineNumberBg={theme().block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme().block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.diffRemoved}>
|
||||
-{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={task_snapshot()}>
|
||||
{(snap) => (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{snap().title}
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
|
||||
{snap().rows.map((row) => (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.text}>
|
||||
{row}
|
||||
</text>
|
||||
))}
|
||||
{snap().tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{snap().tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={todo_snapshot()}>
|
||||
{(snap) => (
|
||||
<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}>
|
||||
{snap().items.map((item) => (
|
||||
<text width="100%" wrapMode="word" fg={todoColor(theme(), item.status)}>
|
||||
{todoText(item)}
|
||||
</text>
|
||||
))}
|
||||
{snap().tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{snap().tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={question_snapshot()}>
|
||||
{(snap) => (
|
||||
<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}>
|
||||
{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>
|
||||
))}
|
||||
{snap().tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
||||
{snap().tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={markdown()}>
|
||||
{(body) => (
|
||||
<markdown
|
||||
width="100%"
|
||||
syntaxStyle={syntax()}
|
||||
streaming={streaming()}
|
||||
content={body().content}
|
||||
fg={color()}
|
||||
tableOptions={{ widthMode: "content" }}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function entryWriter(input: {
|
||||
commit: StreamCommit
|
||||
theme?: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
}): ScrollbackWriter {
|
||||
return createScrollbackWriter(
|
||||
(ctx) => <RunEntryContent commit={input.commit} theme={input.theme} opts={input.opts} width={ctx.width} />,
|
||||
entryFlags(input.commit),
|
||||
)
|
||||
}
|
||||
|
||||
export function spacerWriter(): ScrollbackWriter {
|
||||
return (ctx) => ({
|
||||
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,
|
||||
})
|
||||
}
|
||||
942
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
942
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
@@ -0,0 +1,942 @@
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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") {
|
||||
data.tools.delete(part.id)
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits, view)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
196
packages/opencode/src/cli/cmd/run/session.shared.ts
Normal file
196
packages/opencode/src/cli/cmd/run/session.shared.ts
Normal file
@@ -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
|
||||
}
|
||||
279
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
279
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// Entry and exit splash banners for direct interactive mode scrollback.
|
||||
//
|
||||
// Renders the opencode ASCII logo with half-block shadow characters, the
|
||||
// session title, and contextual hints (entry: "/exit to finish", exit:
|
||||
// "opencode -s <id>" to resume). These are scrollback snapshots, so they
|
||||
// become immutable terminal history once committed.
|
||||
//
|
||||
// The logo uses a cell-based renderer. cells() classifies each character
|
||||
// in the logo 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 { 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
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
return Locale.truncate(text.trim(), 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"))
|
||||
const rightShadow = color(input.theme.rightShadow, fallback(240, "#475569"))
|
||||
let y = 0
|
||||
|
||||
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: y,
|
||||
fg: left,
|
||||
shadow: leftShadow,
|
||||
})
|
||||
draw(lines, rightText, {
|
||||
left: leftText.length + 1,
|
||||
top: y,
|
||||
fg: right,
|
||||
shadow: rightShadow,
|
||||
})
|
||||
y += 1
|
||||
}
|
||||
|
||||
y += 1
|
||||
|
||||
if (input.showSession !== false) {
|
||||
const label = "Session".padEnd(10, " ")
|
||||
push(lines, 0, y, label, input.theme.left, undefined, TextAttributes.DIM)
|
||||
push(lines, label.length, y, meta.title, input.theme.right, undefined, TextAttributes.BOLD)
|
||||
y += 1
|
||||
}
|
||||
|
||||
if (kind === "entry") {
|
||||
push(lines, 0, y, "Type /exit to finish.", input.theme.left, undefined, undefined)
|
||||
y += 1
|
||||
}
|
||||
|
||||
if (kind === "exit") {
|
||||
const next = "Continue".padEnd(10, " ")
|
||||
push(lines, 0, y, next, input.theme.left, undefined, TextAttributes.DIM)
|
||||
push(
|
||||
lines,
|
||||
next.length,
|
||||
y,
|
||||
`opencode -s ${meta.session_id}`,
|
||||
input.theme.right,
|
||||
undefined,
|
||||
TextAttributes.BOLD,
|
||||
)
|
||||
y += 1
|
||||
}
|
||||
|
||||
const height = Math.max(1, y)
|
||||
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)
|
||||
}
|
||||
876
packages/opencode/src/cli/cmd/run/stream.transport.ts
Normal file
876
packages/opencode/src/cli/cmd/run/stream.transport.ts
Normal file
@@ -0,0 +1,876 @@
|
||||
// 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 a session.status idle event
|
||||
// arrives for this session. 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,
|
||||
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 !== "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 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 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")(() =>
|
||||
Effect.promise(() => input.sdk.session.status()).pipe(
|
||||
Effect.map((out) => {
|
||||
const item = out.data?.[input.sessionID]
|
||||
return !item || item.type === "idle"
|
||||
}),
|
||||
Effect.orElseSucceed(() => true),
|
||||
),
|
||||
)
|
||||
|
||||
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 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 || !next.armed || !next.live) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(yield* idle()) || state.wait !== next) {
|
||||
return
|
||||
}
|
||||
|
||||
state.tick = next.tick + 1
|
||||
state.wait = undefined
|
||||
yield* Deferred.succeed(next.done, undefined).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
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 })
|
||||
|
||||
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,
|
||||
],
|
||||
}
|
||||
input.trace?.write("send.prompt", req)
|
||||
|
||||
const send = Effect.promise(() =>
|
||||
input.sdk.session.promptAsync(req, {
|
||||
signal: turn.signal,
|
||||
}),
|
||||
).pipe(
|
||||
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
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
175
packages/opencode/src/cli/cmd/run/stream.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
746
packages/opencode/src/cli/cmd/run/subagent-data.ts
Normal file
746
packages/opencode/src/cli/cmd/run/subagent-data.ts
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
641
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
641
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
// 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)), color.tag)
|
||||
}
|
||||
|
||||
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 luminance(color: RGBA) {
|
||||
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
}
|
||||
|
||||
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 pickPrimaryColor(
|
||||
bg: RGBA,
|
||||
candidates: Array<{
|
||||
key: string
|
||||
color: RGBA | undefined
|
||||
}>,
|
||||
) {
|
||||
return candidates
|
||||
.flatMap((item) => {
|
||||
if (!item.color) return []
|
||||
const contrast = Math.abs(luminance(item.color) - luminance(bg))
|
||||
const vivid = chroma(item.color)
|
||||
if (contrast < 0.16 || vivid < 0.12) return []
|
||||
return [{ key: item.key, color: item.color, score: vivid * 1.5 + contrast }]
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)[0]
|
||||
}
|
||||
|
||||
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 =
|
||||
pickPrimaryColor(bg_snapshot, [
|
||||
{
|
||||
key: "cursor",
|
||||
color: colors.cursorColor ? nearest(RGBA.fromHex(colors.cursorColor)) : undefined,
|
||||
},
|
||||
{
|
||||
key: "selection",
|
||||
color: colors.highlightBackground ? nearest(RGBA.fromHex(colors.highlightBackground)) : undefined,
|
||||
},
|
||||
{
|
||||
key: "blue",
|
||||
color: ansi.blue,
|
||||
},
|
||||
{
|
||||
key: "magenta",
|
||||
color: ansi.magenta,
|
||||
},
|
||||
]) ?? {
|
||||
key: "blue",
|
||||
color: ansi.blue,
|
||||
}
|
||||
|
||||
return {
|
||||
theme: {
|
||||
primary: primary.color,
|
||||
secondary: primary.key === "magenta" ? ansi.blue : ansi.magenta,
|
||||
accent: primary.color,
|
||||
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: theme.diffAddedBg,
|
||||
diffRemovedBg: theme.diffRemovedBg,
|
||||
diffContextBg: theme.diffContextBg,
|
||||
diffHighlightAdded: theme.diffHighlightAdded,
|
||||
diffHighlightRemoved: theme.diffHighlightRemoved,
|
||||
diffLineNumber: theme.diffLineNumber,
|
||||
diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
|
||||
diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const seed = {
|
||||
highlight: rgba("#38bdf8"),
|
||||
muted: rgba("#64748b"),
|
||||
text: rgba("#f8fafc"),
|
||||
panel: rgba("#0f172a"),
|
||||
success: rgba("#22c55e"),
|
||||
warning: rgba("#f59e0b"),
|
||||
error: 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
|
||||
}
|
||||
}
|
||||
1434
packages/opencode/src/cli/cmd/run/tool.ts
Normal file
1434
packages/opencode/src/cli/cmd/run/tool.ts
Normal file
File diff suppressed because it is too large
Load Diff
94
packages/opencode/src/cli/cmd/run/trace.ts
Normal file
94
packages/opencode/src/cli/cmd/run/trace.ts
Normal file
@@ -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
|
||||
}
|
||||
289
packages/opencode/src/cli/cmd/run/types.ts
Normal file
289
packages/opencode/src/cli/cmd/run/types.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// 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 RunPrompt = {
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
}
|
||||
|
||||
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?: RunDemo
|
||||
demoText?: string
|
||||
}
|
||||
|
||||
export type RunDemo = "on" | "permission" | "question" | "mix" | "text"
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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[]
|
||||
}
|
||||
| {
|
||||
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
|
||||
variantCycle: string
|
||||
interrupt: string
|
||||
historyPrevious: string
|
||||
historyNext: 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
|
||||
}
|
||||
200
packages/opencode/src/cli/cmd/run/variant.shared.ts
Normal file
200
packages/opencode/src/cli/cmd/run/variant.shared.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
// 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 } 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)
|
||||
}
|
||||
|
||||
export function formatModelLabel(model: NonNullable<RunInput["model"]>, variant: string | undefined): string {
|
||||
const label = variant ? ` · ${variant}` : ""
|
||||
return `${model.modelID} · ${model.providerID}${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,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"
|
||||
@@ -67,6 +66,7 @@ export const AttachCommand = cmd({
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const config = await TuiConfig.get()
|
||||
const { tui } = await import("./app")
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Adapter = {
|
||||
type Adaptor = {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
@@ -108,26 +108,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[]>()
|
||||
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
const dir = sync.path.directory || sdk.directory
|
||||
const url = new URL("/experimental/workspace/adapter", sdk.url)
|
||||
const url = new URL("/experimental/workspace/adaptor", sdk.url)
|
||||
if (dir) url.searchParams.set("directory", dir)
|
||||
const res = await sdk
|
||||
.fetch(url)
|
||||
.then((x) => x.json() as Promise<Adapter[]>)
|
||||
.then((x) => x.json() as Promise<Adaptor[]>)
|
||||
.catch(() => undefined)
|
||||
if (!res) {
|
||||
toast.show({
|
||||
message: "Failed to load workspace adapters",
|
||||
message: "Failed to load workspace adaptors",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
setAdapters(res)
|
||||
setAdaptors(res)
|
||||
})()
|
||||
})
|
||||
|
||||
@@ -142,13 +142,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
},
|
||||
]
|
||||
}
|
||||
const list = adapters()
|
||||
const list = adaptors()
|
||||
if (!list) {
|
||||
return [
|
||||
{
|
||||
title: "Loading workspaces...",
|
||||
value: "loading" as const,
|
||||
description: "Fetching available workspace adapters",
|
||||
description: "Fetching available workspace adaptors",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
|
||||
import { useTheme, tint } from "@tui/context/theme"
|
||||
import * as Sound from "@tui/util/sound"
|
||||
@@ -555,7 +554,6 @@ function buildIdleState(t: number, ctx: LogoContext): IdleState {
|
||||
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
|
||||
const ctx = props.shape ? build(props.shape) : DEFAULT
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [rings, setRings] = createSignal<Ring[]>([])
|
||||
const [hold, setHold] = createSignal<Hold>()
|
||||
const [release, setRelease] = createSignal<Release>()
|
||||
@@ -686,7 +684,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
|
||||
})
|
||||
|
||||
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
|
||||
const useSubpixelBlocks = () => renderer.capabilities?.rgb === true
|
||||
|
||||
const renderLine = (
|
||||
line: string,
|
||||
@@ -792,7 +789,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
|
||||
}
|
||||
|
||||
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
|
||||
if (char === "█" && useSubpixelBlocks()) {
|
||||
if (char === "█") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(inkTop, theme, n + p + e + b)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -514,7 +514,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)
|
||||
@@ -710,11 +710,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,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() {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { ProjectID } from "@/project/schema"
|
||||
import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types"
|
||||
import { WorktreeAdapter } from "./worktree"
|
||||
|
||||
const BUILTIN: Record<string, WorkspaceAdapter> = {
|
||||
worktree: WorktreeAdapter,
|
||||
}
|
||||
|
||||
const state = new Map<ProjectID, Map<string, WorkspaceAdapter>>()
|
||||
|
||||
export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter {
|
||||
const custom = state.get(projectID)?.get(type)
|
||||
if (custom) return custom
|
||||
|
||||
const builtin = BUILTIN[type]
|
||||
if (builtin) return builtin
|
||||
|
||||
throw new Error(`Unknown workspace adapter: ${type}`)
|
||||
}
|
||||
|
||||
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
|
||||
const builtin = await Promise.all(
|
||||
Object.entries(BUILTIN).map(async ([type, adapter]) => {
|
||||
return {
|
||||
type,
|
||||
name: adapter.name,
|
||||
description: adapter.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
|
||||
type,
|
||||
name: adapter.name,
|
||||
description: adapter.description,
|
||||
}))
|
||||
return [...builtin, ...custom]
|
||||
}
|
||||
|
||||
// Plugins can be loaded per-project so we need to scope them. If you
|
||||
// want to install a global one pass `ProjectID.global`
|
||||
export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) {
|
||||
const adapters = state.get(projectID) ?? new Map<string, WorkspaceAdapter>()
|
||||
adapters.set(type, adapter)
|
||||
state.set(projectID, adapters)
|
||||
}
|
||||
45
packages/opencode/src/control-plane/adaptors/index.ts
Normal file
45
packages/opencode/src/control-plane/adaptors/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ProjectID } from "@/project/schema"
|
||||
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
|
||||
import { WorktreeAdaptor } from "./worktree"
|
||||
|
||||
const BUILTIN: Record<string, WorkspaceAdaptor> = {
|
||||
worktree: WorktreeAdaptor,
|
||||
}
|
||||
|
||||
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
|
||||
|
||||
export function getAdaptor(projectID: ProjectID, type: string): WorkspaceAdaptor {
|
||||
const custom = state.get(projectID)?.get(type)
|
||||
if (custom) return custom
|
||||
|
||||
const builtin = BUILTIN[type]
|
||||
if (builtin) return builtin
|
||||
|
||||
throw new Error(`Unknown workspace adaptor: ${type}`)
|
||||
}
|
||||
|
||||
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
|
||||
const builtin = await Promise.all(
|
||||
Object.entries(BUILTIN).map(async ([type, adaptor]) => {
|
||||
return {
|
||||
type,
|
||||
name: adaptor.name,
|
||||
description: adaptor.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
|
||||
type,
|
||||
name: adaptor.name,
|
||||
description: adaptor.description,
|
||||
}))
|
||||
return [...builtin, ...custom]
|
||||
}
|
||||
|
||||
// Plugins can be loaded per-project so we need to scope them. If you
|
||||
// want to install a global one pass `ProjectID.global`
|
||||
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
|
||||
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
|
||||
adaptors.set(type, adaptor)
|
||||
state.set(projectID, adaptors)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
|
||||
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
|
||||
|
||||
const WorktreeConfig = Schema.Struct({
|
||||
name: WorkspaceInfo.fields.name,
|
||||
@@ -13,7 +13,7 @@ async function loadWorktree() {
|
||||
return { AppRuntime, Worktree }
|
||||
}
|
||||
|
||||
export const WorktreeAdapter: WorkspaceAdapter = {
|
||||
export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
async configure(info) {
|
||||
@@ -17,12 +17,12 @@ export const WorkspaceInfo = Schema.Struct({
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
|
||||
|
||||
export const WorkspaceAdapterEntry = Schema.Struct({
|
||||
export const WorkspaceAdaptorEntry = Schema.Struct({
|
||||
type: Schema.String,
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type WorkspaceAdapterEntry = Schema.Schema.Type<typeof WorkspaceAdapterEntry>
|
||||
export type WorkspaceAdaptorEntry = Schema.Schema.Type<typeof WorkspaceAdaptorEntry>
|
||||
|
||||
export type Target =
|
||||
| {
|
||||
@@ -35,7 +35,7 @@ export type Target =
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type WorkspaceAdapter = {
|
||||
export type WorkspaceAdaptor = {
|
||||
name: string
|
||||
description: string
|
||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
import { getAdapter } from "./adapters"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -335,8 +335,8 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space)))
|
||||
const adaptor = getAdaptor(space.projectID, space.type)
|
||||
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
|
||||
|
||||
if (target.type === "local") return
|
||||
|
||||
@@ -419,8 +419,8 @@ export const layer = Layer.effect(
|
||||
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
|
||||
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space)))
|
||||
const adaptor = getAdaptor(space.projectID, space.type)
|
||||
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
|
||||
|
||||
if (target.type === "local") {
|
||||
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
|
||||
@@ -458,9 +458,9 @@ export const layer = Layer.effect(
|
||||
|
||||
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
|
||||
const id = WorkspaceID.ascending(input.id)
|
||||
const adapter = getAdapter(input.projectID, input.type)
|
||||
const adaptor = getAdaptor(input.projectID, input.type)
|
||||
const config = yield* Effect.promise(() =>
|
||||
Promise.resolve(adapter.configure({ ...input, id, name: Slug.create(), directory: null })),
|
||||
Promise.resolve(adaptor.configure({ ...input, id, name: Slug.create(), directory: null })),
|
||||
)
|
||||
|
||||
const info: Info = {
|
||||
@@ -496,7 +496,7 @@ export const layer = Layer.effect(
|
||||
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => adapter.create(config, env))
|
||||
yield* Effect.promise(() => adaptor.create(config, env))
|
||||
yield* Effect.all(
|
||||
[
|
||||
waitEvent({
|
||||
@@ -531,8 +531,8 @@ export const layer = Layer.effect(
|
||||
workspaceID: input.workspaceID,
|
||||
})
|
||||
|
||||
const adapter = getAdapter(space.projectID, space.type)
|
||||
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space)))
|
||||
const adaptor = getAdaptor(space.projectID, space.type)
|
||||
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
|
||||
|
||||
yield* sync.run(Session.Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -726,12 +726,12 @@ export const layer = Layer.effect(
|
||||
const info = fromRow(row)
|
||||
yield* Effect.catch(
|
||||
Effect.gen(function* () {
|
||||
const adapter = getAdapter(info.projectID, row.type)
|
||||
yield* Effect.tryPromise(() => Promise.resolve(adapter.remove(info)))
|
||||
const adaptor = getAdaptor(info.projectID, row.type)
|
||||
yield* Effect.tryPromise(() => Promise.resolve(adaptor.remove(info)))
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
log.error("adapter not available when removing workspace", { type: row.type })
|
||||
log.error("adaptor not available when removing workspace", { type: row.type })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
PluginInput,
|
||||
Plugin as PluginInstance,
|
||||
PluginModule,
|
||||
WorkspaceAdapter as PluginWorkspaceAdapter,
|
||||
WorkspaceAdaptor as PluginWorkspaceAdaptor,
|
||||
} from "@opencode-ai/plugin"
|
||||
import { Config } from "@/config/config"
|
||||
import { Bus } from "../bus"
|
||||
@@ -24,8 +24,8 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { PluginLoader } from "./loader"
|
||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||
import { registerAdapter } from "@/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "@/control-plane/types"
|
||||
import { registerAdaptor } from "@/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "@/control-plane/types"
|
||||
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
@@ -138,8 +138,8 @@ export const layer = Layer.effect(
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
experimental_workspace: {
|
||||
register(type: string, adapter: PluginWorkspaceAdapter) {
|
||||
registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter)
|
||||
register(type: string, adaptor: PluginWorkspaceAdaptor) {
|
||||
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
|
||||
},
|
||||
},
|
||||
get serverUrl(): URL {
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { listAdapters } from "@/control-plane/adapters"
|
||||
import { listAdaptors } from "@/control-plane/adaptors"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { errors } from "../../error"
|
||||
@@ -18,24 +18,24 @@ const log = Log.create({ service: "server.workspace" })
|
||||
export const WorkspaceRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/adapter",
|
||||
"/adaptor",
|
||||
describeRoute({
|
||||
summary: "List workspace adapters",
|
||||
description: "List all available workspace adapters for the current project.",
|
||||
operationId: "experimental.workspace.adapter.list",
|
||||
summary: "List workspace adaptors",
|
||||
description: "List all available workspace adaptors for the current project.",
|
||||
operationId: "experimental.workspace.adaptor.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace adapters",
|
||||
description: "Workspace adaptors",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))),
|
||||
schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await listAdapters(Instance.project.id))
|
||||
return c.json(await listAdaptors(Instance.project.id))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
@@ -16,7 +16,7 @@ export const SessionRestoreResponse = Schema.Struct({
|
||||
})
|
||||
|
||||
export const WorkspacePaths = {
|
||||
adapters: `${root}/adapter`,
|
||||
adaptors: `${root}/adaptor`,
|
||||
list: root,
|
||||
status: `${root}/status`,
|
||||
remove: `${root}/:id`,
|
||||
@@ -27,13 +27,13 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
.add(
|
||||
HttpApiGroup.make("workspace")
|
||||
.add(
|
||||
HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, {
|
||||
success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"),
|
||||
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
|
||||
success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.workspace.adapter.list",
|
||||
summary: "List workspace adapters",
|
||||
description: "List all available workspace adapters for the current project.",
|
||||
identifier: "experimental.workspace.adaptor.list",
|
||||
summary: "List workspace adaptors",
|
||||
description: "List all available workspace adaptors for the current project.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", WorkspacePaths.list, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listAdapters } from "@/control-plane/adapters"
|
||||
import { listAdaptors } from "@/control-plane/adaptors"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Effect } from "effect"
|
||||
@@ -10,9 +10,9 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
|
||||
const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () {
|
||||
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
|
||||
const instance = yield* InstanceState.context
|
||||
return yield* Effect.promise(() => listAdapters(instance.project.id))
|
||||
return yield* Effect.promise(() => listAdaptors(instance.project.id))
|
||||
})
|
||||
|
||||
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
|
||||
@@ -51,7 +51,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("adapters", adapters)
|
||||
.handle("adaptors", adaptors)
|
||||
.handle("list", list)
|
||||
.handle("create", create)
|
||||
.handle("status", status)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAdapter } from "@/control-plane/adapters"
|
||||
import { getAdaptor } from "@/control-plane/adaptors"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import type { Target } from "@/control-plane/types"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
@@ -89,8 +89,8 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
|
||||
|
||||
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
|
||||
return Effect.gen(function* () {
|
||||
const adapter = yield* Effect.sync(() => getAdapter(workspace.projectID, workspace.type))
|
||||
return yield* Effect.promise(() => Promise.resolve(adapter.target(workspace)))
|
||||
const adaptor = yield* Effect.sync(() => getAdaptor(workspace.projectID, workspace.type))
|
||||
return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace)))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { getAdapter } from "@/control-plane/adapters"
|
||||
import { getAdaptor } from "@/control-plane/adaptors"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
@@ -91,8 +91,8 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
return next()
|
||||
}
|
||||
|
||||
const adapter = getAdapter(workspace.projectID, workspace.type)
|
||||
const target = await adapter.target(workspace)
|
||||
const adaptor = getAdaptor(workspace.projectID, workspace.type)
|
||||
const target = await adaptor.target(workspace)
|
||||
|
||||
if (target.type === "local") {
|
||||
return WorkspaceContext.provide({
|
||||
|
||||
@@ -142,9 +142,9 @@ const Share = Schema.Struct({
|
||||
url: Schema.String,
|
||||
})
|
||||
|
||||
// Legacy HTTP accepted negative values here. Keep archive timestamps permissive
|
||||
// while excluding non-finite values that cannot round-trip through JSON.
|
||||
export const ArchivedTimestamp = Schema.Finite
|
||||
// Legacy HTTP accepted any number here, and persisted data may already contain
|
||||
// negative values. Keep archive timestamps permissive while other clocks stay non-negative.
|
||||
export const ArchivedTimestamp = Schema.Number
|
||||
|
||||
const Time = Schema.Struct({
|
||||
created: NonNegativeInt,
|
||||
|
||||
@@ -94,7 +94,7 @@ Importantly, **sync events automatically re-publish as bus events**. This makes
|
||||
|
||||
### Event shape
|
||||
|
||||
- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event through the bus.
|
||||
- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus.
|
||||
|
||||
The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types.
|
||||
|
||||
@@ -112,9 +112,9 @@ The system install projectors in `server/projectors.js`. It calls `SyncEvent.ini
|
||||
|
||||
This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat.
|
||||
|
||||
The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync event only contains the fields updated. We convert the event to contain the full object for backwards compatibility (but ideally we'd remove this).
|
||||
The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this).
|
||||
|
||||
It's very important that types are correct when working with events. Event definitions have a `schema` which carries the definition of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples:
|
||||
It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples:
|
||||
|
||||
```ts
|
||||
// The schema from `Updated` typechecks the object correctly
|
||||
|
||||
348
packages/opencode/test/cli/run/entry.body.test.ts
Normal file
348
packages/opencode/test/cli/run/entry.body.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
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("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",
|
||||
})
|
||||
})
|
||||
})
|
||||
48
packages/opencode/test/cli/run/footer.view.test.tsx
Normal file
48
packages/opencode/test/cli/run/footer.view.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
|
||||
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
||||
import type { StreamCommit } from "@/cli/cmd/run/types"
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
144
packages/opencode/test/cli/run/permission.shared.test.ts
Normal file
144
packages/opencode/test/cli/run/permission.shared.test.ts
Normal file
@@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
115
packages/opencode/test/cli/run/prompt.shared.test.ts
Normal file
115
packages/opencode/test/cli/run/prompt.shared.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createPromptHistory,
|
||||
isExitCommand,
|
||||
movePromptHistory,
|
||||
printableBinding,
|
||||
promptCycle,
|
||||
promptInfo,
|
||||
promptKeys,
|
||||
pushPromptHistory,
|
||||
} from "@/cli/cmd/run/prompt.shared"
|
||||
import type { FooterKeybinds, RunPrompt } from "@/cli/cmd/run/types"
|
||||
|
||||
const keybinds: FooterKeybinds = {
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
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(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)
|
||||
})
|
||||
})
|
||||
115
packages/opencode/test/cli/run/question.shared.test.ts
Normal file
115
packages/opencode/test/cli/run/question.shared.test.ts
Normal file
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
165
packages/opencode/test/cli/run/runtime.boot.test.ts
Normal file
165
packages/opencode/test/cli/run/runtime.boot.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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 ",
|
||||
history_previous: " k ",
|
||||
history_next: " j ",
|
||||
input_submit: " ctrl+s ",
|
||||
input_newline: " alt+return ",
|
||||
},
|
||||
})
|
||||
|
||||
await expect(resolveFooterKeybinds()).resolves.toEqual({
|
||||
leader: "ctrl+g",
|
||||
variantCycle: "ctrl+t,<leader>t,alt+t",
|
||||
interrupt: "ctrl+c",
|
||||
historyPrevious: "k",
|
||||
historyNext: "j",
|
||||
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",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
})
|
||||
})
|
||||
|
||||
test("collects model variants and context limits", 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.provider, "list").mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
data,
|
||||
error: undefined,
|
||||
request: new Request("https://opencode.test"),
|
||||
response: new Response(),
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(resolveModelInfo(sdk, { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
|
||||
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")
|
||||
})
|
||||
})
|
||||
248
packages/opencode/test/cli/run/runtime.queue.test.ts
Normal file
248
packages/opencode/test/cli/run/runtime.queue.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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("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("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")
|
||||
})
|
||||
})
|
||||
500
packages/opencode/test/cli/run/scrollback.surface.test.ts
Normal file
500
packages/opencode/test/cli/run/scrollback.surface.test.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
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 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 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()
|
||||
}
|
||||
})
|
||||
383
packages/opencode/test/cli/run/session-data.test.ts
Normal file
383
packages/opencode/test/cli/run/session-data.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
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("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",
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
247
packages/opencode/test/cli/run/session.shared.test.ts
Normal file
247
packages/opencode/test/cli/run/session.shared.test.ts
Normal file
@@ -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")
|
||||
})
|
||||
})
|
||||
55
packages/opencode/test/cli/run/stream.test.ts
Normal file
55
packages/opencode/test/cli/run/stream.test.ts
Normal file
@@ -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",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
712
packages/opencode/test/cli/run/stream.transport.test.ts
Normal file
712
packages/opencode/test/cli/run/stream.transport.test.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
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 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 }
|
||||
}
|
||||
|
||||
function busy(sessionID = "session-1") {
|
||||
return {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "busy",
|
||||
},
|
||||
},
|
||||
} satisfies SdkEvent
|
||||
}
|
||||
|
||||
function idle(sessionID = "session-1") {
|
||||
return {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "idle",
|
||||
},
|
||||
},
|
||||
} satisfies SdkEvent
|
||||
}
|
||||
|
||||
function assistant(id: string) {
|
||||
return {
|
||||
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 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 textPart(id: string, messageID: string, text: string): TextPart {
|
||||
return {
|
||||
id,
|
||||
sessionID: "session-1",
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
function textUpdated(part: TextPart): SdkEvent {
|
||||
return {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function textDelta(messageID: string, partID: string, delta: string): SdkEvent {
|
||||
return {
|
||||
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("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("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()
|
||||
}
|
||||
})
|
||||
})
|
||||
328
packages/opencode/test/cli/run/subagent-data.test.ts
Normal file
328
packages/opencode/test/cli/run/subagent-data.test.ts
Normal file
@@ -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" }),
|
||||
])
|
||||
})
|
||||
})
|
||||
116
packages/opencode/test/cli/run/theme.test.ts
Normal file
116
packages/opencode/test/cli/run/theme.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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 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)
|
||||
expect(RGBA.getIntentTag(expectRgba(theme.splash.left))).toBeLessThan(256)
|
||||
expect(RGBA.getIntentTag(expectRgba(theme.splash.right))).toBeLessThan(256)
|
||||
expect(RGBA.getIntentTag(expectRgba(theme.splash.leftShadow))).toBeLessThan(256)
|
||||
expect(RGBA.getIntentTag(expectRgba(theme.splash.rightShadow))).toBeLessThan(256)
|
||||
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)
|
||||
})
|
||||
152
packages/opencode/test/cli/run/variant.shared.test.ts
Normal file
152
packages/opencode/test/cli/run/variant.shared.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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 { testEffect } from "../../lib/effect"
|
||||
|
||||
const model = {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
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",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getAdapter, registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import type { WorkspaceInfo } from "../../src/control-plane/types"
|
||||
|
||||
@@ -15,7 +15,7 @@ function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInf
|
||||
}
|
||||
}
|
||||
|
||||
function adapter(dir: string) {
|
||||
function adaptor(dir: string) {
|
||||
return {
|
||||
name: dir,
|
||||
description: dir,
|
||||
@@ -33,19 +33,19 @@ function adapter(dir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
describe("control-plane/adapters", () => {
|
||||
test("isolates custom adapters by project", async () => {
|
||||
describe("control-plane/adaptors", () => {
|
||||
test("isolates custom adaptors by project", async () => {
|
||||
const type = `demo-${Math.random().toString(36).slice(2)}`
|
||||
const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
|
||||
const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
|
||||
registerAdapter(one, type, adapter("/one"))
|
||||
registerAdapter(two, type, adapter("/two"))
|
||||
registerAdaptor(one, type, adaptor("/one"))
|
||||
registerAdaptor(two, type, adaptor("/two"))
|
||||
|
||||
expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({
|
||||
expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({
|
||||
type: "local",
|
||||
directory: "/one",
|
||||
})
|
||||
expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({
|
||||
expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({
|
||||
type: "local",
|
||||
directory: "/two",
|
||||
})
|
||||
@@ -54,16 +54,16 @@ describe("control-plane/adapters", () => {
|
||||
test("latest install wins within a project", async () => {
|
||||
const type = `demo-${Math.random().toString(36).slice(2)}`
|
||||
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
|
||||
registerAdapter(id, type, adapter("/one"))
|
||||
registerAdaptor(id, type, adaptor("/one"))
|
||||
|
||||
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
|
||||
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
|
||||
type: "local",
|
||||
directory: "/one",
|
||||
})
|
||||
|
||||
registerAdapter(id, type, adapter("/two"))
|
||||
registerAdaptor(id, type, adaptor("/two"))
|
||||
|
||||
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
|
||||
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
|
||||
type: "local",
|
||||
directory: "/two",
|
||||
})
|
||||
@@ -23,10 +23,10 @@ import { EventSequenceTable, EventTable } from "@/sync/event.sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
|
||||
import type { Target, WorkspaceAdaptor, WorkspaceInfo } from "../../src/control-plane/types"
|
||||
import * as WorkspaceOld from "../../src/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
@@ -53,8 +53,8 @@ type RecordedCreate = {
|
||||
from?: WorkspaceInfo
|
||||
}
|
||||
|
||||
type RecordedAdapter = {
|
||||
adapter: WorkspaceAdapter
|
||||
type RecordedAdaptor = {
|
||||
adaptor: WorkspaceAdaptor
|
||||
calls: {
|
||||
configure: WorkspaceInfo[]
|
||||
create: RecordedCreate[]
|
||||
@@ -165,13 +165,13 @@ function eventuallyEffect(effect: Effect.Effect<void>, timeout = 1500) {
|
||||
})
|
||||
}
|
||||
|
||||
function recordedAdapter(input: {
|
||||
function recordedAdaptor(input: {
|
||||
target: (info: WorkspaceInfo) => Target | Promise<Target>
|
||||
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
|
||||
remove?: (info: WorkspaceInfo) => Promise<void>
|
||||
}): RecordedAdapter {
|
||||
const calls: RecordedAdapter["calls"] = {
|
||||
}): RecordedAdaptor {
|
||||
const calls: RecordedAdaptor["calls"] = {
|
||||
configure: [],
|
||||
create: [],
|
||||
remove: [],
|
||||
@@ -180,7 +180,7 @@ function recordedAdapter(input: {
|
||||
|
||||
return {
|
||||
calls,
|
||||
adapter: {
|
||||
adaptor: {
|
||||
name: "recorded",
|
||||
description: "recorded",
|
||||
configure(info) {
|
||||
@@ -207,8 +207,8 @@ function recordedAdapter(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise<void> }) {
|
||||
return recordedAdapter({
|
||||
function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise<void> }) {
|
||||
return recordedAdaptor({
|
||||
configure(info) {
|
||||
return { ...info, directory: dir }
|
||||
},
|
||||
@@ -223,8 +223,8 @@ function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info
|
||||
})
|
||||
}
|
||||
|
||||
function remoteAdapter(url: string, input?: { directory?: string | null; headers?: HeadersInit }) {
|
||||
return recordedAdapter({
|
||||
function remoteAdaptor(url: string, input?: { directory?: string | null; headers?: HeadersInit }) {
|
||||
return recordedAdaptor({
|
||||
configure(info) {
|
||||
return { ...info, directory: input?.directory ?? info.directory }
|
||||
},
|
||||
@@ -429,7 +429,7 @@ describe("workspace-old CRUD", () => {
|
||||
const workspaceID = WorkspaceID.ascending("wrk_create_local")
|
||||
const type = unique("create-local")
|
||||
const targetDir = path.join(dir, "created-local")
|
||||
const recorded = recordedAdapter({
|
||||
const recorded = recordedAdaptor({
|
||||
configure(info) {
|
||||
return {
|
||||
...info,
|
||||
@@ -446,7 +446,7 @@ describe("workspace-old CRUD", () => {
|
||||
return { type: "local", directory: targetDir }
|
||||
},
|
||||
})
|
||||
registerAdapter(Instance.project.id, type, recorded.adapter)
|
||||
registerAdaptor(Instance.project.id, type, recorded.adaptor)
|
||||
|
||||
const info = await createWorkspace({
|
||||
id: workspaceID,
|
||||
@@ -489,17 +489,17 @@ describe("workspace-old CRUD", () => {
|
||||
test("create propagates configure failures and does not insert a workspace", async () => {
|
||||
await withInstance(async () => {
|
||||
const type = unique("configure-failure")
|
||||
registerAdapter(
|
||||
registerAdaptor(
|
||||
Instance.project.id,
|
||||
type,
|
||||
recordedAdapter({
|
||||
recordedAdaptor({
|
||||
configure() {
|
||||
throw new Error("configure exploded")
|
||||
},
|
||||
target() {
|
||||
return { type: "local", directory: "/unused" }
|
||||
},
|
||||
}).adapter,
|
||||
}).adaptor,
|
||||
)
|
||||
|
||||
await expect(
|
||||
@@ -509,10 +509,10 @@ describe("workspace-old CRUD", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("create leaves the inserted row when adapter create fails", async () => {
|
||||
test("create leaves the inserted row when adaptor create fails", async () => {
|
||||
await withInstance(async () => {
|
||||
const type = unique("create-failure")
|
||||
const recorded = recordedAdapter({
|
||||
const recorded = recordedAdaptor({
|
||||
async create() {
|
||||
throw new Error("create exploded")
|
||||
},
|
||||
@@ -520,7 +520,7 @@ describe("workspace-old CRUD", () => {
|
||||
return { type: "local", directory: "/unused" }
|
||||
},
|
||||
})
|
||||
registerAdapter(Instance.project.id, type, recorded.adapter)
|
||||
registerAdaptor(Instance.project.id, type, recorded.adaptor)
|
||||
|
||||
await expect(
|
||||
createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }),
|
||||
@@ -538,8 +538,8 @@ describe("workspace-old CRUD", () => {
|
||||
await withInstance(async (dir) => {
|
||||
const type = unique("local-error")
|
||||
const missing = path.join(dir, "missing-local-target")
|
||||
const recorded = localAdapter(missing, { createDir: false })
|
||||
registerAdapter(Instance.project.id, type, recorded.adapter)
|
||||
const recorded = localAdaptor(missing, { createDir: false })
|
||||
registerAdaptor(Instance.project.id, type, recorded.adaptor)
|
||||
|
||||
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
|
||||
|
||||
@@ -576,8 +576,8 @@ describe("workspace-old CRUD", () => {
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* WorkspaceOld.Service
|
||||
const type = unique("remote-create")
|
||||
const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir })
|
||||
registerAdapter(Instance.project.id, type, recorded.adapter)
|
||||
const recorded = remoteAdaptor(`${url}/base/?ignored=1#hash`, { directory: dir })
|
||||
registerAdaptor(Instance.project.id, type, recorded.adaptor)
|
||||
|
||||
const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null })
|
||||
|
||||
@@ -603,11 +603,11 @@ describe("workspace-old CRUD", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => {
|
||||
test("remove deletes the workspace, associated sessions, adaptor resources, and status", async () => {
|
||||
await withInstance(async (dir) => {
|
||||
const type = unique("remove-local")
|
||||
const recorded = localAdapter(path.join(dir, "remove-local"))
|
||||
registerAdapter(Instance.project.id, type, recorded.adapter)
|
||||
const recorded = localAdaptor(path.join(dir, "remove-local"))
|
||||
registerAdaptor(Instance.project.id, type, recorded.adaptor)
|
||||
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
|
||||
const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||
const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||
@@ -628,21 +628,21 @@ describe("workspace-old CRUD", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("remove still deletes the row when the adapter cannot remove resources", async () => {
|
||||
test("remove still deletes the row when the adaptor cannot remove resources", async () => {
|
||||
await withInstance(async () => {
|
||||
const type = unique("remove-throws")
|
||||
const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") })
|
||||
registerAdapter(
|
||||
registerAdaptor(
|
||||
Instance.project.id,
|
||||
type,
|
||||
recordedAdapter({
|
||||
recordedAdaptor({
|
||||
async remove() {
|
||||
throw new Error("remove exploded")
|
||||
},
|
||||
target() {
|
||||
return { type: "local", directory: "/unused" }
|
||||
},
|
||||
}).adapter,
|
||||
}).adaptor,
|
||||
)
|
||||
insertWorkspace(info)
|
||||
|
||||
@@ -661,7 +661,7 @@ describe("workspace-old sync state", () => {
|
||||
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
||||
attachSessionToWorkspace(session.id, info.id)
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter)
|
||||
registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "flag-disabled")).adaptor)
|
||||
|
||||
startWorkspaceSyncing(Instance.project.id)
|
||||
await delay(25)
|
||||
@@ -682,8 +682,8 @@ describe("workspace-old sync state", () => {
|
||||
await fs.mkdir(withoutSessionDir, { recursive: true })
|
||||
insertWorkspace(withSession)
|
||||
insertWorkspace(withoutSession)
|
||||
registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter)
|
||||
registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter)
|
||||
registerAdaptor(Instance.project.id, withSessionType, localAdaptor(withSessionDir).adaptor)
|
||||
registerAdaptor(Instance.project.id, withoutSessionType, localAdaptor(withoutSessionDir).adaptor)
|
||||
attachSessionToWorkspace(
|
||||
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
|
||||
withSession.id,
|
||||
@@ -707,10 +707,10 @@ describe("workspace-old sync state", () => {
|
||||
const type = unique("missing-local")
|
||||
const info = workspaceInfo(Instance.project.id, type)
|
||||
insertWorkspace(info)
|
||||
registerAdapter(
|
||||
registerAdaptor(
|
||||
Instance.project.id,
|
||||
type,
|
||||
localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter,
|
||||
localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor,
|
||||
)
|
||||
attachSessionToWorkspace(
|
||||
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
|
||||
@@ -738,7 +738,7 @@ describe("workspace-old sync state", () => {
|
||||
const target = path.join(dir, "dedupe-local")
|
||||
await fs.mkdir(target, { recursive: true })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, localAdapter(target).adapter)
|
||||
registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor)
|
||||
attachSessionToWorkspace(
|
||||
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
|
||||
info.id,
|
||||
@@ -795,7 +795,7 @@ describe("workspace-old sync state", () => {
|
||||
const type = unique("remote-start")
|
||||
const info = workspaceInfo(Instance.project.id, type)
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sync`).adapter)
|
||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor)
|
||||
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
|
||||
|
||||
yield* workspace.startWorkspaceSyncing(Instance.project.id)
|
||||
@@ -850,7 +850,7 @@ describe("workspace-old sync state", () => {
|
||||
const type = unique("remote-connect-fail")
|
||||
const info = workspaceInfo(Instance.project.id, type)
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/failed`).adapter)
|
||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor)
|
||||
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
|
||||
|
||||
yield* workspace.startWorkspaceSyncing(Instance.project.id)
|
||||
@@ -890,7 +890,7 @@ describe("workspace-old sync state", () => {
|
||||
const type = unique("remote-history-fail")
|
||||
const info = workspaceInfo(Instance.project.id, type)
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter)
|
||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history-failed`).adaptor)
|
||||
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
|
||||
|
||||
yield* workspace.startWorkspaceSyncing(Instance.project.id)
|
||||
@@ -947,7 +947,7 @@ describe("workspace-old sync state", () => {
|
||||
const type = unique("history-replay")
|
||||
const info = workspaceInfo(Instance.project.id, type)
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history`).adapter)
|
||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor)
|
||||
const session = yield* sessionSvc.create({ title: "before history" })
|
||||
attachSessionToWorkspace(session.id, info.id)
|
||||
historySessionID = session.id
|
||||
@@ -1014,7 +1014,7 @@ describe("workspace-old sync state", () => {
|
||||
const type = unique("sse-forward")
|
||||
const info = workspaceInfo(Instance.project.id, type)
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter)
|
||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor)
|
||||
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
|
||||
|
||||
yield* workspace.startWorkspaceSyncing(Instance.project.id)
|
||||
@@ -1095,7 +1095,7 @@ describe("workspace-old sync state", () => {
|
||||
const type = unique("sse-sync")
|
||||
const info = workspaceInfo(Instance.project.id, type)
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter)
|
||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor)
|
||||
const session = yield* sessionSvc.create({ title: "before sse" })
|
||||
attachSessionToWorkspace(session.id, info.id)
|
||||
sseSessionID = session.id
|
||||
@@ -1232,7 +1232,7 @@ describe("workspace-old sessionRestore", () => {
|
||||
const type = unique("restore-missing-session")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
|
||||
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
|
||||
|
||||
await expect(
|
||||
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
|
||||
@@ -1273,13 +1273,13 @@ describe("workspace-old sessionRestore", () => {
|
||||
const type = unique("restore-remote")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(
|
||||
registerAdaptor(
|
||||
Instance.project.id,
|
||||
type,
|
||||
remoteAdapter(`${url}/restore/?ignored=1#hash`, {
|
||||
remoteAdaptor(`${url}/restore/?ignored=1#hash`, {
|
||||
directory: dir,
|
||||
headers: { authorization: "Bearer restore" },
|
||||
}).adapter,
|
||||
}).adaptor,
|
||||
)
|
||||
const session = yield* sessionSvc.create({ title: "restore remote" })
|
||||
replaceSessionEvents(session.id, 24)
|
||||
@@ -1353,7 +1353,7 @@ describe("workspace-old sessionRestore", () => {
|
||||
const type = unique("restore-null-dir")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: null })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
|
||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor)
|
||||
const session = yield* sessionSvc.create({ title: "null dir" })
|
||||
replaceSessionEvents(session.id, 0)
|
||||
|
||||
@@ -1397,7 +1397,7 @@ describe("workspace-old sessionRestore", () => {
|
||||
const type = unique("restore-remote-fail")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
|
||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor)
|
||||
const session = yield* sessionSvc.create({ title: "restore fail" })
|
||||
replaceSessionEvents(session.id, 11)
|
||||
|
||||
@@ -1437,7 +1437,7 @@ describe("workspace-old sessionRestore", () => {
|
||||
const type = unique("restore-local")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
|
||||
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
|
||||
const session = yield* sessionSvc.create({ title: "restore local" })
|
||||
replaceSessionEvents(session.id, 20)
|
||||
|
||||
@@ -1488,7 +1488,7 @@ describe("workspace-old sessionRestore", () => {
|
||||
const type = unique("restore-real-events")
|
||||
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
|
||||
insertWorkspace(info)
|
||||
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
|
||||
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor)
|
||||
const session = yield* sessionSvc.create({ title: "real events" })
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const msg = yield* sessionSvc.updateMessage({
|
||||
|
||||
@@ -34,7 +34,7 @@ afterAll(() => {
|
||||
})
|
||||
|
||||
describe("plugin.workspace", () => {
|
||||
it.live("plugin can install a workspace adapter", () =>
|
||||
it.live("plugin can install a workspace adaptor", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const type = `plug-${Math.random().toString(36).slice(2)}`
|
||||
@@ -48,7 +48,7 @@ describe("plugin.workspace", () => {
|
||||
"export default async ({ experimental_workspace }) => {",
|
||||
` experimental_workspace.register(${JSON.stringify(type)}, {`,
|
||||
' name: "plug",',
|
||||
' description: "plugin workspace adapter",',
|
||||
' description: "plugin workspace adaptor",',
|
||||
" configure(input) {",
|
||||
` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`,
|
||||
" },",
|
||||
@@ -258,18 +258,6 @@ describe("HttpApi server", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("matches SDK-affecting request schema details", () => {
|
||||
const effect = effectOpenApi()
|
||||
const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody
|
||||
const sessionUpdateSchema =
|
||||
typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate
|
||||
? sessionUpdate.content?.["application/json"]?.schema
|
||||
: undefined
|
||||
const sessionUpdateProperties = sessionUpdateSchema?.properties as Record<string, OpenApiSchema> | undefined
|
||||
const time = sessionUpdateProperties?.time
|
||||
expect(time?.properties?.archived).toEqual({ type: "number" })
|
||||
})
|
||||
|
||||
test("documents event routes as server-sent events", () => {
|
||||
const effect = effectOpenApi()
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "e
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -49,7 +49,7 @@ const instanceContextTestLayer = instanceRouterMiddleware
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure: (info) => ({ ...info, name: "local-test", directory }),
|
||||
@@ -63,7 +63,7 @@ const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
registerAdapter(input.projectID, input.type, localAdapter(input.directory))
|
||||
registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
|
||||
const workspace = yield* Workspace.Service
|
||||
return yield* workspace.create({
|
||||
type: input.type,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
@@ -82,7 +82,7 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string
|
||||
)
|
||||
}
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure: (info) => ({ ...info, name: "local-test", directory }),
|
||||
@@ -95,7 +95,7 @@ const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
|
||||
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
registerAdapter(input.projectID, input.type, localAdapter(input.directory))
|
||||
registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
|
||||
return yield* Workspace.Service.use((svc) =>
|
||||
svc.create({
|
||||
type: input.type,
|
||||
|
||||
@@ -15,9 +15,9 @@ import * as Socket from "effect/unstable/socket/Socket"
|
||||
import Http from "node:http"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { Project } from "../../src/project/project"
|
||||
@@ -82,7 +82,7 @@ const listenAdditionalServer = <E, R>(handler: TestHandler<E, R>) =>
|
||||
return HttpServer.formatAddress(server.address)
|
||||
})
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure: (info) => ({ ...info, name: "local-test", directory }),
|
||||
@@ -93,7 +93,7 @@ const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
target: () => ({ type: "local" as const, directory }),
|
||||
})
|
||||
|
||||
const remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({
|
||||
const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({
|
||||
name: "Remote Test",
|
||||
description: "Create a remote test workspace",
|
||||
configure: (info) => ({ ...info, name: "remote-test", directory }),
|
||||
@@ -116,10 +116,10 @@ const syncResponse = (request: HttpServerRequest.HttpServerRequest) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adapter: WorkspaceAdapter }) =>
|
||||
const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
registerAdapter(input.projectID, input.type, input.adapter)
|
||||
registerAdaptor(input.projectID, input.type, input.adaptor)
|
||||
const workspace = yield* Workspace.Service
|
||||
return yield* workspace.create({
|
||||
type: input.type,
|
||||
@@ -144,14 +144,14 @@ const createRemoteWorkspace = (input: {
|
||||
createWorkspace({
|
||||
projectID: input.projectID,
|
||||
type: input.type,
|
||||
adapter: remoteAdapter(path.join(input.dir, `.${input.type}`), input.url, input.headers),
|
||||
adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers),
|
||||
})
|
||||
|
||||
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
|
||||
createWorkspace({
|
||||
projectID: input.projectID,
|
||||
type: input.type,
|
||||
adapter: localAdapter(input.directory),
|
||||
adaptor: localAdaptor(input.directory),
|
||||
})
|
||||
|
||||
const insertRemoteWorkspaceWithoutSync = (input: {
|
||||
@@ -162,7 +162,7 @@ const insertRemoteWorkspaceWithoutSync = (input: {
|
||||
}) =>
|
||||
Effect.sync(() => {
|
||||
const id = WorkspaceID.ascending()
|
||||
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url))
|
||||
registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url))
|
||||
Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
|
||||
return id
|
||||
})
|
||||
@@ -237,7 +237,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
{ status: 201, headers: { "x-remote": "yes" } },
|
||||
)
|
||||
})
|
||||
// The adapter target tells the middleware where to proxy selected remote
|
||||
// The adaptor target tells the middleware where to proxy selected remote
|
||||
// workspace requests. Appending /probe to this base should produce
|
||||
// `${remoteUrl}/base/probe` on the fake remote server above.
|
||||
const workspace = yield* createRemoteWorkspace({
|
||||
|
||||
@@ -4,8 +4,8 @@ import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -36,7 +36,7 @@ function request(path: string, directory: string, init: RequestInit = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function localAdapter(directory: string): WorkspaceAdapter {
|
||||
function localAdaptor(directory: string): WorkspaceAdaptor {
|
||||
return {
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
@@ -60,7 +60,7 @@ function localAdapter(directory: string): WorkspaceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
|
||||
function remoteAdaptor(directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor {
|
||||
return {
|
||||
name: "Remote Test",
|
||||
description: "Create a remote test workspace",
|
||||
@@ -137,14 +137,14 @@ describe("workspace HttpApi", () => {
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const [adapters, workspaces, status] = yield* Effect.all([
|
||||
request(WorkspacePaths.adapters, dir),
|
||||
const [adaptors, workspaces, status] = yield* Effect.all([
|
||||
request(WorkspacePaths.adaptors, dir),
|
||||
request(WorkspacePaths.list, dir),
|
||||
request(WorkspacePaths.status, dir),
|
||||
])
|
||||
|
||||
expect(adapters.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => adapters.json())).toContainEqual({
|
||||
expect(adaptors.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => adaptors.json())).toContainEqual({
|
||||
type: "worktree",
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
@@ -163,7 +163,7 @@ describe("workspace HttpApi", () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
|
||||
registerAdaptor(project.project.id, "local-test", localAdaptor(path.join(dir, ".workspace")))
|
||||
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
@@ -201,7 +201,7 @@ describe("workspace HttpApi", () => {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const workspaceDir = path.join(dir, ".workspace-local")
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir))
|
||||
registerAdaptor(project.project.id, "local-target", localAdaptor(workspaceDir))
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -250,10 +250,10 @@ describe("workspace HttpApi", () => {
|
||||
})
|
||||
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(
|
||||
registerAdaptor(
|
||||
project.project.id,
|
||||
"remote-target",
|
||||
remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
|
||||
remoteAdaptor(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
|
||||
"x-target-auth": "secret",
|
||||
}),
|
||||
)
|
||||
@@ -319,10 +319,10 @@ describe("workspace HttpApi", () => {
|
||||
})
|
||||
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(
|
||||
registerAdaptor(
|
||||
project.project.id,
|
||||
"remote-session-target",
|
||||
remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
|
||||
remoteAdaptor(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
|
||||
)
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
|
||||
@@ -45,7 +45,7 @@ export type WorkspaceTarget =
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type WorkspaceAdapter = {
|
||||
export type WorkspaceAdaptor = {
|
||||
name: string
|
||||
description: string
|
||||
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
@@ -60,7 +60,7 @@ export type PluginInput = {
|
||||
directory: string
|
||||
worktree: string
|
||||
experimental_workspace: {
|
||||
register(type: string, adapter: WorkspaceAdapter): void
|
||||
register(type: string, adaptor: WorkspaceAdaptor): void
|
||||
}
|
||||
serverUrl: URL
|
||||
$: BunShell
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
ExperimentalConsoleSwitchOrgResponses,
|
||||
ExperimentalResourceListResponses,
|
||||
ExperimentalSessionListResponses,
|
||||
ExperimentalWorkspaceAdapterListResponses,
|
||||
ExperimentalWorkspaceAdaptorListResponses,
|
||||
ExperimentalWorkspaceCreateErrors,
|
||||
ExperimentalWorkspaceCreateResponses,
|
||||
ExperimentalWorkspaceListResponses,
|
||||
@@ -512,11 +512,11 @@ export class App extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Adapter extends HeyApiClient {
|
||||
export class Adaptor extends HeyApiClient {
|
||||
/**
|
||||
* List workspace adapters
|
||||
* List workspace adaptors
|
||||
*
|
||||
* List all available workspace adapters for the current project.
|
||||
* List all available workspace adaptors for the current project.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
@@ -536,8 +536,8 @@ export class Adapter extends HeyApiClient {
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<ExperimentalWorkspaceAdapterListResponses, unknown, ThrowOnError>({
|
||||
url: "/experimental/workspace/adapter",
|
||||
return (options?.client ?? this.client).get<ExperimentalWorkspaceAdaptorListResponses, unknown, ThrowOnError>({
|
||||
url: "/experimental/workspace/adaptor",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
@@ -731,9 +731,9 @@ export class Workspace extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
private _adapter?: Adapter
|
||||
get adapter(): Adapter {
|
||||
return (this._adapter ??= new Adapter({ client: this.client }))
|
||||
private _adaptor?: Adaptor
|
||||
get adaptor(): Adaptor {
|
||||
return (this._adaptor ??= new Adaptor({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2430,19 +2430,19 @@ export type AppLogResponses = {
|
||||
|
||||
export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
|
||||
|
||||
export type ExperimentalWorkspaceAdapterListData = {
|
||||
export type ExperimentalWorkspaceAdaptorListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/experimental/workspace/adapter"
|
||||
url: "/experimental/workspace/adaptor"
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceAdapterListResponses = {
|
||||
export type ExperimentalWorkspaceAdaptorListResponses = {
|
||||
/**
|
||||
* Workspace adapters
|
||||
* Workspace adaptors
|
||||
*/
|
||||
200: Array<{
|
||||
type: string
|
||||
@@ -2451,8 +2451,8 @@ export type ExperimentalWorkspaceAdapterListResponses = {
|
||||
}>
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceAdapterListResponse =
|
||||
ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses]
|
||||
export type ExperimentalWorkspaceAdaptorListResponse =
|
||||
ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses]
|
||||
|
||||
export type ExperimentalWorkspaceListData = {
|
||||
body?: never
|
||||
|
||||
@@ -415,9 +415,9 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/workspace/adapter": {
|
||||
"/experimental/workspace/adaptor": {
|
||||
"get": {
|
||||
"operationId": "experimental.workspace.adapter.list",
|
||||
"operationId": "experimental.workspace.adaptor.list",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
@@ -434,11 +434,11 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": "List workspace adapters",
|
||||
"description": "List all available workspace adapters for the current project.",
|
||||
"summary": "List workspace adaptors",
|
||||
"description": "List all available workspace adaptors for the current project.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Workspace adapters",
|
||||
"description": "Workspace adaptors",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -466,7 +466,7 @@
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})"
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1767,7 +1767,7 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon
|
||||
|
||||
### STACKIT
|
||||
|
||||
STACKIT AI Model Serving provides fully managed sovereign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure.
|
||||
STACKIT AI Model Serving provides fully managed soverign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure.
|
||||
|
||||
1. Head over to [STACKIT Portal](https://portal.stackit.cloud), navigate to **AI Model Serving**, and create an auth token for your project.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user