diff --git a/bun.lock b/bun.lock index 2f21ed7d54..3e73e0c236 100644 --- a/bun.lock +++ b/bun.lock @@ -486,9 +486,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.4", - "@opentui/keymap": ">=0.2.4", - "@opentui/solid": ">=0.2.4", + "@opentui/core": ">=0.2.5", + "@opentui/keymap": ">=0.2.5", + "@opentui/solid": ">=0.2.5", }, "optionalPeers": [ "@opentui/core", @@ -667,9 +667,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.4", - "@opentui/keymap": "0.2.4", - "@opentui/solid": "0.2.4", + "@opentui/core": "0.2.5", + "@opentui/keymap": "0.2.5", + "@opentui/solid": "0.2.5", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1594,23 +1594,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="], + "@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="], - "@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="], + "@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="], - "@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="], + "@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index 15d96e131c..f2258ab698 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.4", - "@opentui/keymap": "0.2.4", - "@opentui/solid": "0.2.4", + "@opentui/core": "0.2.5", + "@opentui/keymap": "0.2.5", + "@opentui/solid": "0.2.5", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 5c38c2871f..bca89c3cab 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.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" @@ -9,38 +22,39 @@ import { ServerAuth } from "@/server/auth" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" -import { Server } from "../../server/server" -import { Provider } from "@/provider/provider" -import { Agent } from "../../agent/agent" -import { Permission } from "../../permission" -import { Tool } from "@/tool/tool" -import { GlobTool } from "../../tool/glob" -import { GrepTool } from "../../tool/grep" -import { ReadTool } from "../../tool/read" -import { WebFetchTool } from "../../tool/webfetch" -import { EditTool } from "../../tool/edit" -import { WriteTool } from "../../tool/write" -import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch" -import { TaskTool } from "../../tool/task" -import { SkillTool } from "../../tool/skill" -import { ShellTool } from "../../tool/shell" -import { ShellID } from "../../tool/shell/id" -import { TodoWriteTool } from "../../tool/todo" -import { Locale } from "@/util/locale" +import { Agent } from "@/agent/agent" +import { Permission } from "@/permission" +import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" -type ToolProps = { - input: Tool.InferParameters - metadata: Tool.InferMetadata - part: ToolPart +const runtimeTask = import("./run/runtime") +type ModelInput = Parameters[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(part: ToolPart): ToolProps { - const state = part.state - return { - input: state.input as Tool.InferParameters, - metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata, - part, +function resolveRunInput(value?: string, piped?: string): string | undefined { + if (!value) { + return piped } + + if (!piped) { + return value + } + + return value + "\n" + piped +} + +type FilePart = { + type: "file" + url: string + filename: string + mime: string } type Inline = { @@ -49,6 +63,12 @@ type Inline = { description?: string } +type SessionInfo = { + id: string + title?: string + directory?: string +} + function inline(info: Inline) { const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) @@ -62,145 +82,40 @@ function block(info: Inline, output?: string) { UI.empty() } -function fallback(part: ToolPart) { - const state = part.state - const input = "input" in state ? state.input : undefined - const title = - ("title" in state && state.title ? state.title : undefined) || - (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown") - inline({ - icon: "⚙", - title: `${part.tool} ${title}`, - }) +async function tool(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + if (next.mode === "block") { + block(next, next.body) + return + } + + inline(next) + } catch { + inline({ + icon: "\u2699", + title: part.tool, + }) + } } -function glob(info: ToolProps) { - 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) { - 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) { - 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) { - block( - { - icon: "←", - title: `Write ${normalizePath(info.input.filePath)}`, - }, - info.part.state.status === "completed" ? info.part.state.output : undefined, - ) -} - -function webfetch(info: ToolProps) { - inline({ - icon: "%", - title: `WebFetch ${info.input.url}`, - }) -} - -function edit(info: ToolProps) { - const title = normalizePath(info.input.filePath) - const diff = info.metadata.diff - block( - { - icon: "←", - title: `Edit ${title}`, - }, - diff, - ) -} - -function websearch(info: ToolProps) { - inline({ - icon: "◈", - title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`, - }) -} - -function task(info: ToolProps) { - 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) { - inline({ - icon: "→", - title: `Skill "${info.input.name}"`, - }) -} - -function shell(info: ToolProps) { - const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined - block( - { - icon: "$", - title: `${info.input.command}`, - }, - output, - ) -} - -function todo(info: ToolProps) { - block( - { - icon: "#", - title: "Todos", - }, - info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"), - ) -} - -function normalizePath(input?: string) { - if (!input) return "" - if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." - return input +async function toolError(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + inline({ + icon: "✗", + title: `${next.title} failed`, + ...(next.description && { description: next.description }), + }) + return + } catch { + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + } } export const RunCommand = effectCmd({ @@ -296,38 +211,98 @@ export const RunCommand = effectCmd({ .option("thinking", { type: "boolean", describe: "show thinking blocks", + }) + .option("interactive", { + alias: ["i"], + type: "boolean", + describe: "run in direct interactive split-footer mode", default: false, }) .option("dangerously-skip-permissions", { type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, + }) + .option("demo", { + type: "boolean", + default: false, + describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { const agentSvc = yield* Agent.Service yield* Effect.promise(async () => { + const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") + const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) + const die = (message: string): never => { + UI.error(message) + process.exit(1) + } + const dieInteractive = (error: unknown): never => { + if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) { + die(error.message) + } + + throw error + } + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") - const directory = (() => { - if (!args.dir) return undefined - if (args.attach) return args.dir + if (args.interactive && args.command) { + die("--interactive cannot be used with --command") + } + + if (args.demo && !args.interactive) { + die("--demo requires --interactive") + } + + if (args.interactive && args.format === "json") { + die("--interactive cannot be used with --format json") + } + + if (args.interactive && !process.stdout.isTTY) { + die("--interactive requires a TTY stdout") + } + + if (args.interactive) { try { - process.chdir(args.dir) + resolveInteractiveStdin().cleanup?.() + } catch (error) { + dieInteractive(error) + } + } + + const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) + const directory = (() => { + if (!args.dir) return args.attach ? undefined : root + if (args.attach) return args.dir + + try { + process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir)) return process.cwd() } catch { UI.error("Failed to change directory to " + args.dir) process.exit(1) } })() + const attachHeaders = args.attach + ? ServerAuth.headers({ password: args.password, username: args.username }) + : undefined + const attachSDK = (dir?: string) => { + return createOpencodeClient({ + baseUrl: args.attach!, + directory: dir, + headers: attachHeaders, + }) + } - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + const files: FilePart[] = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) + const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath) if (!(await Filesystem.exists(resolvedPath))) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -344,9 +319,11 @@ export const RunCommand = effectCmd({ } } - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text() + message = resolveRunInput(message, piped) ?? "" + const initialInput = resolveRunInput(rawMessage, piped) - if (message.trim().length === 0 && !args.command) { + if (message.trim().length === 0 && !args.command && !args.interactive) { UI.error("You must provide a message or a command") process.exit(1) } @@ -356,23 +333,25 @@ export const RunCommand = effectCmd({ process.exit(1) } - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] + const rules: Permission.Ruleset = args.interactive + ? [] + : [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] function title() { if (args.title === undefined) return @@ -380,19 +359,83 @@ export const RunCommand = effectCmd({ return message.slice(0, 50) + (message.length > 50 ? "..." : "") } - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + async function session(sdk: OpencodeClient): Promise { + if (args.session) { + const current = await sdk.session + .get({ + sessionID: args.session, + }) + .catch(() => undefined) - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id + if (!current?.data) { + UI.error("Session not found") + process.exit(1) + } + + if (args.fork) { + const forked = await sdk.session.fork({ + sessionID: args.session, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? current.data.title, + directory: forked.data?.directory ?? current.data.directory, + } + } + + return { + id: current.data.id, + title: current.data.title, + directory: current.data.directory, + } } - if (baseID) return baseID + const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined + + if (base && args.fork) { + const forked = await sdk.session.fork({ + sessionID: base.id, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? base.title, + directory: forked.data?.directory ?? base.directory, + } + } + + if (base) { + return { + id: base.id, + title: base.title, + directory: base.directory, + } + } const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id + const result = await sdk.session.create({ + title: name, + permission: rules, + }) + const id = result.data?.id + if (!id) { + return + } + + return { + id, + title: result.data?.title ?? name, + directory: result.data?.directory, + } } async function share(sdk: OpencodeClient, sessionID: string) { @@ -410,43 +453,159 @@ export const RunCommand = effectCmd({ } } - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === ShellID.ToolID) return shell(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } + async function createFreshSession( + sdk: OpencodeClient, + input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined }, + ): Promise { + const result = await sdk.session.create({ + title: args.title !== undefined && args.title !== "" ? args.title : undefined, + agent: input.agent, + model: input.model + ? { + providerID: input.model.providerID, + id: input.model.modelID, + variant: input.variant, + } + : undefined, + permission: rules, + }) + const id = result.data?.id + if (!id) { + throw new Error("Failed to create session") } + void share(sdk, id).catch(() => {}) + return { + id, + title: result.data?.title, + } + } + + async function current(sdk: OpencodeClient): Promise { + if (!args.attach) { + return directory ?? root + } + + const next = await sdk.path + .get() + .then((x) => x.data?.directory) + .catch(() => undefined) + if (next) { + return next + } + + UI.error("Failed to resolve remote directory") + process.exit(1) + } + + async function localAgent() { + if (!args.agent) return undefined + const name = args.agent + + const entry = await Effect.runPromise(agentSvc.get(name)) + if (!entry) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + if (entry.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + return name + } + + async function attachAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + const name = args.agent + + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) + + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name + } + + async function pickAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + if (args.attach) { + return attachAgent(sdk) + } + + return localAgent() + } + + async function execute(sdk: OpencodeClient) { + const sess = await session(sdk) + if (!sess?.id) { + UI.error("Session not found") + process.exit(1) + } + const sessionID = sess.id + function emit(type: string, data: Record) { 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>) { const toggles = new Map() + let error: string | undefined for await (const event of events.stream) { if ( event.type === "message.updated" && + event.properties.sessionID === sessionID && event.properties.info.role === "assistant" && args.format !== "json" && toggles.get("start") !== true @@ -464,16 +623,10 @@ export const RunCommand = effectCmd({ if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { if (emit("tool_use", { part })) continue if (part.state.status === "completed") { - tool(part) + await tool(part) continue } - inline({ - icon: "✗", - title: - part.tool === "websearch" - ? `${webSearchProviderLabel(props(part).metadata.provider)} failed` - : `${part.tool} failed`, - }) + await toolError(part) UI.error(part.state.error) } @@ -484,7 +637,7 @@ export const RunCommand = effectCmd({ args.format !== "json" ) { if (toggles.get(part.id) === true) continue - task(props(part)) + await tool(part) toggles.set(part.id, true) } @@ -509,7 +662,7 @@ export const RunCommand = effectCmd({ UI.empty() } - if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (part.type === "reasoning" && part.time?.end && thinking) { if (emit("reasoning", { part })) continue const text = part.text.trim() if (!text) continue @@ -549,7 +702,7 @@ export const RunCommand = effectCmd({ if (permission.sessionID !== sessionID) continue if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "once", }) @@ -559,7 +712,7 @@ export const RunCommand = effectCmd({ UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "reject", }) @@ -567,114 +720,113 @@ export const RunCommand = effectCmd({ } } } + const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) + const client = args.attach ? attachSDK(cwd) : sdk // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + const agent = await pickAgent(client) - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + await share(client, sessionID) - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined - } - - const agent = modes.find((a) => a.name === name) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - - if (agent.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - - return name - } - - const entry = await Effect.runPromise(agentSvc.get(name)) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, + if (!args.interactive) { + const events = await client.event.subscribe() + loop(client, events).catch((e) => { + console.error(e) + process.exit(1) }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ + + if (args.command) { + await client.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + return + } + + const model = pick(args.model) + await client.session.prompt({ sessionID, agent, model, variant: args.variant, parts: [...files, { type: "text", text: message }], }) + return + } + + const model = pick(args.model) + const { runInteractiveMode } = await runtimeTask + try { + await runInteractiveMode({ + sdk: client, + directory: cwd, + sessionID, + sessionTitle: sess.title, + resume: Boolean(args.session || args.continue) && !args.fork, + agent, + model, + variant: args.variant, + files, + initialInput, + createSession: createFreshSession, + thinking, + demo: args.demo, + }) + } catch (error) { + dieInteractive(error) + } + return + } + + if (args.interactive && !args.attach && !args.session && !args.continue) { + const model = pick(args.model) + const { runInteractiveLocalMode } = await runtimeTask + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + + try { + return await runInteractiveLocalMode({ + directory: directory ?? root, + fetch: fetchFn, + resolveAgent: localAgent, + session, + share, + createSession: createFreshSession, + agent: args.agent, + model, + variant: args.variant, + files, + initialInput, + thinking, + demo: args.demo, + }) + } catch (error) { + dieInteractive(error) } } if (args.attach) { - const headers = ServerAuth.headers({ password: args.password, username: args.username }) - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + const sdk = attachSDK(directory) return await execute(sdk) } const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") const request = new Request(input, init) return Server.Default().app.fetch(request) }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: fetchFn, + directory, + }) await execute(sdk) }) }), diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts new file mode 100644 index 0000000000..195ef6f496 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -0,0 +1,1281 @@ +// Demo mode for testing direct interactive mode without a real SDK. +// +// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic +// SDK events that feed through the real reducer and footer pipeline. This +// lets you test scrollback formatting, permission UI, question UI, and tool +// snapshots without making actual model calls. Pass a demo slash command as +// the initial interactive message to trigger a preview immediately. +// +// Slash commands: +// /permission [kind] → triggers a permission request variant +// /question [kind] → triggers a question request variant +// /fmt → emits a specific tool/text type (text, reasoning, bash, +// write, edit, patch, task, todo, question, error, mix) +// +// Demo mode also handles permission and question replies locally, completing +// or failing the synthetic tool parts as appropriate. +import path from "path" +import type { Event, ToolPart } from "@opencode-ai/sdk/v2" +import { createSessionData, reduceSessionData, type SessionData } from "./session-data" +import { writeSessionOutput } from "./stream" +import type { + FooterApi, + PermissionReply, + QuestionReject, + QuestionReply, + RunPrompt, + StreamCommit, +} from "./types" + +const KINDS = [ + "markdown", + "table", + "text", + "reasoning", + "bash", + "write", + "edit", + "patch", + "task", + "todo", + "question", + "error", + "mix", +] +const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const +const QUESTIONS = ["multi", "single", "checklist", "custom"] as const + +type PermissionKind = (typeof PERMISSIONS)[number] +type QuestionKind = (typeof QUESTIONS)[number] + +function permissionKind(value: string | undefined): PermissionKind | undefined { + const next = (value || "edit").toLowerCase() + return PERMISSIONS.find((item) => item === next) +} + +function questionKind(value: string | undefined): QuestionKind | undefined { + const next = (value || "multi").toLowerCase() + return QUESTIONS.find((item) => item === next) +} + +const SAMPLE_MARKDOWN = [ + "# Direct Mode Demo", + "", + "This is a realistic assistant response for direct-mode formatting checks.", + "It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.", + "", + "## Summary", + "", + "- Restored the final markdown flush so the last block is committed on idle.", + "- Switched markdown scrollback commits back to top-level block boundaries.", + "- Added footer-level regression coverage for split-footer rendering.", + "", + "## Status", + "", + "| Area | Before | After | Notes |", + "| --- | --- | --- | --- |", + "| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |", + "| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |", + "| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |", + "", + "> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.", + "", + "```ts", + "const result = { markdown: true, tables: 2, stable: true }", + "```", + "", + "## Files", + "", + "| File | Change |", + "| --- | --- |", + "| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |", + "| `footer.ts` | Keep active surfaces across footer-height-only resizes |", + "| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |", + "", + "Next step: run `/fmt table` if you want a tighter table-only sample.", +].join("\n") + +const SAMPLE_TABLE = [ + "# Table Sample", + "", + "| Kind | Example | Notes |", + "| --- | --- | --- |", + "| Pipe | `A\\|B` | Escaped pipes should stay in one cell |", + "| Unicode | `漢字` | Wide characters should remain aligned |", + "| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |", + "| Status | done | Final row should still appear after idle |", +].join("\n") + +type Ref = { + msg: string + part: string + call: string + tool: string + input: Record + start: number +} + +type Ask = { + ref: Ref +} + +type Perm = { + ref: Ref + done: { + title: string + output: string + metadata?: Record + } +} + +type Permit = { + ref: Ref + permission: string + patterns: string[] + metadata?: Record + always: string[] + done: Perm["done"] +} + +type State = { + id: string + thinking: boolean + data: SessionData + footer: FooterApi + limits: () => Record + msg: number + part: number + call: number + perm: number + ask: number + perms: Map + asks: Map +} + +type Input = { + sessionID: string + thinking: boolean + limits: () => Record + footer: FooterApi +} + +function note(footer: FooterApi, text: string): void { + footer.append({ + kind: "system", + text, + phase: "start", + source: "system", + }) +} + +function clearSubagent(footer: FooterApi): void { + footer.event({ + type: "stream.subagent", + state: { + tabs: [], + details: {}, + permissions: [], + questions: [], + }, + }) +} + +function showSubagent( + state: State, + input: { + sessionID: string + partID: string + callID: string + label: string + description: string + status: "running" | "completed" | "error" + title?: string + toolCalls?: number + commits: StreamCommit[] + }, +) { + state.footer.event({ + type: "stream.subagent", + state: { + tabs: [ + { + sessionID: input.sessionID, + partID: input.partID, + callID: input.callID, + label: input.label, + description: input.description, + status: input.status, + title: input.title, + toolCalls: input.toolCalls, + lastUpdatedAt: Date.now(), + }, + ], + details: { + [input.sessionID]: { + sessionID: input.sessionID, + commits: input.commits, + }, + }, + permissions: [], + questions: [], + }, + }) +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (!signal) { + setTimeout(resolve, ms) + return + } + + if (signal.aborted) { + resolve() + return + } + + const done = () => { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + + const timer = setTimeout(() => { + signal.removeEventListener("abort", done) + resolve() + }, ms) + + signal.addEventListener("abort", done, { once: true }) + }) +} + +function split(text: string): string[] { + if (text.length <= 48) { + return [text] + } + + const size = Math.ceil(text.length / 3) + return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)] +} + +function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string { + state[key] += 1 + return `demo_${prefix}_${state[key]}` +} + +function feed(state: State, event: Event): void { + const out = reduceSessionData({ + data: state.data, + event, + sessionID: state.id, + thinking: state.thinking, + limits: state.limits(), + }) + state.data = out.data + writeSessionOutput( + { + footer: state.footer, + }, + out, + ) +} + +function open(state: State): string { + const id = take(state, "msg", "msg") + feed(state, { + type: "message.updated", + properties: { + sessionID: state.id, + info: { + id, + sessionID: state.id, + role: "assistant", + time: { + created: Date.now(), + }, + parentID: `user_${id}`, + modelID: "demo", + providerID: "demo", + mode: "demo", + agent: "demo", + path: { + cwd: process.cwd(), + root: process.cwd(), + }, + cost: 0.001, + tokens: { + input: 120, + output: 320, + reasoning: 80, + cache: { + read: 0, + write: 0, + }, + }, + }, + }, + } as Event) + return id +} + +async function emitText(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +function make(state: State, tool: string, input: Record): Ref { + return { + msg: open(state), + part: take(state, "part", "part"), + call: take(state, "call", "call"), + tool, + input, + start: Date.now(), + } +} + +function startTool(state: State, ref: Ref, metadata: Record = {}): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "running", + input: ref.input, + metadata, + time: { + start: ref.start, + }, + }, + }, + }, + } as Event) +} + +function askPermission(state: State, item: Permit): void { + startTool(state, item.ref) + + const id = take(state, "perm", "perm") + state.perms.set(id, { + ref: item.ref, + done: item.done, + }) + + feed(state, { + type: "permission.asked", + properties: { + id, + sessionID: state.id, + permission: item.permission, + patterns: item.patterns, + metadata: item.metadata ?? {}, + always: item.always, + tool: { + messageID: item.ref.msg, + callID: item.ref.call, + }, + }, + } as Event) +} + +function doneTool( + state: State, + ref: Ref, + output: { + title: string + output: string + metadata?: Record + }, +): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "completed", + input: ref.input, + output: output.output, + title: output.title, + metadata: output.metadata ?? {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function failTool(state: State, ref: Ref, error: string): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "error", + input: ref.input, + error, + metadata: {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function emitError(state: State, text: string): void { + const event = { + id: `session.error:${state.id}:${Date.now()}`, + type: "session.error", + properties: { + sessionID: state.id, + error: { + name: "UnknownError", + data: { + message: text, + }, + }, + }, + } satisfies Event + feed(state, event) +} + +async function emitBash(state: State, signal?: AbortSignal): Promise { + const ref = make(state, "bash", { + command: "git status", + workdir: process.cwd(), + description: "Show git status", + }) + startTool(state, ref) + await wait(70, signal) + doneTool(state, ref, { + title: "git status", + output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`, + metadata: { + exitCode: 0, + }, + }) +} + +function emitWrite(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "write", { + filePath: file, + content: "export const demo = 42\n", + }) + doneTool(state, ref, { + title: "write", + output: "", + metadata: {}, + }) +} + +function emitEdit(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "edit", { + filePath: file, + }) + doneTool(state, ref, { + title: "edit", + output: "", + metadata: { + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + }, + }) +} + +function emitPatch(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "apply_patch", { + patchText: "*** Begin Patch\n*** End Patch", + }) + doneTool(state, ref, { + title: "apply_patch", + output: "", + metadata: { + files: [ + { + type: "update", + filePath: file, + relativePath: "src/demo-format.ts", + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + deletions: 1, + }, + { + type: "add", + filePath: path.join(process.cwd(), "README-demo.md"), + relativePath: "README-demo.md", + diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n", + deletions: 0, + }, + ], + }, + }) +} + +function emitTask(state: State): void { + const ref = make(state, "task", { + description: "Scan run/* for reducer touchpoints", + subagent_type: "explore", + }) + doneTool(state, ref, { + title: "Reducer touchpoints found", + output: "", + metadata: { + toolcalls: 4, + sessionId: "sub_demo_1", + }, + }) + const part = { + id: "sub_demo_tool_1", + type: "tool", + sessionID: "sub_demo_1", + messageID: "sub_demo_msg_tool", + callID: "sub_demo_call_1", + tool: "read", + state: { + status: "running", + input: { + filePath: "packages/opencode/src/cli/cmd/run/stream.ts", + offset: 1, + limit: 200, + }, + time: { + start: Date.now(), + }, + }, + } satisfies ToolPart + showSubagent(state, { + sessionID: "sub_demo_1", + partID: ref.part, + callID: ref.call, + label: "Explore", + description: "Scan run/* for reducer touchpoints", + status: "completed", + title: "Reducer touchpoints found", + toolCalls: 4, + commits: [ + { + kind: "user", + text: "Scan run/* for reducer touchpoints", + phase: "start", + source: "system", + }, + { + kind: "reasoning", + text: "Thinking: tracing reducer and footer boundaries", + phase: "progress", + source: "reasoning", + messageID: "sub_demo_msg_reasoning", + partID: "sub_demo_reasoning_1", + }, + { + kind: "tool", + text: "running read", + phase: "start", + source: "tool", + messageID: "sub_demo_msg_tool", + partID: "sub_demo_tool_1", + tool: "read", + part, + }, + { + kind: "assistant", + text: "Footer updates flow through stream.ts into RunFooter", + phase: "progress", + source: "assistant", + messageID: "sub_demo_msg_text", + partID: "sub_demo_text_1", + }, + ], + }) +} + +function emitTodo(state: State): void { + const ref = make(state, "todowrite", { + todos: [ + { + content: "Trigger permission UI", + status: "completed", + }, + { + content: "Trigger question UI", + status: "in_progress", + }, + { + content: "Tune tool formatting", + status: "pending", + }, + ], + }) + doneTool(state, ref, { + title: "todowrite", + output: "", + metadata: {}, + }) +} + +function emitQuestionTool(state: State): void { + const ref = make(state, "question", { + questions: [ + { + header: "Style", + question: "Which output style do you want to inspect?", + options: [ + { label: "Diff", description: "Show diff block" }, + { label: "Code", description: "Show code block" }, + ], + multiple: false, + }, + { + header: "Extras", + question: "Pick extra rows", + options: [ + { label: "Usage", description: "Add usage row" }, + { label: "Duration", description: "Add duration row" }, + ], + multiple: true, + custom: true, + }, + ], + }) + doneTool(state, ref, { + title: "question", + output: "", + metadata: { + answers: [["Diff"], ["Usage", "custom-note"]], + }, + }) +} + +function emitPermission(state: State, kind: PermissionKind = "edit"): void { + const root = process.cwd() + const file = path.join(root, "src", "demo-format.ts") + + if (kind === "bash") { + const command = "git status --short" + const ref = make(state, "bash", { + command, + workdir: root, + description: "Inspect worktree changes", + }) + askPermission(state, { + ref, + permission: "bash", + patterns: [command], + always: ["*"], + done: { + title: "git status --short", + output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`, + metadata: { + exitCode: 0, + }, + }, + }) + return + } + + if (kind === "read") { + const target = path.join(root, "package.json") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 80, + }) + askPermission(state, { + ref, + permission: "read", + patterns: [target], + always: [target], + done: { + title: "read", + output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"), + metadata: {}, + }, + }) + return + } + + if (kind === "task") { + const ref = make(state, "task", { + description: "Inspect footer spacing across direct-mode prompts", + subagent_type: "explore", + }) + askPermission(state, { + ref, + permission: "task", + patterns: ["explore"], + always: ["*"], + done: { + title: "Footer spacing checked", + output: "", + metadata: { + toolcalls: 3, + sessionId: "sub_demo_perm_1", + }, + }, + }) + return + } + + if (kind === "external") { + const dir = path.join(path.dirname(root), "demo-shared") + const target = path.join(dir, "README.md") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 40, + }) + askPermission(state, { + ref, + permission: "external_directory", + patterns: [`${dir}/**`], + metadata: { + parentDir: dir, + filepath: target, + }, + always: [`${dir}/**`], + done: { + title: "read", + output: `1: # External demo\n2: Shared preview file\nPath: ${target}`, + metadata: {}, + }, + }) + return + } + + if (kind === "doom") { + const ref = make(state, "task", { + description: "Retry the formatter after repeated failures", + subagent_type: "general", + }) + askPermission(state, { + ref, + permission: "doom_loop", + patterns: ["*"], + always: ["*"], + done: { + title: "Retry allowed", + output: "Continuing after repeated failures.\n", + metadata: {}, + }, + }) + return + } + + const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n" + const ref = make(state, "edit", { + filePath: file, + filepath: file, + diff, + }) + askPermission(state, { + ref, + permission: "edit", + patterns: [file], + always: [file], + done: { + title: "edit", + output: "", + metadata: { + diff, + }, + }, + }) +} + +function emitQuestion(state: State, kind: QuestionKind = "multi"): void { + const questions = (() => { + if (kind === "single") { + return [ + { + header: "Mode", + question: "Which footer should be the reference for spacing checks?", + options: [ + { label: "Permission", description: "Inspect the permission footer" }, + { label: "Question", description: "Keep this question footer open" }, + { label: "Prompt", description: "Return to the normal composer" }, + ], + multiple: false, + custom: false, + }, + ] + } + + if (kind === "checklist") { + return [ + { + header: "Checks", + question: "Select the direct-mode cases you want to inspect next", + options: [ + { label: "Diff", description: "Show an edit diff in the footer" }, + { label: "Task", description: "Show a structured task summary" }, + { label: "Todo", description: "Show a todo snapshot" }, + { label: "Error", description: "Show an error transcript row" }, + ], + multiple: true, + custom: false, + }, + ] + } + + if (kind === "custom") { + return [ + { + header: "Reply", + question: "What custom answer should appear in the footer preview?", + options: [ + { label: "Short note", description: "Keep the answer to one line" }, + { label: "Wrapped note", description: "Use a longer answer to test wrapping" }, + ], + multiple: false, + custom: true, + }, + ] + } + + return [ + { + header: "Layout", + question: "Which footer view should stay active while testing?", + options: [ + { label: "Prompt", description: "Return to prompt" }, + { label: "Question", description: "Keep question open" }, + ], + multiple: false, + }, + { + header: "Rows", + question: "Pick formatting previews", + options: [ + { label: "Diff", description: "Emit edit diff" }, + { label: "Task", description: "Emit task card" }, + { label: "Todo", description: "Emit todo card" }, + ], + multiple: true, + custom: true, + }, + ] + })() + + const ref = make(state, "question", { questions }) + startTool(state, ref) + + const id = take(state, "ask", "ask") + state.asks.set(id, { ref }) + + feed(state, { + type: "question.asked", + properties: { + id, + sessionID: state.id, + questions, + tool: { + messageID: ref.msg, + callID: ref.call, + }, + }, + } as Event) +} + +async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise { + if (kind === "text") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "markdown" || kind === "md") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "table") { + await emitText(state, body || SAMPLE_TABLE, signal) + return true + } + + if (kind === "reasoning") { + await emitReasoning(state, body || "Planning next steps [REDACTED] while preserving reducer ordering.", signal) + return true + } + + if (kind === "bash") { + await emitBash(state, signal) + return true + } + + if (kind === "write") { + emitWrite(state) + return true + } + + if (kind === "edit") { + emitEdit(state) + return true + } + + if (kind === "patch") { + emitPatch(state) + return true + } + + if (kind === "task") { + emitTask(state) + return true + } + + if (kind === "todo") { + emitTodo(state) + return true + } + + if (kind === "question") { + emitQuestionTool(state) + return true + } + + if (kind === "error") { + emitError(state, body || "demo error event") + return true + } + + if (kind === "mix") { + await emitText(state, SAMPLE_MARKDOWN, signal) + await wait(50, signal) + await emitReasoning(state, "Thinking through formatter edge cases [REDACTED].", signal) + await wait(50, signal) + await emitBash(state, signal) + emitWrite(state) + emitEdit(state) + emitPatch(state) + emitTask(state) + emitTodo(state) + emitQuestionTool(state) + emitError(state, "demo mixed scenario error") + return true + } + + return false +} + +function intro(state: State): void { + note( + state.footer, + [ + "Demo slash commands enabled for interactive mode.", + `- /permission [kind] (${PERMISSIONS.join(", ")})`, + `- /question [kind] (${QUESTIONS.join(", ")})`, + `- /fmt (${KINDS.join(", ")})`, + "Examples:", + "- /permission bash", + "- /question custom", + "- /fmt markdown", + "- /fmt table", + "- /fmt text your custom text", + ].join("\n"), + ) +} + +export function createRunDemo(input: Input) { + const state: State = { + id: input.sessionID, + thinking: input.thinking, + data: createSessionData(), + footer: input.footer, + limits: input.limits, + msg: 0, + part: 0, + call: 0, + perm: 0, + ask: 0, + perms: new Map(), + asks: new Map(), + } + + const start = async (): Promise => { + intro(state) + } + + const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise => { + const text = line.text.trim() + const list = text.split(/\s+/) + const cmd = list[0] || "" + + clearSubagent(state.footer) + + if (cmd === "/help") { + intro(state) + return true + } + + if (cmd === "/permission") { + const kind = permissionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`) + return true + } + + emitPermission(state, kind) + return true + } + + if (cmd === "/question") { + const kind = questionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`) + return true + } + + emitQuestion(state, kind) + return true + } + + if (cmd === "/fmt") { + const kind = (list[1] || "").toLowerCase() + const body = list.slice(2).join(" ") + if (!kind) { + note(state.footer, `Pick a kind: ${KINDS.join(", ")}`) + return true + } + + const ok = await emitFmt(state, kind, body, signal) + if (ok) { + return true + } + + note(state.footer, `Unknown kind "${kind}". Use: ${KINDS.join(", ")}`) + return true + } + + return false + } + + const permission = (input: PermissionReply): boolean => { + const item = state.perms.get(input.requestID) + if (!item || !input.reply) { + return false + } + + state.perms.delete(input.requestID) + const event = { + id: `permission.replied:${input.requestID}:${Date.now()}`, + type: "permission.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + reply: input.reply, + }, + } satisfies Event + feed(state, event) + + if (input.reply === "reject") { + failTool(state, item.ref, input.message || "permission rejected") + return true + } + + doneTool(state, item.ref, item.done) + return true + } + + const questionReply = (input: QuestionReply): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask || !input.answers) { + return false + } + + state.asks.delete(input.requestID) + const event = { + id: `question.replied:${input.requestID}:${Date.now()}`, + type: "question.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + answers: input.answers, + }, + } satisfies Event + feed(state, event) + doneTool(state, ask.ref, { + title: "question", + output: "", + metadata: { + answers: input.answers, + }, + }) + return true + } + + const questionReject = (input: QuestionReject): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask) { + return false + } + + state.asks.delete(input.requestID) + feed(state, { + type: "question.rejected", + properties: { + sessionID: state.id, + requestID: input.requestID, + }, + } as Event) + failTool(state, ask.ref, "question rejected") + return true + } + + return { + start, + prompt, + permission, + questionReply, + questionReject, + } +} diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts new file mode 100644 index 0000000000..bb058e8a37 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/entry.body.ts @@ -0,0 +1,194 @@ +import { toolEntryBody } from "./tool" +import type { RunEntryBody, StreamCommit } from "./types" + +export type EntryFlags = { + startOnNewLine: boolean + trailingNewline: boolean +} + +export const RUN_ENTRY_NONE: RunEntryBody = { + type: "none", +} + +export function cleanRunText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +function textBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "text", + content, + } +} + +function codeBody(content: string, filetype?: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "code", + content, + filetype, + } +} + +function markdownBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "markdown", + content, + } +} + +function userBody(raw: string): RunEntryBody { + if (!raw.trim()) { + return RUN_ENTRY_NONE + } + + const lead = raw.match(/^\n+/)?.[0] ?? "" + const body = lead ? raw.slice(lead.length) : raw + return textBody(`${lead}› ${body}`) +} + +function reasoningBody(raw: string): RunEntryBody { + const clean = raw.replace(/\[REDACTED\]/g, "") + if (!clean) { + return RUN_ENTRY_NONE + } + + const lead = clean.match(/^\n+/)?.[0] ?? "" + const body = lead ? clean.slice(lead.length) : clean + const mark = "Thinking:" + if (body.startsWith(mark)) { + return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown") + } + + return codeBody(clean, "markdown") +} + +function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { + return textBody(phase === "progress" ? raw : raw.trim()) +} + +export function entryFlags(commit: StreamCommit): EntryFlags { + if (commit.kind === "user") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + if (commit.kind === "tool") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "assistant" || commit.kind === "reasoning") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "error") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } +} + +export function entryDone(commit: StreamCommit): boolean { + if (commit.kind === "assistant" || commit.kind === "reasoning") { + return commit.phase === "final" + } + + if (commit.kind === "tool") { + return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed") + } + + return true +} + +export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean { + if (commit.phase !== "progress") { + return false + } + + if (body.type === "none") { + return false + } + + if (commit.kind === "tool") { + return commit.toolState !== "completed" + } + + return commit.kind === "assistant" || commit.kind === "reasoning" +} + +export function entryBody(commit: StreamCommit): RunEntryBody { + const raw = cleanRunText(commit.text) + + if (commit.kind === "user") { + return userBody(raw) + } + + if (commit.kind === "tool") { + return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE + } + + if (commit.kind === "assistant") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE + } + + return markdownBody(raw) + } + + if (commit.kind === "reasoning") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE + } + + return reasoningBody(raw) + } + + return systemBody(raw, commit.phase) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx new file mode 100644 index 0000000000..9e964d02bc --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -0,0 +1,647 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core" +import { useKeyboard, type JSX } from "@opentui/solid" +import fuzzysort from "fuzzysort" +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" +import { formatBindings } from "./keymap.shared" +import type { RunFooterTheme } from "./theme" +import type { FooterKeybinds, RunCommand, RunInput, RunProvider } from "./types" + +type PanelEntry = RunFooterMenuItem & { + category: string + keywords?: string +} + +type CommandEntry = + | (PanelEntry & { action: "model" }) + | (PanelEntry & { action: "variant.cycle" }) + | (PanelEntry & { action: "variant.list" }) + | (PanelEntry & { action: "slash"; name: string }) + | (PanelEntry & { action: "exit" }) + +type ModelEntry = PanelEntry & { + providerID: string + modelID: string + providerName: string + current: boolean +} + +type VariantEntry = PanelEntry & { + variant: string | undefined + current: boolean +} + +type MenuState = ReturnType + +const PANEL_PAD = 2 +const PANEL_LIST_ROWS = 10 +export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + 6 +const PANEL_PAGE = PANEL_LIST_ROWS - 1 +const PANEL_BORDER = { + topLeft: "", + bottomLeft: "", + vertical: "┃", + topRight: "", + bottomRight: "", + horizontal: " ", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} +const PANEL_BOTTOM_BORDER = { + ...PANEL_BORDER, + vertical: "╹", +} +const HALF_BLOCK_BORDER = { + topLeft: "", + bottomLeft: "", + vertical: "", + topRight: "", + bottomRight: "", + horizontal: "▀", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} + +function countLabel(count: number, total: number, query: string) { + if (!query.trim()) { + return `${total}` + } + + return `${count}/${total}` +} + +function categoryRank(category: string) { + if (category === "Project Commands") { + return 0 + } + + if (category === "MCP Commands") { + return 1 + } + + return 2 +} + +function handleKey(input: { + event: KeyEvent + menu: MenuState + field: () => InputRenderable | undefined + setQuery: (value: string) => void + select: () => void + close: () => void +}) { + const name = input.event.name.toLowerCase() + const ctrl = input.event.ctrl && !input.event.meta && !input.event.shift && !input.event.super + + if (name === "escape" || (ctrl && name === "c")) { + input.event.preventDefault() + input.close() + return + } + + if (name === "up" || (ctrl && name === "p")) { + input.event.preventDefault() + input.menu.move(-1) + return + } + + if (name === "down" || (ctrl && name === "n")) { + input.event.preventDefault() + input.menu.move(1) + return + } + + if (name === "pageup") { + input.event.preventDefault() + input.menu.reveal(input.menu.selected() - PANEL_PAGE) + return + } + + if (name === "pagedown") { + input.event.preventDefault() + input.menu.reveal(input.menu.selected() + PANEL_PAGE) + return + } + + if (name === "home") { + input.event.preventDefault() + input.menu.reveal(0) + return + } + + if (name === "end") { + input.event.preventDefault() + input.menu.reveal(Number.POSITIVE_INFINITY) + return + } + + if (name === "return") { + input.event.preventDefault() + input.select() + return + } + + if (ctrl && name === "u") { + input.event.preventDefault() + input.setQuery("") + input.field()?.setText("") + } +} + +function match(query: string, entries: T[]) { + const text = query.trim() + if (!text) { + return entries + } + + return fuzzysort + .go(text, entries, { keys: ["display", "category", "description", "keywords"] }) + .map((item) => item.obj) +} + +function PanelShell(props: { + id: string + title: string + countVisible?: boolean + query: string + count: number + total: number + placeholder: string + theme: Accessor + inputRef: (input: InputRenderable) => void + onQuery: (query: string) => void + children: JSX.Element +}) { + return ( + + + + + + {props.title} + + {props.countVisible !== false ? ( + + {countLabel(props.count, props.total, props.query)} + + ) : null} + + + esc + + + + + { + props.inputRef(input) + input.traits = { status: "FILTER" } + queueMicrotask(() => { + if (!input.isDestroyed) { + input.focus() + } + }) + }} + /> + + + + {props.children} + + + + + + + ) +} + +export function RunCommandMenuBody(props: { + theme: Accessor + commands: Accessor + variants: Accessor + keybinds: FooterKeybinds + onClose: () => void + onModel: () => void + onVariant: () => void + onVariantCycle: () => void + onCommand: (name: string) => void + onNew: () => void + onExit: () => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => { + const builtins = ["new"] + return [ + { + action: "model", + category: "Suggested", + display: "Switch model", + }, + { + action: "variant.cycle", + category: "Suggested", + display: "Variant cycle", + footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader), + keywords: "variant cycle", + }, + ...(props.variants().length > 0 + ? [ + { + action: "variant.list" as const, + category: "Suggested", + display: "Switch model variant", + keywords: `variant variants ${props.variants().join(" ")}`, + }, + ] + : []), + { + action: "slash", + category: "Session", + name: "new", + display: "New session", + footer: "/new", + keywords: "new session clear", + }, + ...(props.commands() ?? []) + .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) + .map( + (item) => + ({ + action: "slash", + category: item.source === "mcp" ? "MCP Commands" : "Project Commands", + name: item.name, + display: item.name, + footer: `/${item.name}`, + keywords: + item.source === "mcp" + ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` + : `/${item.name} ${item.name} ${item.description ?? ""}`, + }) satisfies CommandEntry, + ) + .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)), + { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" }, + ] + }) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: CommandEntry) => { + if (item.action === "model") { + props.onModel() + return + } + + if (item.action === "variant.cycle") { + props.onVariantCycle() + return + } + + if (item.action === "variant.list") { + props.onVariant() + return + } + + if (item.action === "exit") { + props.onExit() + return + } + + if (item.name === "new") { + props.onNew() + return + } + + props.onCommand(item.name) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No results found" + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={!query().trim()} + /> + + ) +} + +export function RunVariantSelectBody(props: { + theme: Accessor + variants: Accessor + current: Accessor + onClose: () => void + onSelect: (variant: string | undefined) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => [ + { + category: "", + display: "Default", + description: props.current() === undefined ? "current" : undefined, + keywords: "default", + variant: undefined, + current: props.current() === undefined, + }, + ...props.variants().map((variant) => ({ + category: "", + display: variant, + description: props.current() === variant ? "current" : undefined, + keywords: variant, + variant, + current: props.current() === variant, + })), + ]) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: VariantEntry) => { + props.onSelect(item.variant) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + createEffect(() => { + if (query().trim()) { + return + } + + const index = items().findIndex((item) => item.current) + if (index !== -1) { + menu.reveal(index) + } + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No results found" + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={false} + /> + + ) +} + +export function RunModelSelectBody(props: { + theme: Accessor + providers: Accessor + current: Accessor + onClose: () => void + onSelect: (model: NonNullable) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => + (props.providers() ?? []) + .flatMap((provider) => + Object.entries(provider.models) + .filter(([, model]) => model.status !== "deprecated") + .map(([modelID, model]) => { + const title = model.name ?? modelID + const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID + const footer = current + ? "current" + : model.cost?.input === 0 && provider.id === "opencode" + ? "Free" + : title !== modelID + ? modelID + : undefined + return { + providerID: provider.id, + modelID, + providerName: provider.name, + category: provider.name, + display: title, + footer, + keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`, + current, + } + }), + ) + .sort((a, b) => { + const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode") + if (provider !== 0) { + return provider + } + + const name = a.providerName.localeCompare(b.providerName) + if (name !== 0) { + return name + } + + return a.display.localeCompare(b.display) + }), + ) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: ModelEntry) => { + props.onSelect({ providerID: item.providerID, modelID: item.modelID }) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + createEffect(() => { + if (query().trim()) { + return + } + + const index = items().findIndex((item) => item.current) + if (index !== -1) { + menu.reveal(index) + } + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty={props.providers() ? "No results found" : "Models loading"} + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={!query().trim()} + /> + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/opencode/src/cli/cmd/run/footer.menu.tsx new file mode 100644 index 0000000000..7a3332165b --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.menu.tsx @@ -0,0 +1,290 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes } from "@opentui/core" +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import { transparent, type RunFooterTheme } from "./theme" + +export const FOOTER_MENU_ROWS = 8 + +export type RunFooterMenuItem = { + display: string + description?: string + category?: string + footer?: string +} + +type RunFooterMenuRow = + | { type: "header"; label: string } + | { type: "item"; item: RunFooterMenuItem; index: number } + | { type: "spacer" } + +function maxOffset(count: number, limit: number) { + return Math.max(0, count - limit) +} + +function previewMargin(limit: number) { + return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2))) +} + +function revealOffset(value: number, input: { count: number; limit: number; selected: number }) { + const max = maxOffset(input.count, input.limit) + if (input.selected < value) { + return Math.min(max, input.selected) + } + + if (input.selected >= value + input.limit) { + return Math.min(max, input.selected - input.limit + 1) + } + + return Math.min(max, value) +} + +function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) { + const max = maxOffset(input.count, input.limit) + const margin = previewMargin(input.limit) + if (input.dir < 0 && input.selected < value + margin) { + return Math.max(0, Math.min(max, input.selected - margin)) + } + + if (input.dir > 0 && input.selected > value + input.limit - margin - 1) { + return Math.min(max, input.selected - input.limit + margin + 1) + } + + return Math.min(max, value) +} + +export function createFooterMenuState(input: { count: Accessor; limit?: number }) { + const [selected, setSelected] = createSignal(0) + const [offset, setOffset] = createSignal(0) + const limit = () => input.limit ?? FOOTER_MENU_ROWS + const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count()))) + + const reveal = (index: number) => { + const count = input.count() + if (count === 0) { + setSelected(0) + setOffset(0) + return + } + + const next = Math.max(0, Math.min(count - 1, index)) + setSelected(next) + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next })) + } + + const reset = () => { + setSelected(0) + setOffset(0) + } + + createEffect(() => { + const count = input.count() + if (count === 0) { + reset() + return + } + + if (selected() >= count) { + setSelected(count - 1) + } + + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() })) + }) + + const move = (dir: -1 | 1) => { + const count = input.count() + if (count === 0) { + reset() + return + } + + const next = Math.max(0, Math.min(count - 1, selected() + dir)) + setSelected(next) + setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir })) + } + + return { + selected, + offset, + rows, + reveal, + reset, + move, + } +} + +export function RunFooterMenu(props: { + id?: string + theme: Accessor + items: Accessor + selected: Accessor + offset: Accessor + rows: Accessor + limit?: number + empty?: string + border?: boolean + paddingLeft?: number + paddingRight?: number + grouped?: boolean +}) { + const limit = () => props.limit ?? FOOTER_MENU_ROWS + const border = () => props.border ?? true + const [groupOffset, setGroupOffset] = createSignal(0) + let previous = -1 + const groupedRows = createMemo(() => { + const all: RunFooterMenuRow[] = [] + let category = "" + props.items().forEach((item, index) => { + if (item.category && item.category !== category) { + if (all.length > 0) { + all.push({ type: "spacer" }) + } + + category = item.category + all.push({ type: "header", label: item.category }) + } + + all.push({ type: "item", item, index }) + }) + return all + }) + + createEffect(() => { + if (!props.grouped) { + return + } + + const all = groupedRows() + const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected()) + if (all.length === 0 || selected === -1) { + setGroupOffset(0) + previous = props.selected() + return + } + + const dir = + props.selected() === previous + 1 ? 1 + : props.selected() === previous - 1 ? -1 + : undefined + setGroupOffset((value) => + dir + ? moveOffset(value, { count: all.length, limit: limit(), selected, dir }) + : revealOffset(value, { count: all.length, limit: limit(), selected }), + ) + previous = props.selected() + }) + + const rows = createMemo(() => { + if (!props.grouped) { + return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({ + type: "item", + item, + index: index + props.offset(), + })) + } + + const all = groupedRows() + const start = Math.max(0, Math.min(groupOffset(), all.length - limit())) + return all.slice(start, start + limit()) + }) + const descriptionColumn = createMemo(() => { + const width = Math.max(0, ...props.items().filter((item) => item.description).map((item) => Bun.stringWidth(item.display))) + return width === 0 ? 0 : width + 2 + }) + const descriptionPad = (item: RunFooterMenuItem) => { + if (!item.description) { + return "" + } + + return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display))) + } + return ( + + {rows().length === 0 ? ( + + {border() ? ( + + ┃ + + ) : undefined} + + + {props.empty ?? "No matching items"} + + + + ) : ( + rows().map((row) => { + if (row.type === "spacer") { + return + } + + if (row.type === "header") { + return ( + + + {row.label} + + + ) + } + + const active = () => row.index === props.selected() + const inset = () => (active() ? 1 : 0) + return ( + + {border() ? ( + + ┃ + + ) : undefined} + + + + + {row.item.display} + {row.item.description ? ( + + {descriptionPad(row.item)} + {row.item.description} + + ) : undefined} + + {row.item.footer ? ( + + {row.item.footer} + + ) : undefined} + + + + + ) + }) + )} + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx new file mode 100644 index 0000000000..b38c2da9d1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -0,0 +1,478 @@ +// Permission UI body for the direct-mode footer. +// +// Renders inside the footer when the reducer pushes a FooterView of type +// "permission". Uses a three-stage state machine (permission.shared.ts): +// +// permission → shows the request with Allow once / Always / Reject buttons +// always → confirmation step before granting permanent access +// reject → text field for the rejection message +// +// Keyboard: left/right to select, enter to confirm, esc to reject. +// The diff view (when available) uses the same diff component as scrollback +// tool snapshots. +/** @jsxImportSource @opentui/solid */ +import type { TextareaRenderable } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { + createPermissionBodyState, + permissionAlwaysLines, + permissionCancel, + permissionEscape, + permissionHover, + permissionInfo, + permissionLabel, + permissionOptions, + permissionReject, + permissionRun, + permissionShift, + type PermissionOption, +} from "./permission.shared" +import { toolFiletype } from "./tool" +import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme" +import type { PermissionReply, RunDiffStyle } from "./types" + +function buttons( + list: PermissionOption[], + selected: PermissionOption, + theme: RunFooterTheme, + disabled: boolean, + onHover: (option: PermissionOption) => void, + onSelect: (option: PermissionOption) => void, +) { + return ( + + + {(option) => ( + { + if (!disabled) onHover(option) + }} + onMouseUp={() => { + if (!disabled) onSelect(option) + }} + > + {permissionLabel(option)} + + )} + + + ) +} + +function RejectField(props: { + theme: RunFooterTheme + text: string + disabled: boolean + onChange: (text: string) => void + onConfirm: () => void + onCancel: () => void +}) { + let area: TextareaRenderable | undefined + + createEffect(() => { + if (!area || area.isDestroyed) { + return + } + + if (area.plainText !== props.text) { + area.setText(props.text) + area.cursorOffset = props.text.length + } + + queueMicrotask(() => { + if (!area || area.isDestroyed || props.disabled) { + return + } + area.focus() + }) + }) + + return ( +