From 1405c958ebd1715a6164d05a7692d44cd26e0120 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 5 Apr 2026 21:53:54 +0200 Subject: [PATCH] cli: add interactive split-footer mode to run Add `run --interactive` (`-i`) which renders a split-screen TUI inside the run command: scrollback output on top, input footer on the bottom with prompt, permission, and question views. This bridges the gap between headless `run` and the full TUI, giving users an interactive experience without leaving the CLI pipeline. Tool rendering, session data reduction, and stream transport are extracted into run/ submodules to keep the orchestration manageable. *.shared.ts files contain shared logic for both run and the TUI, but are intentionally kept in /run for now until later refactor --- packages/opencode/src/cli/cmd/run.ts | 661 ++++---- packages/opencode/src/cli/cmd/run/demo.ts | 1105 +++++++++++++ .../src/cli/cmd/run/footer.permission.tsx | 487 ++++++ .../src/cli/cmd/run/footer.prompt.tsx | 471 ++++++ .../src/cli/cmd/run/footer.question.tsx | 596 +++++++ packages/opencode/src/cli/cmd/run/footer.ts | 626 +++++++ .../opencode/src/cli/cmd/run/footer.view.tsx | 306 ++++ .../src/cli/cmd/run/permission.shared.ts | 256 +++ .../opencode/src/cli/cmd/run/prompt.shared.ts | 253 +++ .../src/cli/cmd/run/question.shared.ts | 340 ++++ .../opencode/src/cli/cmd/run/runtime.boot.ts | 140 ++ .../src/cli/cmd/run/runtime.lifecycle.ts | 235 +++ .../opencode/src/cli/cmd/run/runtime.queue.ts | 214 +++ packages/opencode/src/cli/cmd/run/runtime.ts | 292 ++++ .../src/cli/cmd/run/scrollback.format.ts | 92 ++ .../opencode/src/cli/cmd/run/scrollback.tsx | 26 + .../src/cli/cmd/run/scrollback.writer.tsx | 635 +++++++ .../opencode/src/cli/cmd/run/session-data.ts | 881 ++++++++++ .../src/cli/cmd/run/session.shared.ts | 94 ++ packages/opencode/src/cli/cmd/run/splash.ts | 291 ++++ .../src/cli/cmd/run/stream.transport.ts | 376 +++++ packages/opencode/src/cli/cmd/run/stream.ts | 59 + packages/opencode/src/cli/cmd/run/theme.ts | 218 +++ packages/opencode/src/cli/cmd/run/tool.ts | 1465 +++++++++++++++++ packages/opencode/src/cli/cmd/run/trace.ts | 94 ++ packages/opencode/src/cli/cmd/run/types.ts | 180 ++ .../src/cli/cmd/run/variant.shared.ts | 126 ++ packages/opencode/src/cli/cmd/tui/app.tsx | 1 + packages/opencode/src/cli/cmd/tui/attach.ts | 4 +- .../src/cli/cmd/tui/context/theme.tsx | 10 +- packages/opencode/src/cli/cmd/tui/thread.ts | 4 +- packages/opencode/src/tool/lsp.ts | 7 +- 32 files changed, 10220 insertions(+), 325 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/demo.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.permission.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.prompt.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.question.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.view.tsx create mode 100644 packages/opencode/src/cli/cmd/run/permission.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/prompt.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/question.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.boot.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.queue.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.format.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.tsx create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.writer.tsx create mode 100644 packages/opencode/src/cli/cmd/run/session-data.ts create mode 100644 packages/opencode/src/cli/cmd/run/session.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/splash.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.transport.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.ts create mode 100644 packages/opencode/src/cli/cmd/run/theme.ts create mode 100644 packages/opencode/src/cli/cmd/run/tool.ts create mode 100644 packages/opencode/src/cli/cmd/run/trace.ts create mode 100644 packages/opencode/src/cli/cmd/run/types.ts create mode 100644 packages/opencode/src/cli/cmd/run/variant.shared.ts diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 92b6156ca..3f68c0d01 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" @@ -7,40 +20,27 @@ import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" import { EOL } from "os" import { Filesystem } from "../../util/filesystem" -import { createOpencodeClient, type Message, 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 { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { Permission } from "../../permission" -import { Tool } from "../../tool/tool" -import { GlobTool } from "../../tool/glob" -import { GrepTool } from "../../tool/grep" -import { ListTool } from "../../tool/ls" -import { ReadTool } from "../../tool/read" -import { WebFetchTool } from "../../tool/webfetch" -import { EditTool } from "../../tool/edit" -import { WriteTool } from "../../tool/write" -import { CodeSearchTool } from "../../tool/codesearch" -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 type { RunDemo } from "./run/types" -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, - } +type FilePart = { + type: "file" + url: string + filename: string + mime: string } type Inline = { @@ -49,6 +49,11 @@ type Inline = { description?: string } +type SessionInfo = { + id: string + title?: 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,160 +67,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) { - 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 list(info: ToolProps) { - const dir = info.input.path ? normalizePath(info.input.path) : "" - inline({ - icon: "→", - title: dir ? `List ${dir}` : "List", - }) -} - -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 codesearch(info: ToolProps) { - inline({ - icon: "◇", - title: `Exa Code Search "${info.input.query}"`, - }) -} - -function websearch(info: ToolProps) { - inline({ - icon: "◈", - title: `Exa Web Search "${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 bash(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 + inline(next) + } catch { + inline({ + icon: "⚙", + title: part.tool, + }) + } } export const RunCommand = cmd({ @@ -300,14 +167,61 @@ 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("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) + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") + if (args.interactive && args.command) { + UI.error("--interactive cannot be used with --command") + process.exit(1) + } + + if (args.demo && !args.interactive) { + UI.error("--demo requires --interactive") + process.exit(1) + } + + if (args.demoText && args.demo !== "text") { + UI.error("--demo-text requires --demo text") + process.exit(1) + } + + if (args.interactive && args.format === "json") { + UI.error("--interactive cannot be used with --format json") + process.exit(1) + } + + if (args.interactive && !process.stdin.isTTY) { + UI.error("--interactive requires a TTY") + process.exit(1) + } + + if (args.interactive && !process.stdout.isTTY) { + UI.error("--interactive requires a TTY stdout") + process.exit(1) + } + const directory = (() => { if (!args.dir) return undefined if (args.attach) return args.dir @@ -320,7 +234,7 @@ export const RunCommand = cmd({ } })() - 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] @@ -344,7 +258,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) } @@ -354,23 +268,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 @@ -378,19 +294,78 @@ 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 { + 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, + } + } + + return { + id: current.data.id, + title: current.data.title, + } } - 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, + } + } + + if (base) { + return { + id: base.id, + title: base.title, + } + } 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, + } } async function share(sdk: OpencodeClient, sessionID: string) { @@ -408,45 +383,101 @@ export const RunCommand = cmd({ } } - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === "bash") return bash(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "list") return list(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 === "codesearch") return codesearch(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 localAgent() { + if (!args.agent) return undefined + + const entry = await (await import("../../agent/agent")).Agent.get(args.agent) + if (!entry) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${args.agent}" 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 "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + return args.agent + } + + async function attachAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + + 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 === args.agent) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${args.agent}" 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 "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return args.agent + } + + async function pickAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + if (args.attach) { + return attachAgent(sdk) + } + + return localAgent() + } + + async function execute(sdk: OpencodeClient) { 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() { + async function loop(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,7 +495,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({ @@ -481,7 +512,7 @@ export const RunCommand = cmd({ args.format !== "json" ) { if (toggles.get(part.id) === true) continue - task(props(part)) + await tool(part) toggles.set(part.id, true) } @@ -558,90 +589,36 @@ export const RunCommand = cmd({ } // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined + const agent = await pickAgent(sdk) - // 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) - - 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 === args.agent) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${args.agent}" 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 "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - - return args.agent - } - - const entry = await Agent.get(args.agent) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${args.agent}" 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 "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return args.agent - })() - - const sessionID = await session(sdk) - if (!sessionID) { + const sess = await session(sdk) + if (!sess?.id) { UI.error("Session not found") process.exit(1) } + const sessionID = sess.id 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 sdk.event.subscribe() + loop(events).catch((e) => { + console.error(e) + process.exit(1) }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined + + if (args.command) { + await sdk.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + return + } + + const model = pick(args.model) await sdk.session.prompt({ sessionID, agent, @@ -649,7 +626,51 @@ export const RunCommand = cmd({ variant: args.variant, parts: [...files, { type: "text", text: message }], }) + return } + + const model = pick(args.model) + const { runInteractiveMode } = await runtimeTask + await runInteractiveMode({ + sdk, + sessionID, + sessionTitle: sess.title, + resume: Boolean(args.session) && !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.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().fetch(request) + }) as typeof globalThis.fetch + + return await runInteractiveLocalMode({ + 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) { @@ -660,16 +681,24 @@ export const RunCommand = cmd({ const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` return { Authorization: auth } })() - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + const sdk = createOpencodeClient({ + baseUrl: args.attach, + directory, + headers, + }) return await execute(sdk) } await bootstrap(process.cwd(), async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("../../server/server") const request = new Request(input, init) return Server.Default().fetch(request) }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: fetchFn, + }) 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 000000000..342253abf --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -0,0 +1,1105 @@ +// 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. +// +// 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 } from "@opencode-ai/sdk/v2" +import { createSessionData, reduceSessionData, type SessionData } from "./session-data" +import { writeSessionOutput } from "./stream" +import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunDemo } from "./types" + +const KINDS = ["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] + +const SAMPLE_TEXT = [ + "# Demo markdown", + "", + "This is sample assistant output for direct mode formatting checks.", + "It includes **bold**, _italic_, and `inline code`.", + "", + "- bullet: short line", + "- bullet: long line that should wrap cleanly in narrow terminals while keeping list indentation readable", + "- bullet: [link text](https://example.com)", + "", + "1. ordered item", + "2. second ordered item", + "", + "> quote line for spacing and style checks", + "", + "```ts", + "const sample = { ok: true, count: 42 }", + "```", + "", + "| key | value |", + "| ----- | ----- |", + "| alpha | one |", + "| beta | two |", +].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 = { + mode: RunDemo + text?: string + 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 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 { + feed(state, { + type: "session.error", + properties: { + sessionID: state.id, + error: { + name: "DemoError", + message: text, + }, + }, + } as unknown as 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", + }, + }) +} + +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 = + kind === "single" + ? [ + { + 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, + }, + ] + : kind === "checklist" + ? [ + { + 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, + }, + ] + : kind === "custom" + ? [ + { + 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, + }, + ] + : [ + { + 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_TEXT, 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, "Demo run: assistant text block for wrap testing.", 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 mix", + "- /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) + if (input.mode === "on") { + return + } + + if (input.mode === "permission") { + emitPermission(state, "edit") + return + } + + if (input.mode === "question") { + emitQuestion(state, "multi") + return + } + + if (input.mode === "mix") { + await emitFmt(state, "mix", "") + return + } + + if (input.mode === "text") { + await emitFmt(state, "text", input.text ?? SAMPLE_TEXT) + } + } + + const prompt = async (line: string, signal?: AbortSignal): Promise => { + const text = line.trim() + const list = text.split(/\s+/) + const cmd = list[0] || "" + + if (cmd === "/help") { + intro(state) + return true + } + + if (cmd === "/permission") { + const kind = (list[1] || "edit").toLowerCase() as PermissionKind + if (!PERMISSIONS.includes(kind)) { + note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`) + return true + } + + emitPermission(state, kind) + return true + } + + if (cmd === "/question") { + const kind = (list[1] || "multi").toLowerCase() as QuestionKind + if (!QUESTIONS.includes(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) { + return false + } + + state.perms.delete(input.requestID) + feed(state, { + type: "permission.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + reply: input.reply, + }, + } as 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) { + return false + } + + state.asks.delete(input.requestID) + feed(state, { + type: "question.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + answers: input.answers, + }, + } as 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/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx new file mode 100644 index 000000000..cd4e8e7f9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -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 type { RunBlockTheme, 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 ( + + + {(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: 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 ( +