Преглед изворни кода

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
Simon Klee пре 1 недеља
родитељ
комит
1405c958eb
32 измењених фајлова са 10221 додато и 326 уклоњено
  1. 346 317
      packages/opencode/src/cli/cmd/run.ts
  2. 1105 0
      packages/opencode/src/cli/cmd/run/demo.ts
  3. 487 0
      packages/opencode/src/cli/cmd/run/footer.permission.tsx
  4. 471 0
      packages/opencode/src/cli/cmd/run/footer.prompt.tsx
  5. 596 0
      packages/opencode/src/cli/cmd/run/footer.question.tsx
  6. 626 0
      packages/opencode/src/cli/cmd/run/footer.ts
  7. 306 0
      packages/opencode/src/cli/cmd/run/footer.view.tsx
  8. 256 0
      packages/opencode/src/cli/cmd/run/permission.shared.ts
  9. 253 0
      packages/opencode/src/cli/cmd/run/prompt.shared.ts
  10. 340 0
      packages/opencode/src/cli/cmd/run/question.shared.ts
  11. 140 0
      packages/opencode/src/cli/cmd/run/runtime.boot.ts
  12. 235 0
      packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
  13. 214 0
      packages/opencode/src/cli/cmd/run/runtime.queue.ts
  14. 292 0
      packages/opencode/src/cli/cmd/run/runtime.ts
  15. 92 0
      packages/opencode/src/cli/cmd/run/scrollback.format.ts
  16. 26 0
      packages/opencode/src/cli/cmd/run/scrollback.tsx
  17. 635 0
      packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
  18. 881 0
      packages/opencode/src/cli/cmd/run/session-data.ts
  19. 94 0
      packages/opencode/src/cli/cmd/run/session.shared.ts
  20. 291 0
      packages/opencode/src/cli/cmd/run/splash.ts
  21. 376 0
      packages/opencode/src/cli/cmd/run/stream.transport.ts
  22. 59 0
      packages/opencode/src/cli/cmd/run/stream.ts
  23. 218 0
      packages/opencode/src/cli/cmd/run/theme.ts
  24. 1465 0
      packages/opencode/src/cli/cmd/run/tool.ts
  25. 94 0
      packages/opencode/src/cli/cmd/run/trace.ts
  26. 180 0
      packages/opencode/src/cli/cmd/run/types.ts
  27. 126 0
      packages/opencode/src/cli/cmd/run/variant.shared.ts
  28. 1 0
      packages/opencode/src/cli/cmd/tui/app.tsx
  29. 2 2
      packages/opencode/src/cli/cmd/tui/attach.ts
  30. 6 4
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  31. 2 2
      packages/opencode/src/cli/cmd/tui/thread.ts
  32. 6 1
      packages/opencode/src/tool/lsp.ts

+ 346 - 317
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"
-
-type ToolProps<T> = {
-  input: Tool.InferParameters<T>
-  metadata: Tool.InferMetadata<T>
-  part: ToolPart
-}
+import type { RunDemo } from "./run/types"
+
+const runtimeTask = import("./run/runtime")
+type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
 
-function props<T>(part: ToolPart): ToolProps<T> {
-  const state = part.state
+function pick(value: string | undefined): ModelInput | undefined {
+  if (!value) return undefined
+  const [providerID, ...rest] = value.split("/")
   return {
-    input: state.input as Tool.InferParameters<T>,
-    metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
-    part,
-  }
+    providerID,
+    modelID: rest.join("/"),
+  } as ModelInput
+}
+
+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}`,
-  })
-}
-
-function glob(info: ToolProps<typeof GlobTool>) {
-  const root = info.input.path ?? ""
-  const title = `Glob "${info.input.pattern}"`
-  const suffix = root ? `in ${normalizePath(root)}` : ""
-  const num = info.metadata.count
-  const description =
-    num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
-  inline({
-    icon: "✱",
-    title,
-    ...(description && { description }),
-  })
-}
-
-function grep(info: ToolProps<typeof GrepTool>) {
-  const root = info.input.path ?? ""
-  const title = `Grep "${info.input.pattern}"`
-  const suffix = root ? `in ${normalizePath(root)}` : ""
-  const num = info.metadata.matches
-  const description =
-    num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
-  inline({
-    icon: "✱",
-    title,
-    ...(description && { description }),
-  })
-}
-
-function list(info: ToolProps<typeof ListTool>) {
-  const dir = info.input.path ? normalizePath(info.input.path) : ""
-  inline({
-    icon: "→",
-    title: dir ? `List ${dir}` : "List",
-  })
-}
-
-function read(info: ToolProps<typeof ReadTool>) {
-  const file = normalizePath(info.input.filePath)
-  const pairs = Object.entries(info.input).filter(([key, value]) => {
-    if (key === "filePath") return false
-    return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
-  })
-  const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined
-  inline({
-    icon: "→",
-    title: `Read ${file}`,
-    ...(description && { description }),
-  })
-}
-
-function write(info: ToolProps<typeof WriteTool>) {
-  block(
-    {
-      icon: "←",
-      title: `Write ${normalizePath(info.input.filePath)}`,
-    },
-    info.part.state.status === "completed" ? info.part.state.output : undefined,
-  )
-}
-
-function webfetch(info: ToolProps<typeof WebFetchTool>) {
-  inline({
-    icon: "%",
-    title: `WebFetch ${info.input.url}`,
-  })
-}
-
-function edit(info: ToolProps<typeof EditTool>) {
-  const title = normalizePath(info.input.filePath)
-  const diff = info.metadata.diff
-  block(
-    {
-      icon: "←",
-      title: `Edit ${title}`,
-    },
-    diff,
-  )
-}
-
-function codesearch(info: ToolProps<typeof CodeSearchTool>) {
-  inline({
-    icon: "◇",
-    title: `Exa Code Search "${info.input.query}"`,
-  })
-}
-
-function websearch(info: ToolProps<typeof WebSearchTool>) {
-  inline({
-    icon: "◈",
-    title: `Exa Web Search "${info.input.query}"`,
-  })
-}
-
-function task(info: ToolProps<typeof TaskTool>) {
-  const input = info.part.state.input
-  const status = info.part.state.status
-  const subagent =
-    typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown"
-  const agent = Locale.titlecase(subagent)
-  const desc =
-    typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined
-  const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓"
-  const name = desc ?? `${agent} Task`
-  inline({
-    icon,
-    title: name,
-    description: desc ? `${agent} Agent` : undefined,
-  })
-}
-
-function skill(info: ToolProps<typeof SkillTool>) {
-  inline({
-    icon: "→",
-    title: `Skill "${info.input.name}"`,
-  })
-}
-
-function bash(info: ToolProps<typeof BashTool>) {
-  const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
-  block(
-    {
-      icon: "$",
-      title: `${info.input.command}`,
-    },
-    output,
-  )
-}
-
-function todo(info: ToolProps<typeof TodoWriteTool>) {
-  block(
-    {
-      icon: "#",
-      title: "Todos",
-    },
-    info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"),
-  )
-}
+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 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<SessionInfo | undefined> {
+      if (args.session) {
+        const current = await sdk.session
+          .get({
+            sessionID: args.session,
+          })
+          .catch(() => undefined)
+
+        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,
+        }
+      }
+
+      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
+        }
 
-      if (baseID && args.fork) {
-        const forked = await sdk.session.fork({ sessionID: baseID })
-        return forked.data?.id
+        return {
+          id,
+          title: forked.data?.title ?? base.title,
+        }
       }
 
-      if (baseID) return baseID
+      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<typeof BashTool>(part))
-          if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
-          if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
-          if (part.tool === "list") return list(props<typeof ListTool>(part))
-          if (part.tool === "read") return read(props<typeof ReadTool>(part))
-          if (part.tool === "write") return write(props<typeof WriteTool>(part))
-          if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
-          if (part.tool === "edit") return edit(props<typeof EditTool>(part))
-          if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
-          if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
-          if (part.tool === "task") return task(props<typeof TaskTool>(part))
-          if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
-          if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
-          return fallback(part)
-        } catch {
-          return fallback(part)
-        }
+    async function 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<string, unknown>) {
         if (args.format === "json") {
-          process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
+          process.stdout.write(
+            JSON.stringify({
+              type,
+              timestamp: Date.now(),
+              sessionID,
+              ...data,
+            }) + EOL,
+          )
           return true
         }
         return false
       }
 
-      const events = await sdk.event.subscribe()
-      let error: string | undefined
-
-      async function loop() {
+      async function loop(events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
         const toggles = new Map<string, boolean>()
+        let error: string | undefined
 
         for await (const event of events.stream) {
           if (
             event.type === "message.updated" &&
+            event.properties.sessionID === sessionID &&
             event.properties.info.role === "assistant" &&
             args.format !== "json" &&
             toggles.get("start") !== true
@@ -464,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<typeof TaskTool>(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)
     })
   },

+ 1105 - 0
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 <kind>   → emits a specific tool/text type (text, reasoning, bash,
+//                   write, edit, patch, task, todo, question, error, mix)
+//
+// Demo mode also handles permission and question replies locally, completing
+// or failing the synthetic tool parts as appropriate.
+import path from "path"
+import type { Event } 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<string, unknown>
+  start: number
+}
+
+type Ask = {
+  ref: Ref
+}
+
+type Perm = {
+  ref: Ref
+  done: {
+    title: string
+    output: string
+    metadata?: Record<string, unknown>
+  }
+}
+
+type Permit = {
+  ref: Ref
+  permission: string
+  patterns: string[]
+  metadata?: Record<string, unknown>
+  always: string[]
+  done: Perm["done"]
+}
+
+type State = {
+  id: string
+  thinking: boolean
+  data: SessionData
+  footer: FooterApi
+  limits: () => Record<string, number>
+  msg: number
+  part: number
+  call: number
+  perm: number
+  ask: number
+  perms: Map<string, Perm>
+  asks: Map<string, Ask>
+}
+
+type Input = {
+  mode: RunDemo
+  text?: string
+  sessionID: string
+  thinking: boolean
+  limits: () => Record<string, number>
+  footer: FooterApi
+}
+
+function note(footer: FooterApi, text: string): void {
+  footer.append({
+    kind: "system",
+    text,
+    phase: "start",
+    source: "system",
+  })
+}
+
+function wait(ms: number, signal?: AbortSignal): Promise<void> {
+  return new Promise((resolve) => {
+    if (!signal) {
+      setTimeout(resolve, ms)
+      return
+    }
+
+    if (signal.aborted) {
+      resolve()
+      return
+    }
+
+    const done = () => {
+      clearTimeout(timer)
+      signal.removeEventListener("abort", done)
+      resolve()
+    }
+
+    const timer = setTimeout(() => {
+      signal.removeEventListener("abort", done)
+      resolve()
+    }, ms)
+
+    signal.addEventListener("abort", done, { once: true })
+  })
+}
+
+function split(text: string): string[] {
+  if (text.length <= 48) {
+    return [text]
+  }
+
+  const size = Math.ceil(text.length / 3)
+  return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)]
+}
+
+function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string {
+  state[key] += 1
+  return `demo_${prefix}_${state[key]}`
+}
+
+function feed(state: State, event: Event): void {
+  const out = reduceSessionData({
+    data: state.data,
+    event,
+    sessionID: state.id,
+    thinking: state.thinking,
+    limits: state.limits(),
+  })
+  state.data = out.data
+  writeSessionOutput(
+    {
+      footer: state.footer,
+    },
+    out,
+  )
+}
+
+function open(state: State): string {
+  const id = take(state, "msg", "msg")
+  feed(state, {
+    type: "message.updated",
+    properties: {
+      sessionID: state.id,
+      info: {
+        id,
+        sessionID: state.id,
+        role: "assistant",
+        time: {
+          created: Date.now(),
+        },
+        parentID: `user_${id}`,
+        modelID: "demo",
+        providerID: "demo",
+        mode: "demo",
+        agent: "demo",
+        path: {
+          cwd: process.cwd(),
+          root: process.cwd(),
+        },
+        cost: 0.001,
+        tokens: {
+          input: 120,
+          output: 320,
+          reasoning: 80,
+          cache: {
+            read: 0,
+            write: 0,
+          },
+        },
+      },
+    },
+  } as Event)
+  return id
+}
+
+async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
+  const msg = open(state)
+  const part = take(state, "part", "part")
+  const start = Date.now()
+
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: part,
+        sessionID: state.id,
+        messageID: msg,
+        type: "text",
+        text: "",
+        time: {
+          start,
+        },
+      },
+    },
+  } as Event)
+
+  let next = ""
+  for (const item of split(body)) {
+    if (signal?.aborted) {
+      return
+    }
+
+    next += item
+    feed(state, {
+      type: "message.part.delta",
+      properties: {
+        sessionID: state.id,
+        messageID: msg,
+        partID: part,
+        field: "text",
+        delta: item,
+      },
+    } as Event)
+    await wait(45, signal)
+  }
+
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: part,
+        sessionID: state.id,
+        messageID: msg,
+        type: "text",
+        text: next,
+        time: {
+          start,
+          end: Date.now(),
+        },
+      },
+    },
+  } as Event)
+}
+
+async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
+  const msg = open(state)
+  const part = take(state, "part", "part")
+  const start = Date.now()
+
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: part,
+        sessionID: state.id,
+        messageID: msg,
+        type: "reasoning",
+        text: "",
+        time: {
+          start,
+        },
+      },
+    },
+  } as Event)
+
+  let next = ""
+  for (const item of split(body)) {
+    if (signal?.aborted) {
+      return
+    }
+
+    next += item
+    feed(state, {
+      type: "message.part.delta",
+      properties: {
+        sessionID: state.id,
+        messageID: msg,
+        partID: part,
+        field: "text",
+        delta: item,
+      },
+    } as Event)
+    await wait(45, signal)
+  }
+
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: part,
+        sessionID: state.id,
+        messageID: msg,
+        type: "reasoning",
+        text: next,
+        time: {
+          start,
+          end: Date.now(),
+        },
+      },
+    },
+  } as Event)
+}
+
+function make(state: State, tool: string, input: Record<string, unknown>): Ref {
+  return {
+    msg: open(state),
+    part: take(state, "part", "part"),
+    call: take(state, "call", "call"),
+    tool,
+    input,
+    start: Date.now(),
+  }
+}
+
+function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: ref.part,
+        sessionID: state.id,
+        messageID: ref.msg,
+        type: "tool",
+        callID: ref.call,
+        tool: ref.tool,
+        state: {
+          status: "running",
+          input: ref.input,
+          metadata,
+          time: {
+            start: ref.start,
+          },
+        },
+      },
+    },
+  } as Event)
+}
+
+function askPermission(state: State, item: Permit): void {
+  startTool(state, item.ref)
+
+  const id = take(state, "perm", "perm")
+  state.perms.set(id, {
+    ref: item.ref,
+    done: item.done,
+  })
+
+  feed(state, {
+    type: "permission.asked",
+    properties: {
+      id,
+      sessionID: state.id,
+      permission: item.permission,
+      patterns: item.patterns,
+      metadata: item.metadata ?? {},
+      always: item.always,
+      tool: {
+        messageID: item.ref.msg,
+        callID: item.ref.call,
+      },
+    },
+  } as Event)
+}
+
+function doneTool(
+  state: State,
+  ref: Ref,
+  output: {
+    title: string
+    output: string
+    metadata?: Record<string, unknown>
+  },
+): void {
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: ref.part,
+        sessionID: state.id,
+        messageID: ref.msg,
+        type: "tool",
+        callID: ref.call,
+        tool: ref.tool,
+        state: {
+          status: "completed",
+          input: ref.input,
+          output: output.output,
+          title: output.title,
+          metadata: output.metadata ?? {},
+          time: {
+            start: ref.start,
+            end: Date.now(),
+          },
+        },
+      },
+    },
+  } as Event)
+}
+
+function failTool(state: State, ref: Ref, error: string): void {
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: ref.part,
+        sessionID: state.id,
+        messageID: ref.msg,
+        type: "tool",
+        callID: ref.call,
+        tool: ref.tool,
+        state: {
+          status: "error",
+          input: ref.input,
+          error,
+          metadata: {},
+          time: {
+            start: ref.start,
+            end: Date.now(),
+          },
+        },
+      },
+    },
+  } as Event)
+}
+
+function emitError(state: State, text: string): void {
+  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<void> {
+  const ref = make(state, "bash", {
+    command: "git status",
+    workdir: process.cwd(),
+    description: "Show git status",
+  })
+  startTool(state, ref)
+  await wait(70, signal)
+  doneTool(state, ref, {
+    title: "git status",
+    output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
+    metadata: {
+      exitCode: 0,
+    },
+  })
+}
+
+function emitWrite(state: State): void {
+  const file = path.join(process.cwd(), "src", "demo-format.ts")
+  const ref = make(state, "write", {
+    filePath: file,
+    content: "export const demo = 42\n",
+  })
+  doneTool(state, ref, {
+    title: "write",
+    output: "",
+    metadata: {},
+  })
+}
+
+function emitEdit(state: State): void {
+  const file = path.join(process.cwd(), "src", "demo-format.ts")
+  const ref = make(state, "edit", {
+    filePath: file,
+  })
+  doneTool(state, ref, {
+    title: "edit",
+    output: "",
+    metadata: {
+      diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
+    },
+  })
+}
+
+function emitPatch(state: State): void {
+  const file = path.join(process.cwd(), "src", "demo-format.ts")
+  const ref = make(state, "apply_patch", {
+    patchText: "*** Begin Patch\n*** End Patch",
+  })
+  doneTool(state, ref, {
+    title: "apply_patch",
+    output: "",
+    metadata: {
+      files: [
+        {
+          type: "update",
+          filePath: file,
+          relativePath: "src/demo-format.ts",
+          diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
+          deletions: 1,
+        },
+        {
+          type: "add",
+          filePath: path.join(process.cwd(), "README-demo.md"),
+          relativePath: "README-demo.md",
+          diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
+          deletions: 0,
+        },
+      ],
+    },
+  })
+}
+
+function emitTask(state: State): void {
+  const ref = make(state, "task", {
+    description: "Scan run/* for reducer touchpoints",
+    subagent_type: "explore",
+  })
+  doneTool(state, ref, {
+    title: "Reducer touchpoints found",
+    output: "",
+    metadata: {
+      toolcalls: 4,
+      sessionId: "sub_demo_1",
+    },
+  })
+}
+
+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<boolean> {
+  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 <kind> (${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<void> => {
+    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<boolean> => {
+    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,
+  }
+}

+ 487 - 0
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 (
+    <box flexDirection="row" gap={1} flexShrink={0} paddingBottom={1}>
+      <For each={list}>
+        {(option) => (
+          <box
+            paddingLeft={1}
+            paddingRight={1}
+            backgroundColor={option === selected ? theme.highlight : theme.surface}
+            onMouseOver={() => {
+              if (!disabled) onHover(option)
+            }}
+            onMouseUp={() => {
+              if (!disabled) onSelect(option)
+            }}
+          >
+            <text fg={option === selected ? theme.surface : theme.muted}>{permissionLabel(option)}</text>
+          </box>
+        )}
+      </For>
+    </box>
+  )
+}
+
+function RejectField(props: {
+  theme: RunFooterTheme
+  text: string
+  disabled: boolean
+  onChange: (text: string) => void
+  onConfirm: () => void
+  onCancel: () => void
+}) {
+  let area: RejectArea | undefined
+
+  createEffect(() => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    if (area.plainText !== props.text) {
+      area.setText(props.text)
+      area.cursorOffset = props.text.length
+    }
+
+    queueMicrotask(() => {
+      if (!area || area.isDestroyed || props.disabled) {
+        return
+      }
+      area.focus()
+    })
+  })
+
+  return (
+    <textarea
+      id="run-direct-footer-permission-reject"
+      width="100%"
+      minHeight={1}
+      maxHeight={3}
+      paddingBottom={1}
+      wrapMode="word"
+      placeholder="Tell OpenCode what to do differently"
+      placeholderColor={props.theme.muted}
+      textColor={props.theme.text}
+      focusedTextColor={props.theme.text}
+      backgroundColor={props.theme.surface}
+      focusedBackgroundColor={props.theme.surface}
+      cursorColor={props.theme.text}
+      focused={!props.disabled}
+      onContentChange={() => {
+        if (!area || area.isDestroyed) {
+          return
+        }
+        props.onChange(area.plainText)
+      }}
+      onKeyDown={(event) => {
+        if (event.name === "escape") {
+          event.preventDefault()
+          props.onCancel()
+          return
+        }
+
+        if (event.name === "return" && !event.meta && !event.ctrl && !event.shift) {
+          event.preventDefault()
+          props.onConfirm()
+        }
+      }}
+      ref={(item) => {
+        area = item as RejectArea
+      }}
+    />
+  )
+}
+
+export function RunPermissionBody(props: {
+  request: PermissionRequest
+  theme: RunFooterTheme
+  block: RunBlockTheme
+  diffStyle?: RunDiffStyle
+  onReply: (input: PermissionReply) => void | Promise<void>
+}) {
+  const dims = useTerminalDimensions()
+  const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
+  const info = createMemo(() => permissionInfo(props.request))
+  const ft = createMemo(() => toolFiletype(info().file))
+  const view = createMemo(() => toolDiffView(dims().width, props.diffStyle))
+  const narrow = createMemo(() => dims().width < 80)
+  const opts = createMemo(() => permissionOptions(state().stage))
+  const busy = createMemo(() => state().submitting)
+  const title = createMemo(() => {
+    if (state().stage === "always") {
+      return "Always allow"
+    }
+
+    if (state().stage === "reject") {
+      return "Reject permission"
+    }
+
+    return "Permission required"
+  })
+
+  createEffect(() => {
+    const id = props.request.id
+    if (state().requestID === id) {
+      return
+    }
+
+    setState(createPermissionBodyState(id))
+  })
+
+  const shift = (dir: -1 | 1) => {
+    setState((prev) => permissionShift(prev, dir))
+  }
+
+  const submit = async (next: PermissionReply) => {
+    setState((prev) => ({
+      ...prev,
+      submitting: true,
+    }))
+
+    try {
+      await props.onReply(next)
+    } catch {
+      setState((prev) => ({
+        ...prev,
+        submitting: false,
+      }))
+    }
+  }
+
+  const run = (option: PermissionOption) => {
+    const cur = state()
+    const next = permissionRun(cur, props.request.id, option)
+    if (next.state !== cur) {
+      setState(next.state)
+    }
+
+    if (!next.reply) {
+      return
+    }
+
+    void submit(next.reply)
+  }
+
+  const reject = () => {
+    const next = permissionReject(state(), props.request.id)
+    if (!next) {
+      return
+    }
+
+    void submit(next)
+  }
+
+  const cancelReject = () => {
+    setState((prev) => permissionCancel(prev))
+  }
+
+  useKeyboard((event) => {
+    const cur = state()
+    if (cur.stage === "reject") {
+      return
+    }
+
+    if (cur.submitting) {
+      if (["left", "right", "h", "l", "tab", "return", "escape"].includes(event.name)) {
+        event.preventDefault()
+      }
+      return
+    }
+
+    if (event.name === "tab") {
+      shift(event.shift ? -1 : 1)
+      event.preventDefault()
+      return
+    }
+
+    if (event.name === "left" || event.name === "h") {
+      shift(-1)
+      event.preventDefault()
+      return
+    }
+
+    if (event.name === "right" || event.name === "l") {
+      shift(1)
+      event.preventDefault()
+      return
+    }
+
+    if (event.name === "return") {
+      run(state().selected)
+      event.preventDefault()
+      return
+    }
+
+    if (event.name !== "escape") {
+      return
+    }
+
+    setState((prev) => permissionEscape(prev))
+    event.preventDefault()
+  })
+
+  return (
+    <box id="run-direct-footer-permission-body" width="100%" height="100%" flexDirection="column">
+      <box
+        id="run-direct-footer-permission-head"
+        flexDirection="column"
+        gap={1}
+        paddingLeft={1}
+        paddingRight={2}
+        paddingTop={1}
+        paddingBottom={1}
+        flexShrink={0}
+      >
+        <box flexDirection="row" gap={1} paddingLeft={1}>
+          <text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>△</text>
+          <text fg={props.theme.text}>{title()}</text>
+        </box>
+        <Switch>
+          <Match when={state().stage === "permission"}>
+            <box flexDirection="row" gap={1} paddingLeft={2}>
+              <text fg={props.theme.muted} flexShrink={0}>
+                {info().icon}
+              </text>
+              <text fg={props.theme.text} wrapMode="word">
+                {info().title}
+              </text>
+            </box>
+          </Match>
+          <Match when={state().stage === "reject"}>
+            <box paddingLeft={1}>
+              <text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
+            </box>
+          </Match>
+        </Switch>
+      </box>
+
+      <Show
+        when={state().stage !== "reject"}
+        fallback={
+          <box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
+            <box
+              id="run-direct-footer-permission-reject-bar"
+              flexDirection={narrow() ? "column" : "row"}
+              flexShrink={0}
+              backgroundColor={props.theme.line}
+              paddingTop={1}
+              paddingLeft={2}
+              paddingRight={3}
+              paddingBottom={1}
+              justifyContent={narrow() ? "flex-start" : "space-between"}
+              alignItems={narrow() ? "flex-start" : "center"}
+              gap={1}
+            >
+              <box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
+                <RejectField
+                  theme={props.theme}
+                  text={state().message}
+                  disabled={busy()}
+                  onChange={(text) => {
+                    setState((prev) => ({
+                      ...prev,
+                      message: text,
+                    }))
+                  }}
+                  onConfirm={reject}
+                  onCancel={cancelReject}
+                />
+              </box>
+              <Show
+                when={!busy()}
+                fallback={
+                  <text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
+                    Waiting for permission event...
+                  </text>
+                }
+              >
+                <box flexDirection="row" gap={2} flexShrink={0} paddingBottom={1}>
+                  <text fg={props.theme.text}>
+                    enter <span style={{ fg: props.theme.muted }}>confirm</span>
+                  </text>
+                  <text fg={props.theme.text}>
+                    esc <span style={{ fg: props.theme.muted }}>cancel</span>
+                  </text>
+                </box>
+              </Show>
+            </box>
+          </box>
+        }
+      >
+        <box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
+          <Switch>
+            <Match when={state().stage === "permission"}>
+              <scrollbox
+                width="100%"
+                height="100%"
+                verticalScrollbarOptions={{
+                  trackOptions: {
+                    backgroundColor: props.theme.surface,
+                    foregroundColor: props.theme.line,
+                  },
+                }}
+              >
+                <box width="100%" flexDirection="column" gap={1}>
+                  <Show
+                    when={info().diff}
+                    fallback={
+                      <box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
+                        <For each={info().lines}>
+                          {(line) => (
+                            <text fg={props.theme.text} wrapMode="word">
+                              {line}
+                            </text>
+                          )}
+                        </For>
+                      </box>
+                    }
+                  >
+                    <diff
+                      diff={info().diff!}
+                      view={view()}
+                      filetype={ft()}
+                      syntaxStyle={props.block.syntax}
+                      showLineNumbers={true}
+                      width="100%"
+                      wrapMode="word"
+                      fg={props.theme.text}
+                      addedBg={props.block.diffAddedBg}
+                      removedBg={props.block.diffRemovedBg}
+                      contextBg={props.block.diffContextBg}
+                      addedSignColor={props.block.diffHighlightAdded}
+                      removedSignColor={props.block.diffHighlightRemoved}
+                      lineNumberFg={props.block.diffLineNumber}
+                      lineNumberBg={props.block.diffContextBg}
+                      addedLineNumberBg={props.block.diffAddedLineNumberBg}
+                      removedLineNumberBg={props.block.diffRemovedLineNumberBg}
+                    />
+                  </Show>
+                  <Show when={!info().diff && info().lines.length === 0}>
+                    <box paddingLeft={1}>
+                      <text fg={props.theme.muted}>No diff provided</text>
+                    </box>
+                  </Show>
+                </box>
+              </scrollbox>
+            </Match>
+            <Match when={true}>
+              <scrollbox
+                width="100%"
+                height="100%"
+                verticalScrollbarOptions={{
+                  trackOptions: {
+                    backgroundColor: props.theme.surface,
+                    foregroundColor: props.theme.line,
+                  },
+                }}
+              >
+                <box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
+                  <For each={permissionAlwaysLines(props.request)}>
+                    {(line) => (
+                      <text fg={props.theme.text} wrapMode="word">
+                        {line}
+                      </text>
+                    )}
+                  </For>
+                </box>
+              </scrollbox>
+            </Match>
+          </Switch>
+        </box>
+
+        <box
+          id="run-direct-footer-permission-actions"
+          flexDirection={narrow() ? "column" : "row"}
+          flexShrink={0}
+          backgroundColor={props.theme.pane}
+          gap={1}
+          paddingTop={1}
+          paddingLeft={2}
+          paddingRight={3}
+          paddingBottom={1}
+          justifyContent={narrow() ? "flex-start" : "space-between"}
+          alignItems={narrow() ? "flex-start" : "center"}
+        >
+          {buttons(
+            opts(),
+            state().selected,
+            props.theme,
+            busy(),
+            (option) => {
+              setState((prev) => permissionHover(prev, option))
+            },
+            run,
+          )}
+          <Show
+            when={!busy()}
+            fallback={
+              <text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
+                Waiting for permission event...
+              </text>
+            }
+          >
+            <box flexDirection="row" gap={2} flexShrink={0} paddingBottom={1}>
+              <text fg={props.theme.text}>
+                {"⇆"} <span style={{ fg: props.theme.muted }}>select</span>
+              </text>
+              <text fg={props.theme.text}>
+                enter <span style={{ fg: props.theme.muted }}>confirm</span>
+              </text>
+              <text fg={props.theme.text}>
+                esc <span style={{ fg: props.theme.muted }}>{state().stage === "always" ? "cancel" : "reject"}</span>
+              </text>
+            </box>
+          </Show>
+        </box>
+      </Show>
+    </box>
+  )
+}

+ 471 - 0
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

@@ -0,0 +1,471 @@
+// Prompt textarea component and its state machine for direct interactive mode.
+//
+// createPromptState() wires keybinds, history navigation, leader-key sequences
+// for variant cycling, and the submit/interrupt/exit flow. It produces a
+// PromptState that RunPromptBody renders as an OpenTUI textarea.
+//
+// The leader-key pattern: press the leader key (default ctrl+x), then press
+// "t" within 2 seconds to cycle the model variant. This mirrors vim-style
+// two-key sequences. The timer auto-clears if the second key doesn't arrive.
+//
+// History uses arrow keys at cursor boundaries: up at offset 0 scrolls back,
+// down at end-of-text scrolls forward, restoring the draft when you return
+// past the end of history.
+/** @jsxImportSource @opentui/solid */
+import { StyledText, bg, fg, type KeyBinding } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
+import {
+  createPromptHistory,
+  isExitCommand,
+  movePromptHistory,
+  promptCycle,
+  promptHit,
+  promptInfo,
+  promptKeys,
+  pushPromptHistory,
+} from "./prompt.shared"
+import type { FooterKeybinds, FooterState } from "./types"
+import type { RunFooterTheme } from "./theme"
+
+const LEADER_TIMEOUT_MS = 2000
+
+export const TEXTAREA_MIN_ROWS = 1
+export const TEXTAREA_MAX_ROWS = 6
+
+export const HINT_BREAKPOINTS = {
+  send: 50,
+  newline: 66,
+  history: 80,
+  variant: 95,
+}
+
+type Area = {
+  isDestroyed: boolean
+  virtualLineCount: number
+  visualCursor: {
+    visualRow: number
+  }
+  plainText: string
+  cursorOffset: number
+  height?: number
+  setText(text: string): void
+  focus(): void
+  on(event: string, fn: () => void): void
+  off(event: string, fn: () => void): void
+}
+
+type Key = {
+  name: string
+  ctrl?: boolean
+  meta?: boolean
+  shift?: boolean
+  super?: boolean
+  hyper?: boolean
+  preventDefault(): void
+}
+
+type PromptInput = {
+  keybinds: FooterKeybinds
+  state: Accessor<FooterState>
+  view: Accessor<string>
+  prompt: Accessor<boolean>
+  width: Accessor<number>
+  theme: Accessor<RunFooterTheme>
+  history?: string[]
+  onSubmit: (text: string) => boolean
+  onCycle: () => void
+  onInterrupt: () => boolean
+  onExitRequest?: () => boolean
+  onExit: () => void
+  onRows: (rows: number) => void
+  onStatus: (text: string) => void
+}
+
+export type PromptState = {
+  placeholder: Accessor<StyledText | string>
+  bindings: Accessor<KeyBinding[]>
+  onSubmit: () => void
+  onKeyDown: (event: Key) => void
+  onContentChange: () => void
+  bind: (area?: Area) => void
+}
+
+function clamp(rows: number): number {
+  return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
+}
+
+export function hintFlags(width: number) {
+  return {
+    send: width >= HINT_BREAKPOINTS.send,
+    newline: width >= HINT_BREAKPOINTS.newline,
+    history: width >= HINT_BREAKPOINTS.history,
+    variant: width >= HINT_BREAKPOINTS.variant,
+  }
+}
+
+export function RunPromptBody(props: {
+  theme: () => RunFooterTheme
+  placeholder: () => StyledText | string
+  bindings: () => KeyBinding[]
+  onSubmit: () => void
+  onKeyDown: (event: Key) => void
+  onContentChange: () => void
+  bind: (area?: Area) => void
+}) {
+  let item: Area | undefined
+
+  onMount(() => {
+    props.bind(item)
+  })
+
+  onCleanup(() => {
+    props.bind(undefined)
+  })
+
+  return (
+    <box id="run-direct-footer-prompt"
+      paddingTop={1}
+      paddingLeft={2}
+      paddingRight={2}
+    >
+      <textarea
+        id="run-direct-footer-composer"
+        width="100%"
+        minHeight={TEXTAREA_MIN_ROWS}
+        maxHeight={TEXTAREA_MAX_ROWS}
+        wrapMode="word"
+        placeholder={props.placeholder()}
+        placeholderColor={props.theme().muted}
+        textColor={props.theme().text}
+        focusedTextColor={props.theme().text}
+
+        backgroundColor={props.theme().surface}
+        focusedBackgroundColor={props.theme().surface}
+        cursorColor={props.theme().text}
+        keyBindings={props.bindings()}
+        onSubmit={props.onSubmit}
+        onKeyDown={props.onKeyDown}
+        onContentChange={props.onContentChange}
+        ref={(next) => {
+          item = next as Area
+        }}
+      />
+    </box>
+  )
+}
+
+export function createPromptState(input: PromptInput): PromptState {
+  const keys = createMemo(() => promptKeys(input.keybinds))
+  const bindings = createMemo(() => keys().bindings)
+  const [draft, setDraft] = createSignal("")
+  const placeholder = createMemo(() => {
+    if (!input.state().first) {
+      return ""
+    }
+
+    return new StyledText([
+      bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
+    ])
+  })
+
+  let history = createPromptHistory(input.history)
+
+  let area: Area | undefined
+  let leader = false
+  let timeout: NodeJS.Timeout | undefined
+  let tick = false
+  let prev = input.view()
+
+  const clear = () => {
+    leader = false
+    if (!timeout) {
+      return
+    }
+
+    clearTimeout(timeout)
+    timeout = undefined
+  }
+
+  const arm = () => {
+    clear()
+    leader = true
+    timeout = setTimeout(() => {
+      clear()
+    }, LEADER_TIMEOUT_MS)
+  }
+
+  const syncRows = () => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    input.onRows(clamp(area.virtualLineCount || 1))
+  }
+
+  const scheduleRows = () => {
+    if (tick) {
+      return
+    }
+
+    tick = true
+    queueMicrotask(() => {
+      tick = false
+      syncRows()
+    })
+  }
+
+  const bind = (next?: Area) => {
+    if (area === next) {
+      return
+    }
+
+    if (area && !area.isDestroyed) {
+      area.off("line-info-change", scheduleRows)
+    }
+
+    area = next
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    area.on("line-info-change", scheduleRows)
+    queueMicrotask(() => {
+      if (!area || area.isDestroyed || !input.prompt()) {
+        return
+      }
+
+      if (area.plainText !== draft()) {
+        area.setText(draft())
+      }
+
+      area.cursorOffset = area.plainText.length
+      scheduleRows()
+      area.focus()
+    })
+  }
+
+  const syncDraft = () => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    setDraft(area.plainText)
+  }
+
+  const push = (text: string) => {
+    history = pushPromptHistory(history, text)
+  }
+
+  const move = (dir: -1 | 1, event: Key) => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
+    if (!next.apply || next.text === undefined || next.cursor === undefined) {
+      return
+    }
+
+    history = next.state
+    area.setText(next.text)
+    area.cursorOffset = next.cursor
+    event.preventDefault()
+    syncRows()
+  }
+
+  const cycle = (event: Key): boolean => {
+    const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
+    if (!next.consume) {
+      return false
+    }
+
+    if (next.clear) {
+      clear()
+    }
+
+    if (next.arm) {
+      arm()
+    }
+
+    if (next.cycle) {
+      input.onCycle()
+    }
+
+    event.preventDefault()
+    return true
+  }
+
+  const onKeyDown = (event: Key) => {
+    if (event.ctrl && event.name === "c") {
+      const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
+      if (handled) {
+        event.preventDefault()
+      }
+      return
+    }
+
+    const key = promptInfo(event)
+    if (promptHit(keys().interrupts, key)) {
+      if (input.onInterrupt()) {
+        event.preventDefault()
+        return
+      }
+    }
+
+    if (cycle(event)) {
+      return
+    }
+
+    const up = promptHit(keys().previous, key)
+    const down = promptHit(keys().next, key)
+    if (!up && !down) {
+      return
+    }
+
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    const dir = up ? -1 : 1
+    if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
+      move(dir, event)
+      return
+    }
+
+    if (dir === -1 && area.visualCursor.visualRow === 0) {
+      area.cursorOffset = 0
+    }
+
+    const end =
+      typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
+        ? area.height - 1
+        : Math.max(0, area.virtualLineCount - 1)
+    if (dir === 1 && area.visualCursor.visualRow === end) {
+      area.cursorOffset = area.plainText.length
+    }
+  }
+
+  useKeyboard((event) => {
+    if (input.prompt()) {
+      return
+    }
+
+    if (event.ctrl && event.name === "c") {
+      const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
+      if (handled) {
+        event.preventDefault()
+      }
+    }
+  })
+
+  const onSubmit = () => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    const text = area.plainText.trim()
+    if (!text) {
+      input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
+      return
+    }
+
+    if (isExitCommand(text)) {
+      input.onExit()
+      return
+    }
+
+    area.setText("")
+    setDraft("")
+    scheduleRows()
+    area.focus()
+    queueMicrotask(() => {
+      if (input.onSubmit(text)) {
+        push(text)
+        return
+      }
+
+      if (!area || area.isDestroyed) {
+        return
+      }
+
+      area.setText(text)
+      setDraft(text)
+      area.cursorOffset = area.plainText.length
+      syncRows()
+      area.focus()
+    })
+  }
+
+  onCleanup(() => {
+    clear()
+    if (area && !area.isDestroyed) {
+      area.off("line-info-change", scheduleRows)
+    }
+  })
+
+  createEffect(() => {
+    input.width()
+    if (input.prompt()) {
+      scheduleRows()
+    }
+  })
+
+  createEffect(() => {
+    input.state().phase
+    if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
+      return
+    }
+
+    queueMicrotask(() => {
+      if (!area || area.isDestroyed) {
+        return
+      }
+
+      area.focus()
+    })
+  })
+
+  createEffect(() => {
+    const type = input.view()
+    if (type === prev) {
+      return
+    }
+
+    if (prev === "prompt") {
+      syncDraft()
+    }
+
+    clear()
+    prev = type
+    if (type !== "prompt") {
+      return
+    }
+
+    queueMicrotask(() => {
+      if (!area || area.isDestroyed) {
+        return
+      }
+
+      if (area.plainText !== draft()) {
+        area.setText(draft())
+      }
+
+      area.cursorOffset = area.plainText.length
+      scheduleRows()
+      area.focus()
+    })
+  })
+
+  return {
+    placeholder,
+    bindings,
+    onSubmit,
+    onKeyDown,
+    onContentChange: () => {
+      syncDraft()
+      scheduleRows()
+    },
+    bind,
+  }
+}

+ 596 - 0
packages/opencode/src/cli/cmd/run/footer.question.tsx

@@ -0,0 +1,596 @@
+// Question UI body for the direct-mode footer.
+//
+// Renders inside the footer when the reducer pushes a FooterView of type
+// "question". Supports single-question and multi-question flows:
+//
+//   Single question: options list with up/down selection, digit shortcuts,
+//   and optional custom text input.
+//
+//   Multi-question: tabbed interface where each question is a tab, plus a
+//   final "Confirm" tab that shows all answers for review. Tab/shift-tab
+//   or left/right to navigate between questions.
+//
+// All state logic lives in question.shared.ts as a pure state machine.
+// This component just renders it and dispatches keyboard events.
+/** @jsxImportSource @opentui/solid */
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
+import type { QuestionRequest } from "@opencode-ai/sdk/v2"
+import {
+  createQuestionBodyState,
+  questionConfirm,
+  questionCustom,
+  questionInfo,
+  questionInput,
+  questionMove,
+  questionOther,
+  questionPicked,
+  questionReject,
+  questionSave,
+  questionSelect,
+  questionSetEditing,
+  questionSetSelected,
+  questionSetSubmitting,
+  questionSetTab,
+  questionSingle,
+  questionStoreCustom,
+  questionSubmit,
+  questionSync,
+  questionTabs,
+  questionTotal,
+} from "./question.shared"
+import type { RunFooterTheme } from "./theme"
+import type { QuestionReject, QuestionReply } from "./types"
+
+type Area = {
+  isDestroyed: boolean
+  plainText: string
+  cursorOffset: number
+  setText(text: string): void
+  focus(): void
+}
+
+export function RunQuestionBody(props: {
+  request: QuestionRequest
+  theme: RunFooterTheme
+  onReply: (input: QuestionReply) => void | Promise<void>
+  onReject: (input: QuestionReject) => void | Promise<void>
+}) {
+  const dims = useTerminalDimensions()
+  const [state, setState] = createSignal(createQuestionBodyState(props.request.id))
+  const single = createMemo(() => questionSingle(props.request))
+  const confirm = createMemo(() => questionConfirm(props.request, state()))
+  const info = createMemo(() => questionInfo(props.request, state()))
+  const input = createMemo(() => questionInput(state()))
+  const other = createMemo(() => questionOther(props.request, state()))
+  const picked = createMemo(() => questionPicked(state()))
+  const disabled = createMemo(() => state().submitting)
+  const narrow = createMemo(() => dims().width < 80)
+  const verb = createMemo(() => {
+    if (confirm()) {
+      return "submit"
+    }
+
+    if (info()?.multiple) {
+      return "toggle"
+    }
+
+    if (single()) {
+      return "submit"
+    }
+
+    return "confirm"
+  })
+  let area: Area | undefined
+
+  createEffect(() => {
+    setState((prev) => questionSync(prev, props.request.id))
+  })
+
+  const setTab = (tab: number) => {
+    setState((prev) => questionSetTab(prev, tab))
+  }
+
+  const move = (dir: -1 | 1) => {
+    setState((prev) => questionMove(prev, props.request, dir))
+  }
+
+  const beginReply = async (input: QuestionReply) => {
+    setState((prev) => questionSetSubmitting(prev, true))
+
+    try {
+      await props.onReply(input)
+    } catch {
+      setState((prev) => questionSetSubmitting(prev, false))
+    }
+  }
+
+  const beginReject = async (input: QuestionReject) => {
+    setState((prev) => questionSetSubmitting(prev, true))
+
+    try {
+      await props.onReject(input)
+    } catch {
+      setState((prev) => questionSetSubmitting(prev, false))
+    }
+  }
+
+  const saveCustom = () => {
+    const cur = state()
+    const next = questionSave(cur, props.request)
+    if (next.state !== cur) {
+      setState(next.state)
+    }
+
+    if (!next.reply) {
+      return
+    }
+
+    void beginReply(next.reply)
+  }
+
+  const choose = (selected: number) => {
+    const base = state()
+    const cur = questionSetSelected(base, selected)
+    const next = questionSelect(cur, props.request)
+    if (next.state !== base) {
+      setState(next.state)
+    }
+
+    if (!next.reply) {
+      return
+    }
+
+    void beginReply(next.reply)
+  }
+
+  const mark = (selected: number) => {
+    setState((prev) => questionSetSelected(prev, selected))
+  }
+
+  const select = () => {
+    const cur = state()
+    const next = questionSelect(cur, props.request)
+    if (next.state !== cur) {
+      setState(next.state)
+    }
+
+    if (!next.reply) {
+      return
+    }
+
+    void beginReply(next.reply)
+  }
+
+  const submit = () => {
+    void beginReply(questionSubmit(props.request, state()))
+  }
+
+  const reject = () => {
+    void beginReject(questionReject(props.request))
+  }
+
+  useKeyboard((event) => {
+    const cur = state()
+    if (cur.submitting) {
+      event.preventDefault()
+      return
+    }
+
+    if (cur.editing) {
+      if (event.name === "escape") {
+        setState((prev) => questionSetEditing(prev, false))
+        event.preventDefault()
+        return
+      }
+
+      if (event.name === "return" && !event.shift && !event.ctrl && !event.meta) {
+        saveCustom()
+        event.preventDefault()
+      }
+      return
+    }
+
+    if (!single() && (event.name === "left" || event.name === "h")) {
+      setTab((cur.tab - 1 + questionTabs(props.request)) % questionTabs(props.request))
+      event.preventDefault()
+      return
+    }
+
+    if (!single() && (event.name === "right" || event.name === "l")) {
+      setTab((cur.tab + 1) % questionTabs(props.request))
+      event.preventDefault()
+      return
+    }
+
+    if (!single() && event.name === "tab") {
+      const dir = event.shift ? -1 : 1
+      setTab((cur.tab + dir + questionTabs(props.request)) % questionTabs(props.request))
+      event.preventDefault()
+      return
+    }
+
+    if (questionConfirm(props.request, cur)) {
+      if (event.name === "return") {
+        submit()
+        event.preventDefault()
+        return
+      }
+
+      if (event.name === "escape") {
+        reject()
+        event.preventDefault()
+      }
+      return
+    }
+
+    const total = questionTotal(props.request, cur)
+    const max = Math.min(total, 9)
+    const digit = Number(event.name)
+    if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
+      choose(digit - 1)
+      event.preventDefault()
+      return
+    }
+
+    if (event.name === "up" || event.name === "k") {
+      move(-1)
+      event.preventDefault()
+      return
+    }
+
+    if (event.name === "down" || event.name === "j") {
+      move(1)
+      event.preventDefault()
+      return
+    }
+
+    if (event.name === "return") {
+      select()
+      event.preventDefault()
+      return
+    }
+
+    if (event.name === "escape") {
+      reject()
+      event.preventDefault()
+    }
+  })
+
+  createEffect(() => {
+    if (!state().editing || !area || area.isDestroyed) {
+      return
+    }
+
+    if (area.plainText !== input()) {
+      area.setText(input())
+      area.cursorOffset = input().length
+    }
+
+    queueMicrotask(() => {
+      if (!area || area.isDestroyed || !state().editing) {
+        return
+      }
+
+      area.focus()
+      area.cursorOffset = area.plainText.length
+    })
+  })
+
+  return (
+    <box id="run-direct-footer-question-body" width="100%" height="100%" flexDirection="column">
+      <box
+        id="run-direct-footer-question-panel"
+        flexDirection="column"
+        gap={1}
+        paddingLeft={1}
+        paddingRight={3}
+        paddingTop={1}
+        marginBottom={1}
+        flexGrow={1}
+        flexShrink={1}
+        backgroundColor={props.theme.surface}
+      >
+        <Show when={!single()}>
+          <box id="run-direct-footer-question-tabs"
+            flexDirection="row"
+            gap={1}
+            paddingLeft={1}
+            flexShrink={0}
+          >
+            <For each={props.request.questions}>
+              {(item, index) => {
+                const active = () => state().tab === index()
+                const answered = () => (state().answers[index()]?.length ?? 0) > 0
+                return (
+                  <box
+                    id={`run-direct-footer-question-tab-${index()}`}
+                    paddingLeft={1}
+                    paddingRight={1}
+                    backgroundColor={active() ? props.theme.highlight : props.theme.surface}
+                    onMouseUp={() => {
+                      if (!disabled()) setTab(index())
+                    }}
+                  >
+                    <text fg={active() ? props.theme.surface : answered() ? props.theme.text : props.theme.muted}>
+                      {item.header}
+                    </text>
+                  </box>
+                )
+              }}
+            </For>
+            <box
+              id="run-direct-footer-question-tab-confirm"
+              paddingLeft={1}
+              paddingRight={1}
+              backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
+              onMouseUp={() => {
+                if (!disabled()) setTab(props.request.questions.length)
+              }}
+            >
+              <text fg={confirm() ? props.theme.surface : props.theme.muted}>Confirm</text>
+            </box>
+          </box>
+        </Show>
+
+        <Show
+          when={!confirm()}
+          fallback={
+            <box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1}>
+              <scrollbox
+                width="100%"
+                height="100%"
+                verticalScrollbarOptions={{
+                  trackOptions: {
+                    backgroundColor: props.theme.surface,
+                    foregroundColor: props.theme.line,
+                  },
+                }}
+              >
+                <box width="100%" flexDirection="column" gap={1}>
+                  <box paddingLeft={1}>
+                    <text fg={props.theme.text}>Review</text>
+                  </box>
+                  <For each={props.request.questions}>
+                    {(item, index) => {
+                      const value = () => state().answers[index()]?.join(", ") ?? ""
+                      const answered = () => Boolean(value())
+                      return (
+                        <box paddingLeft={1}>
+                          <text wrapMode="word">
+                            <span style={{ fg: props.theme.muted }}>{item.header}:</span>{" "}
+                            <span style={{ fg: answered() ? props.theme.text : props.theme.error }}>
+                              {answered() ? value() : "(not answered)"}
+                            </span>
+                          </text>
+                        </box>
+                      )
+                    }}
+                  </For>
+                </box>
+              </scrollbox>
+            </box>
+          }
+        >
+          <box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} gap={1}>
+            <box>
+              <text fg={props.theme.text} wrapMode="word">
+                {info()?.question}
+                {info()?.multiple ? " (select all that apply)" : ""}
+              </text>
+            </box>
+
+            <box flexGrow={1} flexShrink={1}>
+              <scrollbox
+                width="100%"
+                height="100%"
+                verticalScrollbarOptions={{
+                  trackOptions: {
+                    backgroundColor: props.theme.surface,
+                    foregroundColor: props.theme.line,
+                  },
+                }}
+              >
+                <box width="100%" flexDirection="column">
+                  <For each={info()?.options ?? []}>
+                    {(item, index) => {
+                      const active = () => state().selected === index()
+                      const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
+                      return (
+                        <box
+                          id={`run-direct-footer-question-option-${index()}`}
+                          flexDirection="column"
+                          gap={0}
+                          onMouseOver={() => {
+                            if (!disabled()) {
+                              mark(index())
+                            }
+                          }}
+                          onMouseDown={() => {
+                            if (!disabled()) {
+                              mark(index())
+                            }
+                          }}
+                          onMouseUp={() => {
+                            if (!disabled()) {
+                              choose(index())
+                            }
+                          }}
+                        >
+                          <box flexDirection="row">
+                            <box backgroundColor={active() ? props.theme.line : undefined} paddingRight={1}>
+                              <text fg={active() ? props.theme.highlight : props.theme.muted}>{`${index() + 1}.`}</text>
+                            </box>
+                            <box backgroundColor={active() ? props.theme.line : undefined}>
+                              <text
+                                fg={active() ? props.theme.highlight : hit() ? props.theme.success : props.theme.text}
+                              >
+                                {info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label}
+                              </text>
+                            </box>
+                            <Show when={!info()?.multiple}>
+                              <text fg={props.theme.success}>{hit() ? "✓" : ""}</text>
+                            </Show>
+                          </box>
+                          <box paddingLeft={3}>
+                            <text fg={props.theme.muted} wrapMode="word">
+                              {item.description}
+                            </text>
+                          </box>
+                        </box>
+                      )
+                    }}
+                  </For>
+
+                  <Show when={questionCustom(props.request, state())}>
+                    <box
+                      id="run-direct-footer-question-option-custom"
+                      flexDirection="column"
+                      gap={0}
+                      onMouseOver={() => {
+                        if (!disabled()) {
+                          mark(info()?.options.length ?? 0)
+                        }
+                      }}
+                      onMouseDown={() => {
+                        if (!disabled()) {
+                          mark(info()?.options.length ?? 0)
+                        }
+                      }}
+                      onMouseUp={() => {
+                        if (!disabled()) {
+                          choose(info()?.options.length ?? 0)
+                        }
+                      }}
+                    >
+                      <box flexDirection="row">
+                        <box backgroundColor={other() ? props.theme.line : undefined} paddingRight={1}>
+                          <text
+                            fg={other() ? props.theme.highlight : props.theme.muted}
+                          >{`${(info()?.options.length ?? 0) + 1}.`}</text>
+                        </box>
+                        <box backgroundColor={other() ? props.theme.line : undefined}>
+                          <text
+                            fg={other() ? props.theme.highlight : picked() ? props.theme.success : props.theme.text}
+                          >
+                            {info()?.multiple
+                              ? `[${picked() ? "✓" : " "}] Type your own answer`
+                              : "Type your own answer"}
+                          </text>
+                        </box>
+                        <Show when={!info()?.multiple}>
+                          <text fg={props.theme.success}>{picked() ? "✓" : ""}</text>
+                        </Show>
+                      </box>
+                      <Show
+                        when={state().editing}
+                        fallback={
+                          <Show when={input()}>
+                            <box paddingLeft={3}>
+                              <text fg={props.theme.muted} wrapMode="word">
+                                {input()}
+                              </text>
+                            </box>
+                          </Show>
+                        }
+                      >
+                        <box paddingLeft={3}>
+                          <textarea
+                            id="run-direct-footer-question-custom"
+                            width="100%"
+                            minHeight={1}
+                            maxHeight={4}
+                            wrapMode="word"
+                            placeholder="Type your own answer"
+                            placeholderColor={props.theme.muted}
+                            textColor={props.theme.text}
+                            focusedTextColor={props.theme.text}
+                            backgroundColor={props.theme.surface}
+                            focusedBackgroundColor={props.theme.surface}
+                            cursorColor={props.theme.text}
+                            focused={!disabled()}
+                            onContentChange={() => {
+                              if (!area || area.isDestroyed || disabled()) {
+                                return
+                              }
+
+                              const text = area.plainText
+                              setState((prev) => questionStoreCustom(prev, prev.tab, text))
+                            }}
+                            ref={(item) => {
+                              area = item as Area
+                            }}
+                          />
+                        </box>
+                      </Show>
+                    </box>
+                  </Show>
+                </box>
+              </scrollbox>
+            </box>
+          </box>
+        </Show>
+      </box>
+
+      <box
+        id="run-direct-footer-question-actions"
+        flexDirection={narrow() ? "column" : "row"}
+        flexShrink={0}
+        gap={1}
+        paddingLeft={2}
+        paddingRight={3}
+        paddingBottom={1}
+        justifyContent={narrow() ? "flex-start" : "space-between"}
+        alignItems={narrow() ? "flex-start" : "center"}
+      >
+        <Show
+          when={!disabled()}
+          fallback={
+            <text fg={props.theme.muted} wrapMode="word">
+              Waiting for question event...
+            </text>
+          }
+        >
+          <box
+            flexDirection={narrow() ? "column" : "row"}
+            gap={narrow() ? 1 : 2}
+            flexShrink={0}
+            paddingBottom={1}
+            width={narrow() ? "100%" : undefined}
+          >
+            <Show
+              when={!state().editing}
+              fallback={
+                <>
+                  <text fg={props.theme.text}>
+                    enter <span style={{ fg: props.theme.muted }}>save</span>
+                  </text>
+                  <text fg={props.theme.text}>
+                    esc <span style={{ fg: props.theme.muted }}>cancel</span>
+                  </text>
+                </>
+              }
+            >
+              <Show when={!single()}>
+                <text fg={props.theme.text}>
+                  {"⇆"} <span style={{ fg: props.theme.muted }}>tab</span>
+                </text>
+              </Show>
+              <Show when={!confirm()}>
+                <text fg={props.theme.text}>
+                  {"↑↓"} <span style={{ fg: props.theme.muted }}>select</span>
+                </text>
+              </Show>
+              <text fg={props.theme.text}>
+                enter <span style={{ fg: props.theme.muted }}>{verb()}</span>
+              </text>
+              <text fg={props.theme.text}>
+                esc <span style={{ fg: props.theme.muted }}>dismiss</span>
+              </text>
+            </Show>
+          </box>
+        </Show>
+      </box>
+    </box>
+  )
+}

+ 626 - 0
packages/opencode/src/cli/cmd/run/footer.ts

@@ -0,0 +1,626 @@
+// RunFooter -- the mutable control surface for direct interactive mode.
+//
+// In the split-footer architecture, scrollback is immutable (append-only)
+// and the footer is the only region that can repaint. RunFooter owns both
+// sides of that boundary:
+//
+//   Scrollback: append() queues StreamCommit entries and flush() writes them
+//   to the renderer via writeToScrollback(). Commits coalesce in a microtask
+//   queue -- consecutive progress chunks for the same part merge into one
+//   write to avoid excessive scrollback snapshots.
+//
+//   Footer: event() updates the SolidJS signal-backed FooterState, which
+//   drives the reactive footer view (prompt, status, permission, question).
+//   present() swaps the active footer view and resizes the footer region.
+//
+// Lifecycle:
+//   - close() flushes pending commits and notifies listeners (the prompt
+//     queue uses this to know when to stop).
+//   - destroy() does the same plus tears down event listeners and clears
+//     internal state.
+//   - The renderer's DESTROY event triggers destroy() so the footer
+//     doesn't outlive the renderer.
+//
+// Interrupt and exit use a two-press pattern: first press shows a hint,
+// second press within 5 seconds actually fires the action.
+import { CliRenderEvents, type CliRenderer } from "@opentui/core"
+import { render } from "@opentui/solid"
+import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
+import { TEXTAREA_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
+import { printableBinding } from "./prompt.shared"
+import { RunFooterView } from "./footer.view"
+import { normalizeEntry } from "./scrollback.format"
+import { entryWriter } from "./scrollback"
+import { spacerWriter } from "./scrollback.writer"
+import { toolView } from "./tool"
+import type { RunTheme } from "./theme"
+import type {
+  FooterApi,
+  FooterEvent,
+  FooterKeybinds,
+  FooterPatch,
+  FooterState,
+  FooterView,
+  PermissionReply,
+  QuestionReject,
+  QuestionReply,
+  RunDiffStyle,
+  StreamCommit,
+} from "./types"
+
+type CycleResult = {
+  modelLabel?: string
+  status?: string
+}
+
+type RunFooterOptions = {
+  agentLabel: string
+  modelLabel: string
+  first: boolean
+  history?: string[]
+  theme: RunTheme
+  keybinds: FooterKeybinds
+  diffStyle: RunDiffStyle
+  onPermissionReply: (input: PermissionReply) => void | Promise<void>
+  onQuestionReply: (input: QuestionReply) => void | Promise<void>
+  onQuestionReject: (input: QuestionReject) => void | Promise<void>
+  onCycleVariant?: () => CycleResult | void
+  onInterrupt?: () => void
+  onExit?: () => void
+}
+
+const PERMISSION_ROWS = 12
+const QUESTION_ROWS = 14
+
+
+export class RunFooter implements FooterApi {
+  private closed = false
+  private destroyed = false
+  private prompts = new Set<(text: string) => void>()
+  private closes = new Set<() => void>()
+  // Most recent visible scrollback commit.
+  private tail: StreamCommit | undefined
+  // The entry splash is already in scrollback before footer output starts.
+  private wrote = true
+  // Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
+  private queue: StreamCommit[] = []
+  private pending = false
+  // Fixed portion of footer height above the textarea.
+  private base: number
+  private rows = TEXTAREA_MIN_ROWS
+  private state: Accessor<FooterState>
+  private setState: Setter<FooterState>
+  private view: Accessor<FooterView>
+  private setView: Setter<FooterView>
+  private interruptTimeout: NodeJS.Timeout | undefined
+  private exitTimeout: NodeJS.Timeout | undefined
+  private interruptHint: string
+
+  constructor(
+    private renderer: CliRenderer,
+    private options: RunFooterOptions,
+  ) {
+    const [state, setState] = createSignal<FooterState>({
+      phase: "idle",
+      status: "",
+      queue: 0,
+      model: options.modelLabel,
+      duration: "",
+      usage: "",
+      first: options.first,
+      interrupt: 0,
+      exit: 0,
+    })
+    this.state = state
+    this.setState = setState
+    const [view, setView] = createSignal<FooterView>({ type: "prompt" })
+    this.view = view
+    this.setView = setView
+    this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
+    this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
+
+    this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
+
+    void render(
+      () =>
+        createComponent(RunFooterView, {
+          state: this.state,
+          view: this.view,
+          theme: options.theme.footer,
+          block: options.theme.block,
+          diffStyle: options.diffStyle,
+          keybinds: options.keybinds,
+          history: options.history,
+          agent: options.agentLabel,
+          onSubmit: this.handlePrompt,
+          onPermissionReply: this.handlePermissionReply,
+          onQuestionReply: this.handleQuestionReply,
+          onQuestionReject: this.handleQuestionReject,
+          onCycle: this.handleCycle,
+          onInterrupt: this.handleInterrupt,
+          onExitRequest: this.handleExit,
+          onExit: () => this.close(),
+          onRows: this.syncRows,
+          onStatus: this.setStatus,
+        }),
+      this.renderer as unknown as Parameters<typeof render>[1],
+    ).catch(() => {
+      if (!this.destroyed && !this.renderer.isDestroyed) {
+        this.close()
+      }
+    })
+  }
+
+  public get isClosed(): boolean {
+    return this.closed || this.destroyed || this.renderer.isDestroyed
+  }
+
+  public onPrompt(fn: (text: string) => void): () => void {
+    this.prompts.add(fn)
+    return () => {
+      this.prompts.delete(fn)
+    }
+  }
+
+  public onClose(fn: () => void): () => void {
+    if (this.isClosed) {
+      fn()
+      return () => { }
+    }
+
+    this.closes.add(fn)
+    return () => {
+      this.closes.delete(fn)
+    }
+  }
+
+  public event(next: FooterEvent): void {
+    if (next.type === "queue") {
+      this.patch({ queue: next.queue })
+      return
+    }
+
+    if (next.type === "first") {
+      this.patch({ first: next.first })
+      return
+    }
+
+    if (next.type === "model") {
+      this.patch({ model: next.model })
+      return
+    }
+
+    if (next.type === "turn.send") {
+      this.patch({
+        phase: "running",
+        status: "sending prompt",
+        queue: next.queue,
+      })
+      return
+    }
+
+    if (next.type === "turn.wait") {
+      this.patch({
+        phase: "running",
+        status: "waiting for assistant",
+      })
+      return
+    }
+
+    if (next.type === "turn.idle") {
+      this.patch({
+        phase: "idle",
+        status: "",
+        queue: next.queue,
+      })
+      return
+    }
+
+    if (next.type === "turn.duration") {
+      this.patch({ duration: next.duration })
+      return
+    }
+
+    if (next.type === "stream.patch") {
+      if (typeof next.patch.status === "string" && next.patch.phase === undefined) {
+        this.patch({ phase: "running", ...next.patch })
+        return
+      }
+
+      this.patch(next.patch)
+      return
+    }
+
+    this.present(next.view)
+  }
+
+  private patch(next: FooterPatch): void {
+    if (this.destroyed || this.renderer.isDestroyed) {
+      return
+    }
+
+    const prev = this.state()
+    const state = {
+      phase: next.phase ?? prev.phase,
+      status: typeof next.status === "string" ? next.status : prev.status,
+      queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
+      model: typeof next.model === "string" ? next.model : prev.model,
+      duration: typeof next.duration === "string" ? next.duration : prev.duration,
+      usage: typeof next.usage === "string" ? next.usage : prev.usage,
+      first: typeof next.first === "boolean" ? next.first : prev.first,
+      interrupt:
+        typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
+          ? Math.max(0, Math.floor(next.interrupt))
+          : prev.interrupt,
+      exit:
+        typeof next.exit === "number" && Number.isFinite(next.exit) ? Math.max(0, Math.floor(next.exit)) : prev.exit,
+    }
+
+    if (state.phase === "idle") {
+      state.interrupt = 0
+    }
+
+    this.setState(state)
+
+    if (prev.phase === "running" && state.phase === "idle") {
+      this.flush()
+    }
+  }
+
+  private present(view: FooterView): void {
+    if (this.destroyed || this.renderer.isDestroyed) {
+      return
+    }
+
+    this.setView(view)
+    this.applyHeight()
+  }
+
+  // Queues a scrollback commit. Consecutive progress chunks for the same
+  // part coalesce by appending text, reducing the number of renderer writes.
+  // Actual flush happens on the next microtask, so a burst of events from
+  // one reducer pass becomes a single scrollback write.
+  public append(commit: StreamCommit): void {
+    if (this.destroyed || this.renderer.isDestroyed) {
+      return
+    }
+
+    if (!normalizeEntry(commit)) {
+      return
+    }
+
+    const last = this.queue.at(-1)
+    if (
+      last &&
+      last.phase === "progress" &&
+      commit.phase === "progress" &&
+      last.kind === commit.kind &&
+      last.source === commit.source &&
+      last.partID === commit.partID &&
+      last.tool === commit.tool
+    ) {
+      last.text += commit.text
+    } else {
+      this.queue.push(commit)
+    }
+
+    if (this.pending) {
+      return
+    }
+
+    this.pending = true
+    queueMicrotask(() => {
+      this.pending = false
+      this.flush()
+    })
+  }
+
+  public idle(): Promise<void> {
+    if (this.destroyed || this.renderer.isDestroyed) {
+      return Promise.resolve()
+    }
+
+    return this.renderer.idle().catch(() => { })
+  }
+
+  public close(): void {
+    if (this.closed) {
+      return
+    }
+
+    this.flush()
+    this.notifyClose()
+  }
+
+  public requestExit(): boolean {
+    return this.handleExit()
+  }
+
+  public destroy(): void {
+    if (this.destroyed) {
+      return
+    }
+
+    this.flush()
+    this.destroyed = true
+    this.notifyClose()
+    this.clearInterruptTimer()
+    this.clearExitTimer()
+    this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
+    this.prompts.clear()
+    this.closes.clear()
+    this.tail = undefined
+    this.wrote = false
+  }
+
+  private notifyClose(): void {
+    if (this.closed) {
+      return
+    }
+
+    this.closed = true
+    for (const fn of [...this.closes]) {
+      fn()
+    }
+  }
+
+  private setStatus = (status: string): void => {
+    this.patch({ status })
+  }
+
+  // Resizes the footer to fit the current view. Permission and question views
+  // get fixed extra rows; the prompt view scales with textarea line count.
+  private applyHeight(): void {
+    const type = this.view().type
+    const height =
+      type === "permission"
+        ? this.base + PERMISSION_ROWS
+        : type === "question"
+          ? this.base + QUESTION_ROWS
+          : Math.max(this.base + TEXTAREA_MIN_ROWS, Math.min(this.base + TEXTAREA_MAX_ROWS, this.base + this.rows))
+
+    if (height !== this.renderer.footerHeight) {
+      this.renderer.footerHeight = height
+    }
+  }
+
+  private syncRows = (value: number): void => {
+    if (this.destroyed || this.renderer.isDestroyed) {
+      return
+    }
+
+    const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, value))
+    if (rows === this.rows) {
+      return
+    }
+
+    this.rows = rows
+    if (this.view().type === "prompt") {
+      this.applyHeight()
+    }
+  }
+
+  private handlePrompt = (text: string): boolean => {
+    if (this.isClosed) {
+      return false
+    }
+
+    if (this.state().first) {
+      this.patch({ first: false })
+    }
+
+    if (this.prompts.size === 0) {
+      this.patch({ status: "input queue unavailable" })
+      return false
+    }
+
+    for (const fn of [...this.prompts]) {
+      fn(text)
+    }
+
+    return true
+  }
+
+  private handlePermissionReply = async (input: PermissionReply): Promise<void> => {
+    if (this.isClosed) {
+      return
+    }
+
+    await this.options.onPermissionReply(input)
+  }
+
+  private handleQuestionReply = async (input: QuestionReply): Promise<void> => {
+    if (this.isClosed) {
+      return
+    }
+
+    await this.options.onQuestionReply(input)
+  }
+
+  private handleQuestionReject = async (input: QuestionReject): Promise<void> => {
+    if (this.isClosed) {
+      return
+    }
+
+    await this.options.onQuestionReject(input)
+  }
+
+  private handleCycle = (): void => {
+    const result = this.options.onCycleVariant?.()
+    if (!result) {
+      this.patch({ status: "no variants available" })
+      return
+    }
+
+    const patch: FooterPatch = {
+      status: result.status ?? "variant updated",
+    }
+
+    if (result.modelLabel) {
+      patch.model = result.modelLabel
+    }
+
+    this.patch(patch)
+  }
+
+  private clearInterruptTimer(): void {
+    if (!this.interruptTimeout) {
+      return
+    }
+
+    clearTimeout(this.interruptTimeout)
+    this.interruptTimeout = undefined
+  }
+
+  private armInterruptTimer(): void {
+    this.clearInterruptTimer()
+    this.interruptTimeout = setTimeout(() => {
+      this.interruptTimeout = undefined
+      if (this.destroyed || this.renderer.isDestroyed || this.state().phase !== "running") {
+        return
+      }
+
+      this.patch({ interrupt: 0 })
+    }, 5000)
+  }
+
+  private clearExitTimer(): void {
+    if (!this.exitTimeout) {
+      return
+    }
+
+    clearTimeout(this.exitTimeout)
+    this.exitTimeout = undefined
+  }
+
+  private armExitTimer(): void {
+    this.clearExitTimer()
+    this.exitTimeout = setTimeout(() => {
+      this.exitTimeout = undefined
+      if (this.destroyed || this.renderer.isDestroyed || this.isClosed) {
+        return
+      }
+
+      this.patch({ exit: 0 })
+    }, 5000)
+  }
+
+  // Two-press interrupt: first press shows a hint ("esc again to interrupt"),
+  // second press within 5 seconds fires onInterrupt. The timer resets the
+  // counter if the user doesn't follow through.
+  private handleInterrupt = (): boolean => {
+    if (this.isClosed || this.state().phase !== "running") {
+      return false
+    }
+
+    const next = this.state().interrupt + 1
+    this.patch({ interrupt: next })
+
+    if (next < 2) {
+      this.armInterruptTimer()
+      this.patch({ status: `${this.interruptHint} again to interrupt` })
+      return true
+    }
+
+    this.clearInterruptTimer()
+    this.patch({ interrupt: 0, status: "interrupting" })
+    this.options.onInterrupt?.()
+    return true
+  }
+
+  private handleExit = (): boolean => {
+    if (this.isClosed) {
+      return true
+    }
+
+    this.clearInterruptTimer()
+    const next = this.state().exit + 1
+    this.patch({ exit: next, interrupt: 0 })
+
+    if (next < 2) {
+      this.armExitTimer()
+      this.patch({ status: "Press Ctrl-c again to exit" })
+      return true
+    }
+
+    this.clearExitTimer()
+    this.patch({ exit: 0, status: "exiting" })
+    this.close()
+    this.options.onExit?.()
+    return true
+  }
+
+  private handleDestroy = (): void => {
+    if (this.destroyed) {
+      return
+    }
+
+    this.flush()
+    this.destroyed = true
+    this.notifyClose()
+    this.clearInterruptTimer()
+    this.clearExitTimer()
+    this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
+    this.prompts.clear()
+    this.closes.clear()
+    this.tail = undefined
+    this.wrote = false
+  }
+
+  // Drains the commit queue to scrollback. Visible commits start a new block
+  // whenever their block key changes, and new blocks get a single spacer.
+  private flush(): void {
+    if (this.destroyed || this.renderer.isDestroyed || this.queue.length === 0) {
+      this.queue.length = 0
+      return
+    }
+
+    for (const item of this.queue.splice(0)) {
+      const same = sameGroup(this.tail, item)
+      if (this.wrote && !same) {
+        this.renderer.writeToScrollback(spacerWriter())
+      }
+
+      this.renderer.writeToScrollback(entryWriter(item, this.options.theme, { diffStyle: this.options.diffStyle }))
+      this.wrote = true
+      this.tail = item
+    }
+  }
+}
+
+function snap(commit: StreamCommit): boolean {
+  const tool = commit.tool ?? commit.part?.tool
+  return (
+    commit.kind === "tool" &&
+    commit.phase === "final" &&
+    (commit.toolState ?? commit.part?.state.status) === "completed" &&
+    typeof tool === "string" &&
+    Boolean(toolView(tool).snap)
+  )
+}
+
+function groupKey(commit: StreamCommit): string | undefined {
+  if (!commit.partID) {
+    return
+  }
+
+  if (snap(commit)) {
+    return `tool:${commit.partID}:final`
+  }
+
+  return `${commit.kind}:${commit.partID}`
+}
+
+function sameGroup(a: StreamCommit | undefined, b: StreamCommit): boolean {
+  if (!a) {
+    return false
+  }
+
+  const left = groupKey(a)
+  const right = groupKey(b)
+  if (left && right && left === right) {
+    return true
+  }
+
+  return a.kind === "tool" && a.phase === "start" && b.kind === "tool" && b.phase === "start"
+}

+ 306 - 0
packages/opencode/src/cli/cmd/run/footer.view.tsx

@@ -0,0 +1,306 @@
+// Top-level footer layout for direct interactive mode.
+//
+// Renders the footer region as a vertical stack:
+//   1. Spacer row (visual separation from scrollback)
+//   2. Composer frame with left-border accent -- swaps between prompt,
+//      permission, and question bodies via Switch/Match
+//   3. Meta row showing agent name and model label
+//   4. Bottom border + status row (spinner, interrupt hint, duration, usage)
+//
+// All state comes from the parent RunFooter through SolidJS signals.
+// The view itself is stateless except for derived memos.
+/** @jsxImportSource @opentui/solid */
+import { useTerminalDimensions } from "@opentui/solid"
+import { Match, Show, Switch, createMemo } from "solid-js"
+import "opentui-spinner/solid"
+import { createColors, createFrames } from "../tui/ui/spinner"
+import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
+import { RunPermissionBody } from "./footer.permission"
+import { RunQuestionBody } from "./footer.question"
+import { printableBinding } from "./prompt.shared"
+import type {
+  FooterKeybinds,
+  FooterState,
+  FooterView,
+  PermissionReply,
+  QuestionReject,
+  QuestionReply,
+  RunDiffStyle,
+} from "./types"
+import { RUN_THEME_FALLBACK, type RunBlockTheme, type RunFooterTheme } from "./theme"
+
+const EMPTY_BORDER = {
+  topLeft: "",
+  bottomLeft: "",
+  vertical: "",
+  topRight: "",
+  bottomRight: "",
+  horizontal: " ",
+  bottomT: "",
+  topT: "",
+  cross: "",
+  leftT: "",
+  rightT: "",
+}
+
+type RunFooterViewProps = {
+  state: () => FooterState
+  view?: () => FooterView
+  theme?: RunFooterTheme
+  block?: RunBlockTheme
+  diffStyle?: RunDiffStyle
+  keybinds: FooterKeybinds
+  history?: string[]
+  agent: string
+  onSubmit: (text: string) => boolean
+  onPermissionReply: (input: PermissionReply) => void | Promise<void>
+  onQuestionReply: (input: QuestionReply) => void | Promise<void>
+  onQuestionReject: (input: QuestionReject) => void | Promise<void>
+  onCycle: () => void
+  onInterrupt: () => boolean
+  onExitRequest?: () => boolean
+  onExit: () => void
+  onRows: (rows: number) => void
+  onStatus: (text: string) => void
+}
+
+export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
+
+export function RunFooterView(props: RunFooterViewProps) {
+  const term = useTerminalDimensions()
+  const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
+  const prompt = createMemo(() => active().type === "prompt")
+  const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
+  const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
+  const hints = createMemo(() => hintFlags(term().width))
+  const busy = createMemo(() => props.state().phase === "running")
+  const armed = createMemo(() => props.state().interrupt > 0)
+  const exiting = createMemo(() => props.state().exit > 0)
+  const queue = createMemo(() => props.state().queue)
+  const duration = createMemo(() => props.state().duration)
+  const usage = createMemo(() => props.state().usage)
+  const interruptKey = createMemo(() => interrupt() || "/exit")
+  const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK.footer)
+  const block = createMemo(() => props.block ?? RUN_THEME_FALLBACK.block)
+  const spin = createMemo(() => {
+    return {
+      frames: createFrames({
+        color: theme().highlight,
+        style: "blocks",
+        inactiveFactor: 0.6,
+        minAlpha: 0.3,
+      }),
+      color: createColors({
+        color: theme().highlight,
+        style: "blocks",
+        inactiveFactor: 0.6,
+        minAlpha: 0.3,
+      }),
+    }
+  })
+  const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
+    const view = active()
+    return view.type === "permission" ? view : undefined
+  })
+  const question = createMemo<Extract<FooterView, { type: "question" }> | undefined>(() => {
+    const view = active()
+    return view.type === "question" ? view : undefined
+  })
+  const composer = createPromptState({
+    keybinds: props.keybinds,
+    state: props.state,
+    view: () => active().type,
+    prompt,
+    width: () => term().width,
+    theme,
+    history: props.history,
+    onSubmit: props.onSubmit,
+    onCycle: props.onCycle,
+    onInterrupt: props.onInterrupt,
+    onExitRequest: props.onExitRequest,
+    onExit: props.onExit,
+    onRows: props.onRows,
+    onStatus: props.onStatus,
+  })
+
+  return (
+    <box
+      id="run-direct-footer-shell"
+      width="100%"
+      height="100%"
+      border={false}
+      backgroundColor="transparent"
+      flexDirection="column"
+      gap={0}
+      padding={0}
+    >
+      <box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
+
+      <box
+        id="run-direct-footer-composer-frame"
+        width="100%"
+        flexShrink={0}
+        border={["left"]}
+        borderColor={theme().highlight}
+        customBorderChars={{
+          ...EMPTY_BORDER,
+          vertical: "┃",
+          bottomLeft: "╹",
+        }}
+      >
+        <box
+          id="run-direct-footer-composer-area"
+          width="100%"
+          flexGrow={1}
+          paddingLeft={0}
+          paddingRight={0}
+          paddingTop={0}
+          flexDirection="column"
+          backgroundColor={theme().surface}
+          gap={0}
+        >
+          <box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
+            <Switch>
+              <Match when={active().type === "prompt"}>
+                <RunPromptBody
+                  theme={theme}
+                  placeholder={composer.placeholder}
+                  bindings={composer.bindings}
+                  onSubmit={composer.onSubmit}
+                  onKeyDown={composer.onKeyDown}
+                  onContentChange={composer.onContentChange}
+                  bind={composer.bind}
+                />
+              </Match>
+              <Match when={active().type === "permission"}>
+                <RunPermissionBody
+                  request={permission()!.request}
+                  theme={theme()}
+                  block={block()}
+                  diffStyle={props.diffStyle}
+                  onReply={props.onPermissionReply}
+                />
+              </Match>
+              <Match when={active().type === "question"}>
+                <RunQuestionBody
+                  request={question()!.request}
+                  theme={theme()}
+                  onReply={props.onQuestionReply}
+                  onReject={props.onQuestionReject}
+                />
+              </Match>
+            </Switch>
+          </box>
+
+          <box id="run-direct-footer-meta-row" width="100%" flexDirection="row" gap={1} paddingLeft={2} flexShrink={0} paddingTop={1}>
+            <text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
+              {props.agent}
+            </text>
+            <text id="run-direct-footer-model" fg={theme().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
+              {props.state().model}
+            </text>
+          </box>
+        </box>
+      </box>
+
+      <box
+        id="run-direct-footer-line-6"
+        width="100%"
+        height={1}
+        border={["left"]}
+        borderColor={theme().highlight}
+        customBorderChars={{
+          ...EMPTY_BORDER,
+          vertical: "╹",
+        }}
+        flexShrink={0}
+      >
+        <box
+          id="run-direct-footer-line-6-fill"
+          width="100%"
+          height={1}
+          border={["bottom"]}
+          borderColor={theme().line}
+          customBorderChars={{
+            ...EMPTY_BORDER,
+            horizontal: "▀",
+          }}
+        />
+      </box>
+
+      <box
+        id="run-direct-footer-row"
+        width="100%"
+        height={1}
+        flexDirection="row"
+        justifyContent="space-between"
+        gap={1}
+        flexShrink={0}
+      >
+        <Show when={busy() || exiting()}>
+          <box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
+            <Show when={exiting()}>
+              <text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
+                Press Ctrl-c again to exit
+              </text>
+            </Show>
+
+            <Show when={busy() && !exiting()}>
+              <box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
+                <spinner color={spin().color} frames={spin().frames} interval={40} />
+              </box>
+
+              <text
+                id="run-direct-footer-hint-interrupt"
+                fg={armed() ? theme().highlight : theme().text}
+                wrapMode="none"
+                truncate
+              >
+                {interruptKey()}{" "}
+                <span style={{ fg: armed() ? theme().highlight : theme().muted }}>
+                  {armed() ? "again to interrupt" : "interrupt"}
+                </span>
+              </text>
+            </Show>
+          </box>
+        </Show>
+
+        <Show when={!busy() && !exiting() && duration().length > 0}>
+          <box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
+            <text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
+              ▣
+            </text>
+            <box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
+              <text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
+                ·
+              </text>
+              <text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
+                {duration()}
+              </text>
+            </box>
+          </box>
+        </Show>
+
+        <box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
+
+        <box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
+          <Show when={queue() > 0}>
+            <text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
+              {queue()} queued
+            </text>
+          </Show>
+          <Show when={usage().length > 0}>
+            <text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
+              {usage()}
+            </text>
+          </Show>
+          <Show when={variant().length > 0 && hints().variant}>
+            <text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
+              {variant()} variant
+            </text>
+          </Show>
+        </box>
+      </box>
+    </box>
+  )
+}

+ 256 - 0
packages/opencode/src/cli/cmd/run/permission.shared.ts

@@ -0,0 +1,256 @@
+// Pure state machine for the permission UI.
+//
+// Lives outside the JSX component so it can be tested independently. The
+// machine has three stages:
+//
+//   permission → initial view with Allow once / Always / Reject options
+//   always     → confirmation step (Confirm / Cancel)
+//   reject     → text input for rejection message
+//
+// permissionRun() is the main transition: given the current state and the
+// selected option, it returns a new state and optionally a PermissionReply
+// to send to the SDK. The component calls this on enter/click.
+//
+// permissionInfo() extracts display info (icon, title, lines, diff) from
+// the request, delegating to tool.ts for tool-specific formatting.
+import type { PermissionRequest } from "@opencode-ai/sdk/v2"
+import type { PermissionReply } from "./types"
+import { toolPath, toolPermissionInfo } from "./tool"
+
+type Dict = Record<string, unknown>
+
+export type PermissionStage = "permission" | "always" | "reject"
+export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
+
+export type PermissionBodyState = {
+  requestID: string
+  stage: PermissionStage
+  selected: PermissionOption
+  message: string
+  submitting: boolean
+}
+
+export type PermissionInfo = {
+  icon: string
+  title: string
+  lines: string[]
+  diff?: string
+  file?: string
+}
+
+export type PermissionStep = {
+  state: PermissionBodyState
+  reply?: PermissionReply
+}
+
+function dict(v: unknown): Dict {
+  if (!v || typeof v !== "object" || Array.isArray(v)) {
+    return {}
+  }
+
+  return v as Dict
+}
+
+function text(v: unknown): string {
+  return typeof v === "string" ? v : ""
+}
+
+function data(request: PermissionRequest): Dict {
+  const meta = dict(request.metadata)
+  return {
+    ...meta,
+    ...dict(meta.input),
+  }
+}
+
+function patterns(request: PermissionRequest): string[] {
+  return request.patterns.filter((item): item is string => typeof item === "string")
+}
+
+export function createPermissionBodyState(requestID: string): PermissionBodyState {
+  return {
+    requestID,
+    stage: "permission",
+    selected: "once",
+    message: "",
+    submitting: false,
+  }
+}
+
+export function permissionOptions(stage: PermissionStage): PermissionOption[] {
+  if (stage === "permission") {
+    return ["once", "always", "reject"]
+  }
+
+  if (stage === "always") {
+    return ["confirm", "cancel"]
+  }
+
+  return []
+}
+
+export function permissionInfo(request: PermissionRequest): PermissionInfo {
+  const pats = patterns(request)
+  const input = data(request)
+  const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats)
+  if (info) {
+    return info
+  }
+
+  if (request.permission === "external_directory") {
+    const meta = dict(request.metadata)
+    const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
+    const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
+    return {
+      icon: "←",
+      title: `Access external directory ${toolPath(dir, { home: true })}`,
+      lines: pats.map((item) => `- ${item}`),
+    }
+  }
+
+  if (request.permission === "doom_loop") {
+    return {
+      icon: "⟳",
+      title: "Continue after repeated failures",
+      lines: ["This keeps the session running despite repeated failures."],
+    }
+  }
+
+  return {
+    icon: "⚙",
+    title: `Call tool ${request.permission}`,
+    lines: [`Tool: ${request.permission}`],
+  }
+}
+
+export function permissionAlwaysLines(request: PermissionRequest): string[] {
+  if (request.always.length === 1 && request.always[0] === "*") {
+    return [`This will allow ${request.permission} until OpenCode is restarted.`]
+  }
+
+  return [
+    "This will allow the following patterns until OpenCode is restarted.",
+    ...request.always.map((item) => `- ${item}`),
+  ]
+}
+
+export function permissionLabel(option: PermissionOption): string {
+  if (option === "once") return "Allow once"
+  if (option === "always") return "Allow always"
+  if (option === "reject") return "Reject"
+  if (option === "confirm") return "Confirm"
+  return "Cancel"
+}
+
+export function permissionReply(requestID: string, reply: PermissionReply["reply"], message?: string): PermissionReply {
+  return {
+    requestID,
+    reply,
+    ...(message && message.trim() ? { message: message.trim() } : {}),
+  }
+}
+
+export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
+  const list = permissionOptions(state.stage)
+  if (list.length === 0) {
+    return state
+  }
+
+  const idx = Math.max(0, list.indexOf(state.selected))
+  const selected = list[(idx + dir + list.length) % list.length]
+  return {
+    ...state,
+    selected,
+  }
+}
+
+export function permissionHover(state: PermissionBodyState, option: PermissionOption): PermissionBodyState {
+  return {
+    ...state,
+    selected: option,
+  }
+}
+
+export function permissionRun(state: PermissionBodyState, requestID: string, option: PermissionOption): PermissionStep {
+  if (state.submitting) {
+    return { state }
+  }
+
+  if (state.stage === "permission") {
+    if (option === "always") {
+      return {
+        state: {
+          ...state,
+          stage: "always",
+          selected: "confirm",
+        },
+      }
+    }
+
+    if (option === "reject") {
+      return {
+        state: {
+          ...state,
+          stage: "reject",
+          selected: "reject",
+        },
+      }
+    }
+
+    return {
+      state,
+      reply: permissionReply(requestID, "once"),
+    }
+  }
+
+  if (state.stage !== "always") {
+    return { state }
+  }
+
+  if (option === "cancel") {
+    return {
+      state: {
+        ...state,
+        stage: "permission",
+        selected: "always",
+      },
+    }
+  }
+
+  return {
+    state,
+    reply: permissionReply(requestID, "always"),
+  }
+}
+
+export function permissionReject(state: PermissionBodyState, requestID: string): PermissionReply | undefined {
+  if (state.submitting) {
+    return
+  }
+
+  return permissionReply(requestID, "reject", state.message)
+}
+
+export function permissionCancel(state: PermissionBodyState): PermissionBodyState {
+  return {
+    ...state,
+    stage: "permission",
+    selected: "reject",
+  }
+}
+
+export function permissionEscape(state: PermissionBodyState): PermissionBodyState {
+  if (state.stage === "always") {
+    return {
+      ...state,
+      stage: "permission",
+      selected: "always",
+    }
+  }
+
+  return {
+    ...state,
+    stage: "reject",
+    selected: "reject",
+  }
+}

+ 253 - 0
packages/opencode/src/cli/cmd/run/prompt.shared.ts

@@ -0,0 +1,253 @@
+// Pure state machine for the prompt input.
+//
+// Handles keybind parsing, history ring navigation, and the leader-key
+// sequence for variant cycling. All functions are pure -- they take state
+// in and return new state out, with no side effects.
+//
+// The history ring (PromptHistoryState) stores past prompts and tracks
+// the current browse position. When the user arrows up at cursor offset 0,
+// the current draft is saved and history begins. Arrowing past the end
+// restores the draft.
+//
+// The leader-key cycle (promptCycle) uses a two-step pattern: first press
+// arms the leader, second press within the timeout fires the action.
+import type { KeyBinding } from "@opentui/core"
+import { Keybind } from "../../../util/keybind"
+import type { FooterKeybinds } from "./types"
+
+const HISTORY_LIMIT = 200
+
+export type PromptHistoryState = {
+  items: string[]
+  index: number | null
+  draft: string
+}
+
+export type PromptKeys = {
+  leaders: Keybind.Info[]
+  cycles: Keybind.Info[]
+  interrupts: Keybind.Info[]
+  previous: Keybind.Info[]
+  next: Keybind.Info[]
+  bindings: KeyBinding[]
+}
+
+export type PromptCycle = {
+  arm: boolean
+  clear: boolean
+  cycle: boolean
+  consume: boolean
+}
+
+export type PromptMove = {
+  state: PromptHistoryState
+  text?: string
+  cursor?: number
+  apply: boolean
+}
+
+function mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
+  return Keybind.parse(binding).map((item) => ({
+    name: item.name,
+    ctrl: item.ctrl || undefined,
+    meta: item.meta || undefined,
+    shift: item.shift || undefined,
+    super: item.super || undefined,
+    action,
+  }))
+}
+
+function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
+  return [
+    { name: "return", action: "submit" },
+    { name: "return", meta: true, action: "newline" },
+    ...mapInputBindings(keybinds.inputSubmit, "submit"),
+    ...mapInputBindings(keybinds.inputNewline, "newline"),
+  ]
+}
+
+export function promptKeys(keybinds: FooterKeybinds): PromptKeys {
+  return {
+    leaders: Keybind.parse(keybinds.leader),
+    cycles: Keybind.parse(keybinds.variantCycle),
+    interrupts: Keybind.parse(keybinds.interrupt),
+    previous: Keybind.parse(keybinds.historyPrevious),
+    next: Keybind.parse(keybinds.historyNext),
+    bindings: textareaBindings(keybinds),
+  }
+}
+
+export function printableBinding(binding: string, leader: string): string {
+  const first = Keybind.parse(binding).at(0)
+  if (!first) {
+    return ""
+  }
+
+  let text = Keybind.toString(first)
+  const lead = Keybind.parse(leader).at(0)
+  if (lead) {
+    text = text.replace("<leader>", Keybind.toString(lead))
+  }
+
+  return text.replace(/escape/g, "esc")
+}
+
+export function isExitCommand(input: string): boolean {
+  const text = input.trim().toLowerCase()
+  return text === "/exit" || text === "/quit"
+}
+
+export function promptInfo(event: {
+  name: string
+  ctrl?: boolean
+  meta?: boolean
+  shift?: boolean
+  super?: boolean
+}): Keybind.Info {
+  return {
+    name: event.name === " " ? "space" : event.name,
+    ctrl: !!event.ctrl,
+    meta: !!event.meta,
+    shift: !!event.shift,
+    super: !!event.super,
+    leader: false,
+  }
+}
+
+export function promptHit(bindings: Keybind.Info[], event: Keybind.Info): boolean {
+  return bindings.some((item) => Keybind.match(item, event))
+}
+
+export function promptCycle(
+  armed: boolean,
+  event: Keybind.Info,
+  leaders: Keybind.Info[],
+  cycles: Keybind.Info[],
+): PromptCycle {
+  if (!armed && promptHit(leaders, event)) {
+    return {
+      arm: true,
+      clear: false,
+      cycle: false,
+      consume: true,
+    }
+  }
+
+  if (armed) {
+    return {
+      arm: false,
+      clear: true,
+      cycle: promptHit(cycles, { ...event, leader: true }),
+      consume: true,
+    }
+  }
+
+  if (!promptHit(cycles, event)) {
+    return {
+      arm: false,
+      clear: false,
+      cycle: false,
+      consume: false,
+    }
+  }
+
+  return {
+    arm: false,
+    clear: false,
+    cycle: true,
+    consume: true,
+  }
+}
+
+export function createPromptHistory(items?: string[]): PromptHistoryState {
+  return {
+    items: (items ?? [])
+      .map((item) => item.trim())
+      .filter((item) => item.length > 0)
+      .filter((item, idx, all) => idx === 0 || item !== all[idx - 1])
+      .slice(-HISTORY_LIMIT),
+    index: null,
+    draft: "",
+  }
+}
+
+export function pushPromptHistory(state: PromptHistoryState, text: string): PromptHistoryState {
+  if (!text) {
+    return state
+  }
+
+  if (state.items[state.items.length - 1] === text) {
+    return {
+      ...state,
+      index: null,
+      draft: "",
+    }
+  }
+
+  const items = [...state.items, text].slice(-HISTORY_LIMIT)
+  return {
+    ...state,
+    items,
+    index: null,
+    draft: "",
+  }
+}
+
+export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptMove {
+  if (state.items.length === 0) {
+    return { state, apply: false }
+  }
+
+  if (dir === -1 && cursor !== 0) {
+    return { state, apply: false }
+  }
+
+  if (dir === 1 && cursor !== text.length) {
+    return { state, apply: false }
+  }
+
+  if (state.index === null) {
+    if (dir === 1) {
+      return { state, apply: false }
+    }
+
+    const idx = state.items.length - 1
+    return {
+      state: {
+        ...state,
+        index: idx,
+        draft: text,
+      },
+      text: state.items[idx],
+      cursor: 0,
+      apply: true,
+    }
+  }
+
+  const idx = state.index + dir
+  if (idx < 0) {
+    return { state, apply: false }
+  }
+
+  if (idx >= state.items.length) {
+    return {
+      state: {
+        ...state,
+        index: null,
+      },
+      text: state.draft,
+      cursor: state.draft.length,
+      apply: true,
+    }
+  }
+
+  return {
+    state: {
+      ...state,
+      index: idx,
+    },
+    text: state.items[idx],
+    cursor: dir === -1 ? 0 : state.items[idx].length,
+    apply: true,
+  }
+}

+ 340 - 0
packages/opencode/src/cli/cmd/run/question.shared.ts

@@ -0,0 +1,340 @@
+// Pure state machine for the question UI.
+//
+// Supports both single-question and multi-question flows. Single questions
+// submit immediately on selection. Multi-question flows use tabs and a
+// final confirmation step.
+//
+// State transitions:
+//   questionSelect  → picks an option (single: submits, multi: toggles/advances)
+//   questionSave    → saves custom text input
+//   questionMove    → arrow key navigation through options
+//   questionSetTab  → tab navigation between questions
+//   questionSubmit  → builds the final QuestionReply with all answers
+//
+// Custom answers: if a question has custom=true, an extra "Type your own
+// answer" option appears. Selecting it enters editing mode with a text field.
+import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
+import type { QuestionReject, QuestionReply } from "./types"
+
+export type QuestionBodyState = {
+  requestID: string
+  tab: number
+  answers: string[][]
+  custom: string[]
+  selected: number
+  editing: boolean
+  submitting: boolean
+}
+
+export type QuestionStep = {
+  state: QuestionBodyState
+  reply?: QuestionReply
+}
+
+export function createQuestionBodyState(requestID: string): QuestionBodyState {
+  return {
+    requestID,
+    tab: 0,
+    answers: [],
+    custom: [],
+    selected: 0,
+    editing: false,
+    submitting: false,
+  }
+}
+
+export function questionSync(state: QuestionBodyState, requestID: string): QuestionBodyState {
+  if (state.requestID === requestID) {
+    return state
+  }
+
+  return createQuestionBodyState(requestID)
+}
+
+export function questionSingle(request: QuestionRequest): boolean {
+  return request.questions.length === 1 && request.questions[0]?.multiple !== true
+}
+
+export function questionTabs(request: QuestionRequest): number {
+  return questionSingle(request) ? 1 : request.questions.length + 1
+}
+
+export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
+  return !questionSingle(request) && state.tab === request.questions.length
+}
+
+export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
+  return request.questions[state.tab]
+}
+
+export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean {
+  return questionInfo(request, state)?.custom !== false
+}
+
+export function questionInput(state: QuestionBodyState): string {
+  return state.custom[state.tab] ?? ""
+}
+
+export function questionPicked(state: QuestionBodyState): boolean {
+  const value = questionInput(state)
+  if (!value) {
+    return false
+  }
+
+  return state.answers[state.tab]?.includes(value) ?? false
+}
+
+export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean {
+  const info = questionInfo(request, state)
+  if (!info || info.custom === false) {
+    return false
+  }
+
+  return state.selected === info.options.length
+}
+
+export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number {
+  const info = questionInfo(request, state)
+  if (!info) {
+    return 0
+  }
+
+  return info.options.length + (questionCustom(request, state) ? 1 : 0)
+}
+
+export function questionAnswers(state: QuestionBodyState, count: number): string[][] {
+  return Array.from({ length: count }, (_, idx) => state.answers[idx] ?? [])
+}
+
+export function questionSetTab(state: QuestionBodyState, tab: number): QuestionBodyState {
+  return {
+    ...state,
+    tab,
+    selected: 0,
+    editing: false,
+  }
+}
+
+export function questionSetSelected(state: QuestionBodyState, selected: number): QuestionBodyState {
+  return {
+    ...state,
+    selected,
+  }
+}
+
+export function questionSetEditing(state: QuestionBodyState, editing: boolean): QuestionBodyState {
+  return {
+    ...state,
+    editing,
+  }
+}
+
+export function questionSetSubmitting(state: QuestionBodyState, submitting: boolean): QuestionBodyState {
+  return {
+    ...state,
+    submitting,
+  }
+}
+
+function storeAnswers(state: QuestionBodyState, tab: number, list: string[]): QuestionBodyState {
+  const answers = [...state.answers]
+  answers[tab] = list
+  return {
+    ...state,
+    answers,
+  }
+}
+
+export function questionStoreCustom(state: QuestionBodyState, tab: number, text: string): QuestionBodyState {
+  const custom = [...state.custom]
+  custom[tab] = text
+  return {
+    ...state,
+    custom,
+  }
+}
+
+function questionPick(
+  state: QuestionBodyState,
+  request: QuestionRequest,
+  answer: string,
+  custom = false,
+): QuestionStep {
+  const answers = [...state.answers]
+  answers[state.tab] = [answer]
+  let next: QuestionBodyState = {
+    ...state,
+    answers,
+    editing: false,
+  }
+
+  if (custom) {
+    const list = [...state.custom]
+    list[state.tab] = answer
+    next = {
+      ...next,
+      custom: list,
+    }
+  }
+
+  if (questionSingle(request)) {
+    return {
+      state: next,
+      reply: {
+        requestID: request.id,
+        answers: [[answer]],
+      },
+    }
+  }
+
+  return {
+    state: questionSetTab(next, state.tab + 1),
+  }
+}
+
+function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyState {
+  const list = [...(state.answers[state.tab] ?? [])]
+  const idx = list.indexOf(answer)
+  if (idx === -1) {
+    list.push(answer)
+  } else {
+    list.splice(idx, 1)
+  }
+
+  return storeAnswers(state, state.tab, list)
+}
+
+export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState {
+  const total = questionTotal(request, state)
+  if (total === 0) {
+    return state
+  }
+
+  return {
+    ...state,
+    selected: (state.selected + dir + total) % total,
+  }
+}
+
+export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
+  const info = questionInfo(request, state)
+  if (!info) {
+    return { state }
+  }
+
+  if (questionOther(request, state)) {
+    if (!info.multiple) {
+      return {
+        state: questionSetEditing(state, true),
+      }
+    }
+
+    const value = questionInput(state)
+    if (value && questionPicked(state)) {
+      return {
+        state: questionToggle(state, value),
+      }
+    }
+
+    return {
+      state: questionSetEditing(state, true),
+    }
+  }
+
+  const option = info.options[state.selected]
+  if (!option) {
+    return { state }
+  }
+
+  if (info.multiple) {
+    return {
+      state: questionToggle(state, option.label),
+    }
+  }
+
+  return questionPick(state, request, option.label)
+}
+
+export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
+  const info = questionInfo(request, state)
+  if (!info) {
+    return { state }
+  }
+
+  const value = questionInput(state).trim()
+  const prev = state.custom[state.tab]
+  if (!value) {
+    if (!prev) {
+      return {
+        state: questionSetEditing(state, false),
+      }
+    }
+
+    const next = questionStoreCustom(state, state.tab, "")
+    return {
+      state: questionSetEditing(
+        storeAnswers(
+          next,
+          state.tab,
+          (state.answers[state.tab] ?? []).filter((item) => item !== prev),
+        ),
+        false,
+      ),
+    }
+  }
+
+  if (info.multiple) {
+    const answers = [...(state.answers[state.tab] ?? [])]
+    if (prev) {
+      const idx = answers.indexOf(prev)
+      if (idx !== -1) {
+        answers.splice(idx, 1)
+      }
+    }
+
+    if (!answers.includes(value)) {
+      answers.push(value)
+    }
+
+    const next = questionStoreCustom(state, state.tab, value)
+    return {
+      state: questionSetEditing(storeAnswers(next, state.tab, answers), false),
+    }
+  }
+
+  return questionPick(state, request, value, true)
+}
+
+export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply {
+  return {
+    requestID: request.id,
+    answers: questionAnswers(state, request.questions.length),
+  }
+}
+
+export function questionReject(request: QuestionRequest): QuestionReject {
+  return {
+    requestID: request.id,
+  }
+}
+
+export function questionHint(request: QuestionRequest, state: QuestionBodyState): string {
+  if (state.submitting) {
+    return "Waiting for question event..."
+  }
+
+  if (questionConfirm(request, state)) {
+    return "enter submit   esc dismiss"
+  }
+
+  if (state.editing) {
+    return "enter save   esc cancel"
+  }
+
+  const info = questionInfo(request, state)
+  if (questionSingle(request)) {
+    return `↑↓ select   enter ${info?.multiple ? "toggle" : "submit"}   esc dismiss`
+  }
+
+  return `⇆ tab   ↑↓ select   enter ${info?.multiple ? "toggle" : "confirm"}   esc dismiss`
+}

+ 140 - 0
packages/opencode/src/cli/cmd/run/runtime.boot.ts

@@ -0,0 +1,140 @@
+// Boot-time resolution for direct interactive mode.
+//
+// These functions run concurrently at startup to gather everything the runtime
+// needs before the first frame: keybinds from TUI config, diff display style,
+// model variant list with context limits, and session history for the prompt
+// history ring. All are async because they read config or hit the SDK, but
+// none block each other.
+import { TuiConfig } from "../../../config/tui"
+import { resolveSession, sessionHistory } from "./session.shared"
+import type { FooterKeybinds, RunDiffStyle, RunInput } from "./types"
+import { pickVariant } from "./variant.shared"
+
+const DEFAULT_KEYBINDS: FooterKeybinds = {
+  leader: "ctrl+x",
+  variantCycle: "ctrl+t,<leader>t",
+  interrupt: "escape",
+  historyPrevious: "up",
+  historyNext: "down",
+  inputSubmit: "return",
+  inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
+}
+
+export type ModelInfo = {
+  variants: string[]
+  limits: Record<string, number>
+}
+
+export type SessionInfo = {
+  first: boolean
+  history: string[]
+  variant: string | undefined
+}
+
+function modelKey(provider: string, model: string): string {
+  return `${provider}/${model}`
+}
+
+// Fetches available variants and context limits for every provider/model pair.
+export async function resolveModelInfo(sdk: RunInput["sdk"], model: RunInput["model"]): Promise<ModelInfo> {
+  try {
+    const response = await sdk.provider.list()
+    const providers = response.data?.all ?? []
+    const limits: Record<string, number> = {}
+
+    for (const provider of providers) {
+      for (const [modelID, info] of Object.entries(provider.models ?? {})) {
+        const limit = info?.limit?.context
+        if (typeof limit === "number" && limit > 0) {
+          limits[modelKey(provider.id, modelID)] = limit
+        }
+      }
+    }
+
+    if (!model) {
+      return {
+        variants: [],
+        limits,
+      }
+    }
+
+    const provider = providers.find((item) => item.id === model.providerID)
+    const modelInfo = provider?.models?.[model.modelID]
+    return {
+      variants: Object.keys(modelInfo?.variants ?? {}),
+      limits,
+    }
+  } catch {
+    return {
+      variants: [],
+      limits: {},
+    }
+  }
+}
+
+// Fetches session messages to determine if this is the first turn and build prompt history.
+export async function resolveSessionInfo(
+  sdk: RunInput["sdk"],
+  sessionID: string,
+  model: RunInput["model"],
+): Promise<SessionInfo> {
+  try {
+    const session = await resolveSession(sdk, sessionID)
+    return {
+      first: session.first,
+      history: sessionHistory(session),
+      variant: pickVariant(model, session),
+    }
+  } catch {
+    return {
+      first: true,
+      history: [],
+      variant: undefined,
+    }
+  }
+}
+
+// Reads keybind overrides from TUI config and merges them with defaults.
+// Always ensures <leader>t is present in the variant cycle binding.
+export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
+  try {
+    const config = await TuiConfig.get()
+    const configuredLeader = config.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader
+    const configuredVariantCycle = config.keybinds?.variant_cycle?.trim() || "ctrl+t"
+    const configuredInterrupt = config.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt
+    const configuredHistoryPrevious = config.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious
+    const configuredHistoryNext = config.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext
+    const configuredSubmit = config.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit
+    const configuredNewline = config.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline
+
+    const variantBindings = configuredVariantCycle
+      .split(",")
+      .map((item) => item.trim())
+      .filter((item) => item.length > 0)
+
+    if (!variantBindings.some((binding) => binding.toLowerCase() === "<leader>t")) {
+      variantBindings.push("<leader>t")
+    }
+
+    return {
+      leader: configuredLeader,
+      variantCycle: variantBindings.join(","),
+      interrupt: configuredInterrupt,
+      historyPrevious: configuredHistoryPrevious,
+      historyNext: configuredHistoryNext,
+      inputSubmit: configuredSubmit,
+      inputNewline: configuredNewline,
+    }
+  } catch {
+    return DEFAULT_KEYBINDS
+  }
+}
+
+export async function resolveDiffStyle(): Promise<RunDiffStyle> {
+  try {
+    const config = await TuiConfig.get()
+    return config.diff_style ?? "auto"
+  } catch {
+    return "auto"
+  }
+}

+ 235 - 0
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

@@ -0,0 +1,235 @@
+// Lifecycle management for the split-footer renderer.
+//
+// Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme
+// from the terminal palette, writes the entry splash to scrollback, and
+// constructs the RunFooter. Returns a Lifecycle handle whose close() writes
+// the exit splash and tears everything down in the right order:
+// footer.close → footer.destroy → renderer shutdown.
+//
+// Also wires SIGINT so Ctrl-c during a turn triggers the two-press exit
+// sequence through RunFooter.requestExit().
+import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
+import { Locale } from "../../../util/locale"
+import { entrySplash, exitSplash, splashMeta } from "./splash"
+import { resolveRunTheme } from "./theme"
+import type {
+  FooterApi,
+  FooterKeybinds,
+  PermissionReply,
+  QuestionReject,
+  QuestionReply,
+  RunDiffStyle,
+  RunInput,
+} from "./types"
+import { formatModelLabel } from "./variant.shared"
+
+const FOOTER_HEIGHT = 7
+const DEFAULT_TITLE = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
+
+type SplashState = {
+  entry: boolean
+  exit: boolean
+}
+
+type CycleResult = {
+  modelLabel?: string
+  status?: string
+}
+
+type FooterLabels = {
+  agentLabel: string
+  modelLabel: string
+}
+
+export type LifecycleInput = {
+  sessionID: string
+  sessionTitle?: string
+  first: boolean
+  history: string[]
+  agent: string | undefined
+  model: RunInput["model"]
+  variant: string | undefined
+  keybinds: FooterKeybinds
+  diffStyle: RunDiffStyle
+  onPermissionReply: (input: PermissionReply) => void | Promise<void>
+  onQuestionReply: (input: QuestionReply) => void | Promise<void>
+  onQuestionReject: (input: QuestionReject) => void | Promise<void>
+  onCycleVariant?: () => CycleResult | void
+  onInterrupt?: () => void
+}
+
+export type Lifecycle = {
+  footer: FooterApi
+  close(input: { showExit: boolean }): Promise<void>
+}
+
+// Gracefully tears down the renderer. Order matters: switch external output
+// back to passthrough before leaving split-footer mode, so pending stdout
+// doesn't get captured into the now-dead scrollback pipeline.
+function shutdown(renderer: CliRenderer): void {
+  if (renderer.isDestroyed) {
+    return
+  }
+
+  if (renderer.externalOutputMode === "capture-stdout") {
+    renderer.externalOutputMode = "passthrough"
+  }
+
+  if (renderer.screenMode === "split-footer") {
+    renderer.screenMode = "main-screen"
+  }
+
+  if (!renderer.isDestroyed) {
+    renderer.destroy()
+  }
+}
+
+function splashTitle(title: string | undefined, history: string[]): string | undefined {
+  if (title && !DEFAULT_TITLE.test(title)) {
+    return title
+  }
+
+  const next = history.find((item) => item.trim().length > 0)
+  return next ?? title
+}
+
+function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
+  const agentLabel = Locale.titlecase(input.agent ?? "build")
+
+  if (!input.model) {
+    return {
+      agentLabel,
+      modelLabel: "Model default",
+    }
+  }
+
+  return {
+    agentLabel,
+    modelLabel: formatModelLabel(input.model, input.variant),
+  }
+}
+
+function queueSplash(
+  renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
+  state: SplashState,
+  phase: keyof SplashState,
+  write: ScrollbackWriter | undefined,
+): boolean {
+  if (state[phase]) {
+    return false
+  }
+
+  if (!write) {
+    return false
+  }
+
+  state[phase] = true
+  renderer.writeToScrollback(write)
+  renderer.requestRender()
+  return true
+}
+
+// Boots the split-footer renderer and constructs the RunFooter.
+//
+// The renderer starts in split-footer mode with captured stdout so that
+// scrollback commits and footer repaints happen in the same frame. After
+// the entry splash, RunFooter takes over the footer region.
+export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
+  const renderer = await createCliRenderer({
+    targetFps: 30,
+    maxFps: 60,
+    useMouse: false,
+    autoFocus: false,
+    openConsoleOnError: false,
+    exitOnCtrlC: false,
+    useKittyKeyboard: { events: process.platform === "win32" },
+    screenMode: "split-footer",
+    footerHeight: FOOTER_HEIGHT,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+    clearOnShutdown: false,
+  })
+  let theme = await resolveRunTheme(renderer)
+  renderer.setBackgroundColor(theme.background)
+  const state: SplashState = {
+    entry: false,
+    exit: false,
+  }
+  const meta = splashMeta({
+    title: splashTitle(input.sessionTitle, input.history),
+    session_id: input.sessionID,
+  })
+  queueSplash(
+    renderer,
+    state,
+    "entry",
+    entrySplash({
+      ...meta,
+      theme: theme.entry,
+      background: theme.background,
+    }),
+  )
+  await renderer.idle().catch(() => {})
+
+  const { RunFooter } = await import("./footer")
+
+  const labels = footerLabels({
+    agent: input.agent,
+    model: input.model,
+    variant: input.variant,
+  })
+  const footer = new RunFooter(renderer, {
+    ...labels,
+    first: input.first,
+    history: input.history,
+    theme,
+    keybinds: input.keybinds,
+    diffStyle: input.diffStyle,
+    onPermissionReply: input.onPermissionReply,
+    onQuestionReply: input.onQuestionReply,
+    onQuestionReject: input.onQuestionReject,
+    onCycleVariant: input.onCycleVariant,
+    onInterrupt: input.onInterrupt,
+  })
+
+  const sigint = () => {
+    footer.requestExit()
+  }
+  process.on("SIGINT", sigint)
+
+  let closed = false
+  const close = async (next: { showExit: boolean }) => {
+    if (closed) {
+      return
+    }
+
+    closed = true
+    process.off("SIGINT", sigint)
+
+    try {
+      const show = renderer.isDestroyed ? false : next.showExit
+      if (!renderer.isDestroyed && show) {
+        queueSplash(
+          renderer,
+          state,
+          "exit",
+          exitSplash({
+            ...meta,
+            theme: theme.entry,
+            background: theme.background,
+          }),
+        )
+        await renderer.idle().catch(() => {})
+      }
+    } finally {
+      footer.close()
+      footer.destroy()
+      shutdown(renderer)
+    }
+  }
+
+  return {
+    footer,
+    close,
+  }
+}

+ 214 - 0
packages/opencode/src/cli/cmd/run/runtime.queue.ts

@@ -0,0 +1,214 @@
+// Serial prompt queue for direct interactive mode.
+//
+// Prompts arrive from the footer (user types and hits enter) and queue up
+// here. The queue drains one turn at a time: it appends the user row to
+// scrollback, calls input.run() to execute the turn through the stream
+// transport, and waits for completion before starting the next prompt.
+//
+// The queue also handles /exit and /quit commands, empty-prompt rejection,
+// and tracks per-turn wall-clock duration for the footer status line.
+//
+// Resolves when the footer closes and all in-flight work finishes.
+import { Locale } from "../../../util/locale"
+import { isExitCommand } from "./prompt.shared"
+import type { FooterApi, FooterEvent } from "./types"
+
+type Trace = {
+  write(type: string, data?: unknown): void
+}
+
+export type QueueInput = {
+  footer: FooterApi
+  initialInput?: string
+  trace?: Trace
+  onPrompt?: () => void
+  run: (prompt: string, signal: AbortSignal) => Promise<void>
+}
+
+// Runs the prompt queue until the footer closes.
+//
+// Subscribes to footer prompt events, queues them, and drains one at a
+// time through input.run(). If the user submits multiple prompts while
+// a turn is running, they queue up and execute in order. The footer shows
+// the queue depth so the user knows how many are pending.
+export async function runPromptQueue(input: QueueInput): Promise<void> {
+  const q: string[] = []
+  let busy = false
+  let closed = input.footer.isClosed
+  let ctrl: AbortController | undefined
+  let stop: (() => void) | undefined
+  let err: unknown
+  let hasErr = false
+  let done: (() => void) | undefined
+  const wait = new Promise<void>((resolve) => {
+    done = resolve
+  })
+  const until = new Promise<void>((resolve) => {
+    stop = resolve
+  })
+
+  const fail = (error: unknown) => {
+    err = error
+    hasErr = true
+    done?.()
+    done = undefined
+  }
+
+  const finish = () => {
+    if (!closed || busy) {
+      return
+    }
+
+    done?.()
+    done = undefined
+  }
+
+  const emit = (next: FooterEvent, row: Record<string, unknown>) => {
+    input.trace?.write("ui.patch", row)
+    input.footer.event(next)
+  }
+
+  const pump = async () => {
+    if (busy || closed) {
+      return
+    }
+
+    busy = true
+
+    try {
+      while (!closed && q.length > 0) {
+        const prompt = q.shift()
+        if (!prompt) {
+          continue
+        }
+
+        emit(
+          {
+            type: "turn.send",
+            queue: q.length,
+          },
+          {
+            phase: "running",
+            status: "sending prompt",
+            queue: q.length,
+          },
+        )
+        const start = Date.now()
+        const next = new AbortController()
+        ctrl = next
+        try {
+          const task = input.run(prompt, next.signal).then(
+            () => ({ type: "done" as const }),
+            (error) => ({ type: "error" as const, error }),
+          )
+          await input.footer.idle()
+          const commit = { kind: "user", text: prompt, phase: "start", source: "system" } as const
+          input.trace?.write("ui.commit", commit)
+          input.footer.append(commit)
+          const out = await Promise.race([task, until.then(() => ({ type: "closed" as const }))])
+          if (out.type === "closed") {
+            next.abort()
+            break
+          }
+
+          if (out.type === "error") {
+            throw out.error
+          }
+        } finally {
+          if (ctrl === next) {
+            ctrl = undefined
+          }
+          const duration = Locale.duration(Math.max(0, Date.now() - start))
+          emit(
+            {
+              type: "turn.duration",
+              duration,
+            },
+            {
+              duration,
+            },
+          )
+        }
+      }
+    } finally {
+      busy = false
+      emit(
+        {
+          type: "turn.idle",
+          queue: q.length,
+        },
+        {
+          phase: "idle",
+          status: "",
+          queue: q.length,
+        },
+      )
+      finish()
+    }
+  }
+
+  const push = (text: string) => {
+    const prompt = text
+    if (!prompt.trim() || closed) {
+      return
+    }
+
+    if (isExitCommand(prompt)) {
+      input.footer.close()
+      return
+    }
+
+    input.onPrompt?.()
+    q.push(prompt)
+    emit(
+      {
+        type: "queue",
+        queue: q.length,
+      },
+      {
+        queue: q.length,
+      },
+    )
+    emit(
+      {
+        type: "first",
+        first: false,
+      },
+      {
+        first: false,
+      },
+    )
+    void pump().catch(fail)
+  }
+
+  const offPrompt = input.footer.onPrompt((text) => {
+    push(text)
+  })
+  const offClose = input.footer.onClose(() => {
+    closed = true
+    q.length = 0
+    ctrl?.abort()
+    stop?.()
+    finish()
+  })
+
+  try {
+    if (closed) {
+      return
+    }
+
+    push(input.initialInput ?? "")
+    await pump()
+
+    if (!closed) {
+      await wait
+    }
+
+    if (hasErr) {
+      throw err
+    }
+  } finally {
+    offPrompt()
+    offClose()
+  }
+}

+ 292 - 0
packages/opencode/src/cli/cmd/run/runtime.ts

@@ -0,0 +1,292 @@
+// Top-level orchestrator for `run --interactive`.
+//
+// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
+// and prompt queue together into a single session loop. Two entry points:
+//
+//   runInteractiveMode     -- used when an SDK client already exists (attach mode)
+//   runInteractiveLocalMode -- used for local in-process mode (no server)
+//
+// Both delegate to runInteractiveRuntime, which:
+//   1. resolves keybinds, diff style, model info, and session history,
+//   2. creates the split-footer lifecycle (renderer + RunFooter),
+//   3. starts the stream transport (SDK event subscription),
+//   4. runs the prompt queue until the footer closes.
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { createRunDemo } from "./demo"
+import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
+import { createRuntimeLifecycle } from "./runtime.lifecycle"
+import { trace } from "./trace"
+import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
+import type { RunInput } from "./types"
+
+/** @internal Exported for testing */
+export { pickVariant, resolveVariant } from "./variant.shared"
+
+/** @internal Exported for testing */
+export { runPromptQueue } from "./runtime.queue"
+
+type BootContext = Pick<RunInput, "sdk" | "sessionID" | "sessionTitle" | "agent" | "model" | "variant">
+
+type RunRuntimeInput = {
+  boot: () => Promise<BootContext>
+  afterPaint?: (ctx: BootContext) => Promise<void> | void
+  files: RunInput["files"]
+  initialInput?: string
+  thinking: boolean
+  demo?: RunInput["demo"]
+  demoText?: RunInput["demoText"]
+}
+
+type RunLocalInput = {
+  fetch: typeof globalThis.fetch
+  resolveAgent: () => Promise<string | undefined>
+  session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
+  share: (sdk: RunInput["sdk"], sessionID: string) => Promise<void>
+  agent: RunInput["agent"]
+  model: RunInput["model"]
+  variant: RunInput["variant"]
+  files: RunInput["files"]
+  initialInput?: string
+  thinking: boolean
+  demo?: RunInput["demo"]
+  demoText?: RunInput["demoText"]
+}
+
+// Core runtime loop. Boot resolves the SDK context, then we set up the
+// lifecycle (renderer + footer), wire the stream transport for SDK events,
+// and feed prompts through the queue until the user exits.
+//
+// Files only attach on the first prompt turn -- after that, includeFiles
+// flips to false so subsequent turns don't re-send attachments.
+async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
+  const log = trace()
+  const keybindTask = resolveFooterKeybinds()
+  const diffTask = resolveDiffStyle()
+  const ctx = await input.boot()
+  const modelTask = resolveModelInfo(ctx.sdk, ctx.model)
+  const sessionTask = resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
+  const savedTask = resolveSavedVariant(ctx.model)
+  let variants: string[] = []
+  let limits: Record<string, number> = {}
+  let aborting = false
+  let shown = false
+  let demo: ReturnType<typeof createRunDemo> | undefined
+  const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
+    keybindTask,
+    diffTask,
+    sessionTask,
+    savedTask,
+  ])
+  shown = !session.first
+  let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
+
+  const shell = await createRuntimeLifecycle({
+    sessionID: ctx.sessionID,
+    sessionTitle: ctx.sessionTitle,
+    first: session.first,
+    history: session.history,
+    agent: ctx.agent,
+    model: ctx.model,
+    variant: activeVariant,
+    keybinds,
+    diffStyle,
+    onPermissionReply: async (next) => {
+      if (demo?.permission(next)) {
+        return
+      }
+
+      log?.write("send.permission.reply", next)
+      await ctx.sdk.permission.reply(next)
+    },
+    onQuestionReply: async (next) => {
+      if (demo?.questionReply(next)) {
+        return
+      }
+
+      await ctx.sdk.question.reply(next)
+    },
+    onQuestionReject: async (next) => {
+      if (demo?.questionReject(next)) {
+        return
+      }
+
+      await ctx.sdk.question.reject(next)
+    },
+    onCycleVariant: () => {
+      if (!ctx.model || variants.length === 0) {
+        return {
+          status: "no variants available",
+        }
+      }
+
+      activeVariant = cycleVariant(activeVariant, variants)
+      saveVariant(ctx.model, activeVariant)
+      return {
+        status: activeVariant ? `variant ${activeVariant}` : "variant default",
+        modelLabel: formatModelLabel(ctx.model, activeVariant),
+      }
+    },
+    onInterrupt: () => {
+      if (aborting) {
+        return
+      }
+
+      aborting = true
+      void ctx.sdk.session
+        .abort({
+          sessionID: ctx.sessionID,
+        })
+        .catch(() => {})
+        .finally(() => {
+          aborting = false
+        })
+    },
+  })
+  const footer = shell.footer
+
+  if (input.demo) {
+    demo = createRunDemo({
+      mode: input.demo,
+      text: input.demoText,
+      footer,
+      sessionID: ctx.sessionID,
+      thinking: input.thinking,
+      limits: () => limits,
+    })
+  }
+
+  if (input.afterPaint) {
+    void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
+  }
+
+  void modelTask.then((info) => {
+    variants = info.variants
+    limits = info.limits
+
+    const next = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
+    if (next === activeVariant) {
+      return
+    }
+
+    activeVariant = next
+    if (!ctx.model || footer.isClosed) {
+      return
+    }
+
+    footer.event({
+      type: "model",
+      model: formatModelLabel(ctx.model, activeVariant),
+    })
+  })
+
+  try {
+    const mod = await import("./stream.transport")
+    let includeFiles = true
+    const stream = await mod.createSessionTransport({
+      sdk: ctx.sdk,
+      sessionID: ctx.sessionID,
+      thinking: input.thinking,
+      limits: () => limits,
+      footer,
+      trace: log,
+    })
+
+    try {
+      if (demo) {
+        await demo.start()
+      }
+
+      const queue = await import("./runtime.queue")
+      await queue.runPromptQueue({
+        footer,
+        initialInput: input.initialInput,
+        trace: log,
+        onPrompt: () => {
+          shown = true
+        },
+        run: async (prompt, signal) => {
+          if (demo && (await demo.prompt(prompt, signal))) {
+            return
+          }
+
+          try {
+            await stream.runPromptTurn({
+              agent: ctx.agent,
+              model: ctx.model,
+              variant: activeVariant,
+              prompt,
+              files: input.files,
+              includeFiles,
+              signal,
+            })
+            includeFiles = false
+          } catch (error) {
+            if (signal.aborted || footer.isClosed) {
+              return
+            }
+            footer.append({ kind: "error", text: mod.formatUnknownError(error), phase: "start", source: "system" })
+          }
+        },
+      })
+    } finally {
+      await stream.close()
+    }
+  } finally {
+    await shell.close({
+      showExit: shown,
+    })
+  }
+}
+
+// Local in-process mode. Creates an SDK client backed by a direct fetch to
+// the in-process server, so no external HTTP server is needed.
+export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
+  const sdk = createOpencodeClient({
+    baseUrl: "http://opencode.internal",
+    fetch: input.fetch,
+  })
+
+  return runInteractiveRuntime({
+    files: input.files,
+    initialInput: input.initialInput,
+    thinking: input.thinking,
+    demo: input.demo,
+    demoText: input.demoText,
+    afterPaint: (ctx) => input.share(ctx.sdk, ctx.sessionID),
+    boot: async () => {
+      const agent = await input.resolveAgent()
+      const session = await input.session(sdk)
+      if (!session?.id) {
+        throw new Error("Session not found")
+      }
+
+      return {
+        sdk,
+        sessionID: session.id,
+        sessionTitle: session.title,
+        agent,
+        model: input.model,
+        variant: input.variant,
+      }
+    },
+  })
+}
+
+// Attach mode. Uses the caller-provided SDK client directly.
+export async function runInteractiveMode(input: RunInput): Promise<void> {
+  return runInteractiveRuntime({
+    files: input.files,
+    initialInput: input.initialInput,
+    thinking: input.thinking,
+    demo: input.demo,
+    demoText: input.demoText,
+    boot: async () => ({
+      sdk: input.sdk,
+      sessionID: input.sessionID,
+      sessionTitle: input.sessionTitle,
+      agent: input.agent,
+      model: input.model,
+      variant: input.variant,
+    }),
+  })
+}

+ 92 - 0
packages/opencode/src/cli/cmd/run/scrollback.format.ts

@@ -0,0 +1,92 @@
+// Text normalization for scrollback entries.
+//
+// Transforms a StreamCommit into the final text that will be appended to
+// terminal scrollback. Each entry kind has its own formatting:
+//
+//   user       → prefixed with "› "
+//   assistant  → raw text (progress), empty (start/final unless interrupted)
+//   reasoning  → raw text with [REDACTED] stripped
+//   tool       → delegated to tool.ts for per-tool scrollback formatting
+//   error/system → raw trimmed text
+//
+// Returns an empty string when the commit should produce no visible output
+// (e.g., assistant start events, empty final events).
+import { toolFrame, toolScroll, toolView } from "./tool"
+import type { StreamCommit } from "./types"
+
+export function clean(text: string): string {
+  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
+}
+
+function toolText(commit: StreamCommit, raw: string): string {
+  const ctx = toolFrame(commit, raw)
+  const view = toolView(ctx.name)
+
+  if (commit.phase === "progress" && !view.output) {
+    return ""
+  }
+
+  if (commit.phase === "final") {
+    if (ctx.status === "error") {
+      return toolScroll("final", ctx)
+    }
+
+    if (!view.final) {
+      return ""
+    }
+
+    if (ctx.status && ctx.status !== "completed") {
+      return ctx.raw.trim()
+    }
+  }
+
+  return toolScroll(commit.phase, ctx)
+}
+
+export function normalizeEntry(commit: StreamCommit): string {
+  const raw = clean(commit.text)
+
+  if (commit.kind === "user") {
+    if (!raw.trim()) {
+      return ""
+    }
+
+    const lead = raw.match(/^\n+/)?.[0] ?? ""
+    const body = lead ? raw.slice(lead.length) : raw
+    return `${lead}› ${body}`
+  }
+
+  if (commit.kind === "tool") {
+    return toolText(commit, raw)
+  }
+
+  if (commit.kind === "assistant") {
+    if (commit.phase === "start") {
+      return ""
+    }
+
+    if (commit.phase === "final") {
+      return commit.interrupted ? "assistant interrupted" : ""
+    }
+
+    return raw
+  }
+
+  if (commit.kind === "reasoning") {
+    if (commit.phase === "start") {
+      return ""
+    }
+
+    if (commit.phase === "final") {
+      return commit.interrupted ? "reasoning interrupted" : ""
+    }
+
+    return raw.replace(/\[REDACTED\]/g, "")
+  }
+
+  if (commit.phase === "start" || commit.phase === "final") {
+    return raw.trim()
+  }
+
+  return raw
+}

+ 26 - 0
packages/opencode/src/cli/cmd/run/scrollback.tsx

@@ -0,0 +1,26 @@
+// Entry writer routing for scrollback commits.
+//
+// Decides whether a commit should render as plain text or as a rich snapshot
+// (code block, diff view, task card, etc.). Completed tool parts whose tool
+// rule has a "snap" mode get routed to snapEntryWriter, which produces a
+// structured JSX snapshot. Everything else goes through textEntryWriter.
+import type { ScrollbackWriter } from "@opentui/core"
+import { toolView } from "./tool"
+import { snapEntryWriter, textEntryWriter } from "./scrollback.writer"
+import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
+import type { ScrollbackOptions, StreamCommit } from "./types"
+
+export function entryWriter(
+  commit: StreamCommit,
+  theme: RunTheme = RUN_THEME_FALLBACK,
+  opts: ScrollbackOptions = {},
+): ScrollbackWriter {
+  const state = commit.toolState ?? commit.part?.state.status
+  if (commit.kind === "tool" && commit.phase === "final" && state === "completed") {
+    if (toolView(commit.tool).snap) {
+      return snapEntryWriter(commit, theme, opts)
+    }
+  }
+
+  return textEntryWriter(commit, theme.entry)
+}

+ 635 - 0
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx

@@ -0,0 +1,635 @@
+// JSX-based scrollback snapshot writers for rich tool output.
+//
+// When a tool commit has a "snap" mode (code, diff, task, todo, question),
+// snapEntryWriter renders it as a structured JSX tree that OpenTUI converts
+// into a ScrollbackSnapshot. These snapshots support syntax highlighting,
+// unified/split diffs, line numbers, and LSP diagnostics.
+//
+// The writers use OpenTUI's createScrollbackWriter to produce snapshots.
+// OpenTUI measures and reflows them when the terminal resizes. The fit()
+// helper measures actual rendered width so narrow content doesn't claim
+// the full terminal width.
+//
+// Plain text entries (textEntryWriter) also go through here -- they just
+// produce a simple <text> element with the right color and attributes.
+/** @jsxImportSource @opentui/solid */
+
+import {
+  SyntaxStyle,
+  TextAttributes,
+  type ColorInput,
+  type ScrollbackRenderContext,
+  type ScrollbackSnapshot,
+  type ScrollbackWriter,
+} from "@opentui/core"
+import { createScrollbackWriter, type JSX } from "@opentui/solid"
+import { For, Show } from "solid-js"
+import { Filesystem } from "../../../util/filesystem"
+import { toolDiffView, toolFiletype, toolFrame, toolSnapshot } from "./tool"
+import { clean, normalizeEntry } from "./scrollback.format"
+import { RUN_THEME_FALLBACK, type RunEntryTheme, type RunTheme } from "./theme"
+import type { ScrollbackOptions, StreamCommit } from "./types"
+
+type ToolDict = Record<string, unknown>
+
+function dict(v: unknown): ToolDict {
+  if (!v || typeof v !== "object") {
+    return {}
+  }
+
+  return v as ToolDict
+}
+
+function text(v: unknown): string {
+  return typeof v === "string" ? v : ""
+}
+
+function arr(v: unknown): unknown[] {
+  return Array.isArray(v) ? v : []
+}
+
+function num(v: unknown): number | undefined {
+  if (typeof v !== "number" || !Number.isFinite(v)) {
+    return
+  }
+
+  return v
+}
+
+function diagnostics(meta: ToolDict, file: string): string[] {
+  const all = dict(meta.diagnostics)
+  const key = Filesystem.normalizePath(file)
+  const list = arr(all[key]).map(dict)
+  return list
+    .filter((item) => item.severity === 1)
+    .slice(0, 3)
+    .map((item) => {
+      const range = dict(item.range)
+      const start = dict(range.start)
+      const line = num(start.line)
+      const char = num(start.character)
+      const msg = text(item.message)
+      if (line === undefined || char === undefined) {
+        return `Error ${msg}`.trim()
+      }
+
+      return `Error [${line + 1}:${char + 1}] ${msg}`.trim()
+    })
+}
+
+type Flags = {
+  startOnNewLine: boolean
+  trailingNewline: boolean
+}
+
+type Paint = {
+  fg: ColorInput
+  attrs?: number
+}
+
+type CodeInput = {
+  title: string
+  content: string
+  filetype?: string
+  diagnostics: string[]
+}
+
+type DiffInput = {
+  title: string
+  diff?: string
+  filetype?: string
+  deletions?: number
+  diagnostics: string[]
+}
+
+type TaskInput = {
+  title: string
+  rows: string[]
+  tail: string
+}
+
+type TodoInput = {
+  items: Array<{
+    status: string
+    content: string
+  }>
+  tail: string
+}
+
+type QuestionInput = {
+  items: Array<{
+    question: string
+    answer: string
+  }>
+  tail: string
+}
+
+type Measure = {
+  widthColsMax: number
+}
+
+type MeasureNode = {
+  textBufferView?: {
+    measureForDimensions(width: number, height: number): Measure | null
+  }
+  getChildren?: () => unknown[]
+}
+
+let bare: SyntaxStyle | undefined
+
+function syntax(style?: SyntaxStyle): SyntaxStyle {
+  if (style) {
+    return style
+  }
+
+  bare ??= SyntaxStyle.fromTheme([])
+  return bare
+}
+
+function failed(commit: StreamCommit): boolean {
+  return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
+}
+
+function look(commit: StreamCommit, theme: RunEntryTheme): Paint {
+  if (commit.kind === "user") {
+    return {
+      fg: theme.user.body,
+      attrs: TextAttributes.BOLD,
+    }
+  }
+
+  if (failed(commit)) {
+    return {
+      fg: theme.error.body,
+      attrs: TextAttributes.BOLD,
+    }
+  }
+
+  if (commit.phase === "final") {
+    return {
+      fg: theme.system.body,
+      attrs: TextAttributes.DIM,
+    }
+  }
+
+  if (commit.kind === "assistant") {
+    return { fg: theme.assistant.body }
+  }
+
+  if (commit.kind === "reasoning") {
+    return {
+      fg: theme.reasoning.body,
+      attrs: TextAttributes.DIM,
+    }
+  }
+
+  if (commit.kind === "error") {
+    return {
+      fg: theme.error.body,
+      attrs: TextAttributes.BOLD,
+    }
+  }
+
+  if (commit.kind === "tool") {
+    return { fg: theme.tool.body }
+  }
+
+  return { fg: theme.system.body }
+}
+
+function cols(ctx: ScrollbackRenderContext): number {
+  return Math.max(1, Math.trunc(ctx.width))
+}
+
+function leaf(node: unknown): MeasureNode | undefined {
+  if (!node || typeof node !== "object") {
+    return
+  }
+
+  const next = node as MeasureNode
+  if (next.textBufferView) {
+    return next
+  }
+
+  const list = next.getChildren?.() ?? []
+  for (const child of list) {
+    const out = leaf(child)
+    if (out) {
+      return out
+    }
+  }
+}
+
+function fit(snapshot: ScrollbackSnapshot, ctx: ScrollbackRenderContext) {
+  const node = leaf(snapshot.root)
+  const width = cols(ctx)
+  const box = node?.textBufferView?.measureForDimensions(width, Math.max(1, snapshot.height ?? 1))
+  const rowColumns = Math.max(1, Math.min(width, box?.widthColsMax ?? 0))
+
+  snapshot.width = width
+  snapshot.rowColumns = rowColumns
+  return snapshot
+}
+
+function full(node: () => JSX.Element, ctx: ScrollbackRenderContext, flags: Flags) {
+  return createScrollbackWriter(node, {
+    width: cols(ctx),
+    rowColumns: cols(ctx),
+    startOnNewLine: flags.startOnNewLine,
+    trailingNewline: flags.trailingNewline,
+  })(ctx)
+}
+
+function TextEntry(props: { body: string; fg: ColorInput; attrs?: number }) {
+  return (
+    <text width="100%" wrapMode="word" fg={props.fg} attributes={props.attrs}>
+      {props.body}
+    </text>
+  )
+}
+
+function thinking(body: string) {
+  const mark = "Thinking: "
+  if (body.startsWith(mark)) {
+    return {
+      head: mark,
+      tail: body.slice(mark.length),
+    }
+  }
+
+  return {
+    tail: body,
+  }
+}
+
+function ReasoningEntry(props: { body: string; theme: RunEntryTheme }) {
+  const part = thinking(props.body)
+  return (
+    <text
+      width="100%"
+      wrapMode="word"
+      fg={props.theme.reasoning.body}
+      attributes={TextAttributes.DIM | TextAttributes.ITALIC}
+    >
+      <Show when={part.head}>{part.head}</Show>
+      {part.tail}
+    </text>
+  )
+}
+
+function Diagnostics(props: { theme: RunTheme; lines: string[] }) {
+  return (
+    <Show when={props.lines.length > 0}>
+      <box>
+        <For each={props.lines}>{(line) => <text fg={props.theme.entry.error.body}>{line}</text>}</For>
+      </box>
+    </Show>
+  )
+}
+
+function BlockTool(props: { theme: RunTheme; title: string; children: JSX.Element }) {
+  return (
+    <box flexDirection="column" gap={1}>
+      <text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
+        {props.title}
+      </text>
+      {props.children}
+    </box>
+  )
+}
+
+function CodeTool(props: { theme: RunTheme; data: CodeInput }) {
+  return (
+    <BlockTool theme={props.theme} title={props.data.title}>
+      <line_number fg={props.theme.block.muted} minWidth={3} paddingRight={1}>
+        <code
+          conceal={false}
+          fg={props.theme.block.text}
+          filetype={props.data.filetype}
+          syntaxStyle={syntax(props.theme.block.syntax)}
+          content={props.data.content}
+          drawUnstyledText={true}
+          wrapMode="word"
+        />
+      </line_number>
+      <Diagnostics theme={props.theme} lines={props.data.diagnostics} />
+    </BlockTool>
+  )
+}
+
+function DiffTool(props: { theme: RunTheme; data: DiffInput; view: "unified" | "split" }) {
+  return (
+    <BlockTool theme={props.theme} title={props.data.title}>
+      <Show
+        when={props.data.diff?.trim()}
+        fallback={
+          <text fg={props.theme.block.diffRemoved}>
+            -{props.data.deletions ?? 0} line{props.data.deletions === 1 ? "" : "s"}
+          </text>
+        }
+      >
+        <box>
+          <diff
+            diff={props.data.diff ?? ""}
+            view={props.view}
+            filetype={props.data.filetype}
+            syntaxStyle={syntax(props.theme.block.syntax)}
+            showLineNumbers={true}
+            width="100%"
+            wrapMode="word"
+            fg={props.theme.block.text}
+            addedBg={props.theme.block.diffAddedBg}
+            removedBg={props.theme.block.diffRemovedBg}
+            contextBg={props.theme.block.diffContextBg}
+            addedSignColor={props.theme.block.diffHighlightAdded}
+            removedSignColor={props.theme.block.diffHighlightRemoved}
+            lineNumberFg={props.theme.block.diffLineNumber}
+            lineNumberBg={props.theme.block.diffContextBg}
+            addedLineNumberBg={props.theme.block.diffAddedLineNumberBg}
+            removedLineNumberBg={props.theme.block.diffRemovedLineNumberBg}
+          />
+        </box>
+      </Show>
+      <Diagnostics theme={props.theme} lines={props.data.diagnostics} />
+    </BlockTool>
+  )
+}
+
+function TaskTool(props: { theme: RunTheme; data: TaskInput }) {
+  return (
+    <BlockTool theme={props.theme} title={props.data.title}>
+      <box>
+        <For each={props.data.rows}>{(line) => <text fg={props.theme.block.text}>{line}</text>}</For>
+      </box>
+      <text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
+        {props.data.tail}
+      </text>
+    </BlockTool>
+  )
+}
+
+function todoMark(status: string): string {
+  if (status === "completed") {
+    return "[x]"
+  }
+  if (status === "in_progress") {
+    return "[>]"
+  }
+  if (status === "cancelled") {
+    return "[-]"
+  }
+  return "[ ]"
+}
+
+function TodoTool(props: { theme: RunTheme; data: TodoInput }) {
+  return (
+    <BlockTool theme={props.theme} title="# Todos">
+      <box>
+        <For each={props.data.items}>
+          {(item) => (
+            <text fg={props.theme.block.text}>
+              {todoMark(item.status)} {item.content}
+            </text>
+          )}
+        </For>
+      </box>
+      <text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
+        {props.data.tail}
+      </text>
+    </BlockTool>
+  )
+}
+
+function QuestionTool(props: { theme: RunTheme; data: QuestionInput }) {
+  return (
+    <BlockTool theme={props.theme} title="# Questions">
+      <text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
+        {props.data.tail}
+      </text>
+      <box gap={1}>
+        <For each={props.data.items}>
+          {(item) => (
+            <box flexDirection="column">
+              <text fg={props.theme.block.muted}>{item.question}</text>
+              <text fg={props.theme.block.text}>{item.answer}</text>
+            </box>
+          )}
+        </For>
+      </box>
+    </BlockTool>
+  )
+}
+
+function textWriter(body: string, commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
+  const style = look(commit, theme)
+  return (ctx) =>
+    fit(
+      createScrollbackWriter(() => <TextEntry body={body} fg={style.fg} attrs={style.attrs} />, {
+        width: cols(ctx),
+        startOnNewLine: flags.startOnNewLine,
+        trailingNewline: flags.trailingNewline,
+      })(ctx),
+      ctx,
+    )
+}
+
+function reasoningWriter(body: string, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
+  return (ctx) =>
+    fit(
+      createScrollbackWriter(() => <ReasoningEntry body={body} theme={theme} />, {
+        width: cols(ctx),
+        startOnNewLine: flags.startOnNewLine,
+        trailingNewline: flags.trailingNewline,
+      })(ctx),
+      ctx,
+    )
+}
+
+function blankWriter(): ScrollbackWriter {
+  return (ctx) =>
+    createScrollbackWriter(() => <text width="100%" />, {
+      width: cols(ctx),
+      startOnNewLine: true,
+      trailingNewline: true,
+    })(ctx)
+}
+
+function textBlockWriter(body: string, theme: RunEntryTheme): ScrollbackWriter {
+  return (ctx) =>
+    full(() => <TextEntry body={body.endsWith("\n") ? body : `${body}\n`} fg={theme.system.body} />, ctx, {
+      startOnNewLine: true,
+      trailingNewline: false,
+    })
+}
+
+function codeWriter(data: CodeInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
+  return (ctx) => full(() => <CodeTool theme={theme} data={data} />, ctx, flags)
+}
+
+function diffWriter(list: DiffInput[], theme: RunTheme, flags: Flags, view: "unified" | "split"): ScrollbackWriter {
+  return (ctx) =>
+    full(
+      () => (
+        <box flexDirection="column" gap={1}>
+          <For each={list}>{(data) => <DiffTool theme={theme} data={data} view={view} />}</For>
+        </box>
+      ),
+      ctx,
+      flags,
+    )
+}
+
+function taskWriter(data: TaskInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
+  return (ctx) => full(() => <TaskTool theme={theme} data={data} />, ctx, flags)
+}
+
+function todoWriter(data: TodoInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
+  return (ctx) => full(() => <TodoTool theme={theme} data={data} />, ctx, flags)
+}
+
+function questionWriter(data: QuestionInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
+  return (ctx) => full(() => <QuestionTool theme={theme} data={data} />, ctx, flags)
+}
+
+function flags(commit: StreamCommit): Flags {
+  if (commit.kind === "user") {
+    return {
+      startOnNewLine: true,
+      trailingNewline: false,
+    }
+  }
+
+  if (commit.kind === "tool") {
+    if (commit.phase === "progress") {
+      return {
+        startOnNewLine: false,
+        trailingNewline: false,
+      }
+    }
+
+    return {
+      startOnNewLine: true,
+      trailingNewline: true,
+    }
+  }
+
+  if (commit.kind === "assistant" || commit.kind === "reasoning") {
+    if (commit.phase === "progress") {
+      return {
+        startOnNewLine: false,
+        trailingNewline: false,
+      }
+    }
+
+    return {
+      startOnNewLine: true,
+      trailingNewline: true,
+    }
+  }
+
+  return {
+    startOnNewLine: true,
+    trailingNewline: true,
+  }
+}
+
+export function textEntryWriter(commit: StreamCommit, theme: RunEntryTheme): ScrollbackWriter {
+  const body = normalizeEntry(commit)
+  const snap = flags(commit)
+  if (commit.kind === "reasoning") {
+    return reasoningWriter(body, theme, snap)
+  }
+
+  return textWriter(body, commit, theme, snap)
+}
+
+export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: ScrollbackOptions): ScrollbackWriter {
+  const snap = toolSnapshot(commit, clean(commit.text))
+  if (!snap) {
+    return textEntryWriter(commit, theme.entry)
+  }
+
+  const info = toolFrame(commit, clean(commit.text))
+  const style = flags(commit)
+
+  if (snap.kind === "code") {
+    return codeWriter(
+      {
+        title: snap.title,
+        content: snap.content,
+        filetype: toolFiletype(snap.file),
+        diagnostics: diagnostics(info.meta, snap.file ?? ""),
+      },
+      theme,
+      style,
+    )
+  }
+
+  if (snap.kind === "diff") {
+    if (snap.items.length === 0) {
+      return textEntryWriter(commit, theme.entry)
+    }
+
+    const list = snap.items
+      .map((item) => {
+        if (!item.diff.trim()) {
+          return
+        }
+
+        return {
+          title: item.title,
+          diff: item.diff,
+          filetype: toolFiletype(item.file),
+          deletions: item.deletions,
+          diagnostics: diagnostics(info.meta, item.file ?? ""),
+        }
+      })
+      .filter((item): item is NonNullable<typeof item> => Boolean(item))
+
+    if (list.length === 0) {
+      return textEntryWriter(commit, theme.entry)
+    }
+
+    return (ctx) => diffWriter(list, theme, style, toolDiffView(ctx.width, opts.diffStyle))(ctx)
+  }
+
+  if (snap.kind === "task") {
+    return taskWriter(
+      {
+        title: snap.title,
+        rows: snap.rows,
+        tail: snap.tail,
+      },
+      theme,
+      style,
+    )
+  }
+
+  if (snap.kind === "todo") {
+    return todoWriter(
+      {
+        items: snap.items,
+        tail: snap.tail,
+      },
+      theme,
+      style,
+    )
+  }
+
+  return questionWriter(
+    {
+      items: snap.items,
+      tail: snap.tail,
+    },
+    theme,
+    style,
+  )
+}
+
+export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter {
+  return textBlockWriter(clean(text), theme)
+}
+
+export function spacerWriter(): ScrollbackWriter {
+  return blankWriter()
+}

+ 881 - 0
packages/opencode/src/cli/cmd/run/session-data.ts

@@ -0,0 +1,881 @@
+// Core reducer for direct interactive mode.
+//
+// Takes raw SDK events and produces two outputs:
+//   - StreamCommit[]: append-only scrollback entries (text, tool, error, etc.)
+//   - FooterOutput:   status bar patches and view transitions (permission, question)
+//
+// The reducer mutates SessionData in place for performance but has no
+// external side effects -- no IO, no footer calls. The caller
+// (stream.transport.ts) feeds events in and forwards output to the footer
+// through stream.ts.
+//
+// Key design decisions:
+//
+// - Text parts buffer in `data.text` until their message role is confirmed as
+//   "assistant". This prevents echoing user-role text parts. The `ready()`
+//   check gates output: if we see a text delta before the message.updated
+//   event that tells us the role, we stash it and flush later via `replay()`.
+//
+// - Tool echo stripping: bash tools may echo their own output in the next
+//   assistant text part. `stashEcho()` records completed bash output, and
+//   `stripEcho()` removes it from the start of the next assistant chunk.
+//
+// - Permission and question requests queue in `data.permissions` and
+//   `data.questions`. The footer shows whichever is first. When a reply
+//   event arrives, the queue entry is removed and the footer falls back
+//   to the next pending request or to the prompt view.
+import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
+import { Locale } from "../../../util/locale"
+import { toolView } from "./tool"
+import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
+
+const money = new Intl.NumberFormat("en-US", {
+  style: "currency",
+  currency: "USD",
+})
+
+type Tokens = {
+  input?: number
+  output?: number
+  reasoning?: number
+  cache?: {
+    read?: number
+    write?: number
+  }
+}
+
+type PartKind = "assistant" | "reasoning"
+type MessageRole = "assistant" | "user"
+type Dict = Record<string, unknown>
+type SessionCommit = StreamCommit
+
+// Mutable accumulator for the reducer. Each field tracks a different aspect
+// of the stream so we can produce correct incremental output:
+//
+// - ids:    parts and error keys we've already committed (dedup guard)
+// - tools:  tool parts we've emitted a "start" for but not yet completed
+// - call:   tool call inputs, keyed by msg:call, for enriching permission views
+// - role:   message ID → "assistant" | "user", learned from message.updated
+// - msg:    part ID → message ID
+// - part:   part ID → "assistant" | "reasoning" (text parts only)
+// - text:   part ID → full accumulated text so far
+// - sent:   part ID → byte offset of last flushed text (for incremental output)
+// - end:    part IDs whose time.end has arrived (part is finished)
+// - echo:   message ID → bash outputs to strip from the next assistant chunk
+export type SessionData = {
+  announced: boolean
+  ids: Set<string>
+  tools: Set<string>
+  call: Map<string, Dict>
+  permissions: PermissionRequest[]
+  questions: QuestionRequest[]
+  role: Map<string, MessageRole>
+  msg: Map<string, string>
+  part: Map<string, PartKind>
+  text: Map<string, string>
+  sent: Map<string, number>
+  end: Set<string>
+  echo: Map<string, Set<string>>
+}
+
+export type SessionDataInput = {
+  data: SessionData
+  event: Event
+  sessionID: string
+  thinking: boolean
+  limits: Record<string, number>
+}
+
+export type SessionDataOutput = {
+  data: SessionData
+  commits: SessionCommit[]
+  footer?: FooterOutput
+}
+
+export function createSessionData(): SessionData {
+  return {
+    announced: false,
+    ids: new Set(),
+    tools: new Set(),
+    call: new Map(),
+    permissions: [],
+    questions: [],
+    role: new Map(),
+    msg: new Map(),
+    part: new Map(),
+    text: new Map(),
+    sent: new Map(),
+    end: new Set(),
+    echo: new Map(),
+  }
+}
+
+function modelKey(provider: string, model: string): string {
+  return `${provider}/${model}`
+}
+
+function formatUsage(
+  tokens: Tokens | undefined,
+  limit: number | undefined,
+  cost: number | undefined,
+): string | undefined {
+  const total =
+    (tokens?.input ?? 0) +
+    (tokens?.output ?? 0) +
+    (tokens?.reasoning ?? 0) +
+    (tokens?.cache?.read ?? 0) +
+    (tokens?.cache?.write ?? 0)
+
+  if (total <= 0) {
+    if (typeof cost === "number" && cost > 0) {
+      return money.format(cost)
+    }
+    return
+  }
+
+  const text =
+    limit && limit > 0 ? `${Locale.number(total)} (${Math.round((total / limit) * 100)}%)` : Locale.number(total)
+
+  if (typeof cost === "number" && cost > 0) {
+    return `${text} · ${money.format(cost)}`
+  }
+
+  return text
+}
+
+function formatError(error: {
+  name?: string
+  message?: string
+  data?: {
+    message?: string
+  }
+}): string {
+  if (error.data?.message) {
+    return String(error.data.message)
+  }
+
+  if (error.message) {
+    return String(error.message)
+  }
+
+  if (error.name) {
+    return String(error.name)
+  }
+
+  return "unknown error"
+}
+
+function isAbort(error: { name?: string } | undefined): boolean {
+  return error?.name === "MessageAbortedError"
+}
+
+function msgErr(id: string): string {
+  return `msg:${id}:error`
+}
+
+function patch(patch?: FooterPatch, view?: FooterView): FooterOutput | undefined {
+  if (!patch && !view) {
+    return
+  }
+
+  return {
+    patch,
+    view,
+  }
+}
+
+function out(data: SessionData, commits: SessionCommit[], footer?: FooterOutput): SessionDataOutput {
+  if (!footer) {
+    return {
+      data,
+      commits,
+    }
+  }
+
+  return {
+    data,
+    commits,
+    footer,
+  }
+}
+
+function pickView(data: SessionData): FooterView {
+  const permission = data.permissions[0]
+  if (permission) {
+    return { type: "permission", request: permission }
+  }
+
+  const question = data.questions[0]
+  if (question) {
+    return { type: "question", request: question }
+  }
+
+  return { type: "prompt" }
+}
+
+function queueFooter(data: SessionData): FooterOutput {
+  const view = pickView(data)
+  if (view.type === "permission") {
+    return {
+      view,
+      patch: { status: "awaiting permission" },
+    }
+  }
+
+  if (view.type === "question") {
+    return {
+      view,
+      patch: { status: "awaiting answer" },
+    }
+  }
+
+  return {
+    view,
+    patch: { status: "" },
+  }
+}
+
+function upsert<T extends { id: string }>(list: T[], item: T) {
+  const idx = list.findIndex((entry) => entry.id === item.id)
+  if (idx === -1) {
+    list.push(item)
+    return
+  }
+
+  list[idx] = item
+}
+
+function remove<T extends { id: string }>(list: T[], id: string): boolean {
+  const idx = list.findIndex((entry) => entry.id === id)
+  if (idx === -1) {
+    return false
+  }
+
+  list.splice(idx, 1)
+  return true
+}
+
+function key(msg: string, call: string): string {
+  return `${msg}:${call}`
+}
+
+function enrichPermission(data: SessionData, request: PermissionRequest): PermissionRequest {
+  if (!request.tool) {
+    return request
+  }
+
+  const input = data.call.get(key(request.tool.messageID, request.tool.callID))
+  if (!input) {
+    return request
+  }
+
+  const meta = request.metadata ?? {}
+  if (meta.input === input) {
+    return request
+  }
+
+  return {
+    ...request,
+    metadata: {
+      ...meta,
+      input,
+    },
+  }
+}
+
+// Updates the active permission request when the matching tool part gets
+// new input (e.g., a diff). This keeps the permission UI in sync with the
+// tool's evolving state. Only triggers a footer update if the currently
+// displayed permission was the one that changed.
+function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undefined {
+  data.call.set(key(part.messageID, part.callID), part.state.input)
+  if (data.permissions.length === 0) {
+    return
+  }
+
+  let changed = false
+  let active = false
+  data.permissions = data.permissions.map((request, index) => {
+    if (!request.tool || request.tool.messageID !== part.messageID || request.tool.callID !== part.callID) {
+      return request
+    }
+
+    const next = enrichPermission(data, request)
+    if (next === request) {
+      return request
+    }
+
+    changed = true
+    active ||= index === 0
+    return next
+  })
+
+  if (!changed || !active) {
+    return
+  }
+
+  return {
+    view: pickView(data),
+  }
+}
+
+function toolStatus(part: ToolPart): string {
+  if (part.tool !== "task") {
+    return `running ${part.tool}`
+  }
+
+  const state = part.state as {
+    input?: {
+      description?: unknown
+      subagent_type?: unknown
+    }
+  }
+  const desc = state.input?.description
+  if (typeof desc === "string" && desc.trim()) {
+    return `running ${desc.trim()}`
+  }
+
+  const type = state.input?.subagent_type
+  if (typeof type === "string" && type.trim()) {
+    return `running ${type.trim()}`
+  }
+
+  return "running task"
+}
+
+// Returns true if we can flush this part's text to scrollback.
+//
+// We gate on the message role being "assistant" because user-role messages
+// also contain text parts (the user's own input) which we don't want to
+// echo. If we haven't received the message.updated event yet, we return
+// false and the text stays buffered until replay() flushes it.
+function ready(data: SessionData, partID: string): boolean {
+  const msg = data.msg.get(partID)
+  if (!msg) {
+    return true
+  }
+
+  const role = data.role.get(msg)
+  if (!role) {
+    return false
+  }
+
+  return role === "assistant"
+}
+
+function syncText(data: SessionData, partID: string, next: string) {
+  const prev = data.text.get(partID) ?? ""
+  if (!next) {
+    return prev
+  }
+
+  if (!prev || next.length >= prev.length) {
+    data.text.set(partID, next)
+    return next
+  }
+
+  return prev
+}
+
+// Records bash tool output for echo stripping. Some models echo bash output
+// verbatim at the start of their next text part. We save both the raw and
+// trimmed forms so stripEcho() can match either.
+function stashEcho(data: SessionData, part: ToolPart) {
+  if (part.tool !== "bash") {
+    return
+  }
+
+  if (typeof part.messageID !== "string" || !part.messageID) {
+    return
+  }
+
+  const output = (part.state as { output?: unknown }).output
+  if (typeof output !== "string") {
+    return
+  }
+
+  const text = output.replace(/^\n+/, "")
+  if (!text.trim()) {
+    return
+  }
+
+  const set = data.echo.get(part.messageID) ?? new Set<string>()
+  set.add(text)
+  const trim = text.replace(/\n+$/, "")
+  if (trim && trim !== text) {
+    set.add(trim)
+  }
+  data.echo.set(part.messageID, set)
+}
+
+function stripEcho(data: SessionData, msg: string | undefined, chunk: string): string {
+  if (!msg) {
+    return chunk
+  }
+
+  const set = data.echo.get(msg)
+  if (!set || set.size === 0) {
+    return chunk
+  }
+
+  data.echo.delete(msg)
+  const list = [...set].sort((a, b) => b.length - a.length)
+  for (const item of list) {
+    if (!item || !chunk.startsWith(item)) {
+      continue
+    }
+
+    return chunk.slice(item.length).replace(/^\n+/, "")
+  }
+
+  return chunk
+}
+
+function flushPart(data: SessionData, commits: SessionCommit[], partID: string, interrupted = false) {
+  const kind = data.part.get(partID)
+  if (!kind) {
+    return
+  }
+
+  const text = data.text.get(partID) ?? ""
+  const sent = data.sent.get(partID) ?? 0
+  let chunk = text.slice(sent)
+  const msg = data.msg.get(partID)
+
+  if (sent === 0) {
+    chunk = chunk.replace(/^\n+/, "")
+    if (kind === "reasoning" && chunk) {
+      chunk = `Thinking: ${chunk.replace(/\[REDACTED\]/g, "")}`
+    }
+    if (kind === "assistant" && chunk) {
+      chunk = stripEcho(data, msg, chunk)
+    }
+  }
+
+  if (chunk) {
+    data.sent.set(partID, text.length)
+    commits.push({
+      kind,
+      text: chunk,
+      phase: "progress",
+      source: kind,
+      messageID: msg,
+      partID,
+    })
+  }
+
+  if (!interrupted) {
+    return
+  }
+
+  commits.push({
+    kind,
+    text: "",
+    phase: "final",
+    source: kind,
+    messageID: msg,
+    partID,
+    interrupted: true,
+  })
+}
+
+function drop(data: SessionData, partID: string) {
+  data.part.delete(partID)
+  data.text.delete(partID)
+  data.sent.delete(partID)
+  data.msg.delete(partID)
+  data.end.delete(partID)
+}
+
+// Called when we learn a message's role (from message.updated). Flushes any
+// buffered text parts that were waiting on role confirmation. User-role
+// parts are silently dropped.
+function replay(data: SessionData, commits: SessionCommit[], messageID: string, role: MessageRole, thinking: boolean) {
+  for (const [partID, msg] of [...data.msg.entries()]) {
+    if (msg !== messageID || data.ids.has(partID)) {
+      continue
+    }
+
+    if (role === "user") {
+      data.ids.add(partID)
+      drop(data, partID)
+      continue
+    }
+
+    const kind = data.part.get(partID)
+    if (!kind) {
+      continue
+    }
+
+    if (kind === "reasoning" && !thinking) {
+      if (data.end.has(partID)) {
+        data.ids.add(partID)
+      }
+      drop(data, partID)
+      continue
+    }
+
+    flushPart(data, commits, partID)
+
+    if (!data.end.has(partID)) {
+      continue
+    }
+
+    data.ids.add(partID)
+    drop(data, partID)
+  }
+}
+
+function startTool(part: ToolPart): SessionCommit {
+  return {
+    kind: "tool",
+    text: toolStatus(part),
+    phase: "start",
+    source: "tool",
+    messageID: part.messageID,
+    partID: part.id,
+    tool: part.tool,
+    part,
+    toolState: "running",
+  }
+}
+
+function doneTool(part: ToolPart): SessionCommit {
+  return {
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    messageID: part.messageID,
+    partID: part.id,
+    tool: part.tool,
+    part,
+    toolState: "completed",
+  }
+}
+
+function failTool(part: ToolPart, text: string): SessionCommit {
+  return {
+    kind: "tool",
+    text,
+    phase: "final",
+    source: "tool",
+    messageID: part.messageID,
+    partID: part.id,
+    tool: part.tool,
+    part,
+    toolState: "error",
+    toolError: text,
+  }
+}
+
+// Emits "interrupted" final entries for all in-flight parts. Called when a turn is aborted.
+export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
+  for (const partID of data.part.keys()) {
+    if (data.ids.has(partID)) {
+      continue
+    }
+
+    const msg = data.msg.get(partID)
+    if (msg && data.role.get(msg) === "user") {
+      continue
+    }
+
+    flushPart(data, commits, partID, true)
+  }
+}
+
+// The main reducer. Takes one SDK event and returns scrollback commits and
+// footer updates. Called once per event from the stream transport's watch loop.
+//
+// Event handling follows the SDK event types:
+//   message.updated      → learn role, flush buffered parts, track usage
+//   message.part.delta   → accumulate text, flush if ready
+//   message.part.updated → handle text/reasoning/tool state transitions
+//   permission.*         → manage the permission queue, drive footer view
+//   question.*           → manage the question queue, drive footer view
+//   session.error        → emit error scrollback entry
+export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
+  const commits: SessionCommit[] = []
+  const data = input.data
+  const event = input.event
+
+  if (event.type === "message.updated") {
+    if (event.properties.sessionID !== input.sessionID) {
+      return out(data, commits)
+    }
+
+    const info = event.properties.info
+    if (typeof info.id === "string") {
+      data.role.set(info.id, info.role)
+      replay(data, commits, info.id, info.role, input.thinking)
+    }
+
+    if (info.role !== "assistant") {
+      return out(data, commits)
+    }
+
+    let next: FooterPatch | undefined
+    if (!data.announced) {
+      data.announced = true
+      next = { status: "assistant responding" }
+    }
+
+    const usage = formatUsage(
+      info.tokens,
+      input.limits[modelKey(info.providerID, info.modelID)],
+      typeof info.cost === "number" ? info.cost : undefined,
+    )
+    if (usage) {
+      next = {
+        ...(next ?? {}),
+        usage,
+      }
+    }
+
+    if (typeof info.id === "string" && info.error && !isAbort(info.error) && !data.ids.has(msgErr(info.id))) {
+      data.ids.add(msgErr(info.id))
+      commits.push({
+        kind: "error",
+        text: formatError(info.error),
+        phase: "start",
+        source: "system",
+        messageID: info.id,
+      })
+    }
+
+    return out(data, commits, patch(next))
+  }
+
+  if (event.type === "message.part.delta") {
+    if (event.properties.sessionID !== input.sessionID) {
+      return out(data, commits)
+    }
+
+    if (
+      typeof event.properties.partID !== "string" ||
+      typeof event.properties.field !== "string" ||
+      typeof event.properties.delta !== "string"
+    ) {
+      return out(data, commits)
+    }
+
+    if (event.properties.field !== "text") {
+      return out(data, commits)
+    }
+
+    const partID = event.properties.partID
+    if (data.ids.has(partID)) {
+      return out(data, commits)
+    }
+
+    if (typeof event.properties.messageID === "string") {
+      data.msg.set(partID, event.properties.messageID)
+    }
+
+    const text = data.text.get(partID) ?? ""
+    data.text.set(partID, text + event.properties.delta)
+
+    const kind = data.part.get(partID)
+    if (!kind) {
+      return out(data, commits)
+    }
+
+    if (kind === "reasoning" && !input.thinking) {
+      return out(data, commits)
+    }
+
+    if (!ready(data, partID)) {
+      return out(data, commits)
+    }
+
+    flushPart(data, commits, partID)
+    return out(data, commits)
+  }
+
+  if (event.type === "message.part.updated") {
+    const part = event.properties.part
+    if (part.sessionID !== input.sessionID) {
+      return out(data, commits)
+    }
+
+    if (part.type === "tool") {
+      const view = syncPermission(data, part)
+
+      if (part.state.status === "running") {
+        if (data.ids.has(part.id)) {
+          return out(data, commits, view)
+        }
+
+        if (!data.tools.has(part.id)) {
+          data.tools.add(part.id)
+          commits.push(startTool(part))
+        }
+
+        return out(data, commits, view ?? patch({ status: toolStatus(part) }))
+      }
+
+      if (part.state.status === "completed") {
+        const seen = data.tools.has(part.id)
+        const mode = toolView(part.tool)
+        data.tools.delete(part.id)
+        if (data.ids.has(part.id)) {
+          return out(data, commits, view)
+        }
+
+        if (!seen) {
+          commits.push(startTool(part))
+        }
+
+        data.ids.add(part.id)
+        stashEcho(data, part)
+
+        const output = part.state.output
+        if (mode.output && typeof output === "string" && output.trim()) {
+          commits.push({
+            kind: "tool",
+            text: output,
+            phase: "progress",
+            source: "tool",
+            messageID: part.messageID,
+            partID: part.id,
+            tool: part.tool,
+            part,
+            toolState: "completed",
+          })
+        }
+
+        if (mode.final) {
+          commits.push(doneTool(part))
+        }
+
+        return out(data, commits, view)
+      }
+
+      if (part.state.status === "error") {
+        data.tools.delete(part.id)
+        if (data.ids.has(part.id)) {
+          return out(data, commits, view)
+        }
+
+        data.ids.add(part.id)
+        const text =
+          typeof part.state.error === "string" && part.state.error.trim() ? part.state.error : "unknown error"
+        commits.push(failTool(part, text))
+        return out(data, commits, view)
+      }
+    }
+
+    if (part.type !== "text" && part.type !== "reasoning") {
+      return out(data, commits)
+    }
+
+    if (data.ids.has(part.id)) {
+      return out(data, commits)
+    }
+
+    const kind = part.type === "text" ? "assistant" : "reasoning"
+    if (typeof part.messageID === "string") {
+      data.msg.set(part.id, part.messageID)
+    }
+
+    const msg = part.messageID
+    const role = msg ? data.role.get(msg) : undefined
+    if (role === "user") {
+      data.ids.add(part.id)
+      drop(data, part.id)
+      return out(data, commits)
+    }
+
+    if (kind === "reasoning" && !input.thinking) {
+      if (part.time?.end) {
+        data.ids.add(part.id)
+      }
+      drop(data, part.id)
+      return out(data, commits)
+    }
+
+    data.part.set(part.id, kind)
+    syncText(data, part.id, part.text)
+
+    if (part.time?.end) {
+      data.end.add(part.id)
+    }
+
+    if (msg && !role) {
+      return out(data, commits)
+    }
+
+    if (!ready(data, part.id)) {
+      return out(data, commits)
+    }
+
+    flushPart(data, commits, part.id)
+
+    if (!part.time?.end) {
+      return out(data, commits)
+    }
+
+    data.ids.add(part.id)
+    drop(data, part.id)
+    return out(data, commits)
+  }
+
+  if (event.type === "permission.asked") {
+    if (event.properties.sessionID !== input.sessionID) {
+      return out(data, commits)
+    }
+
+    upsert(data.permissions, enrichPermission(data, event.properties))
+    return out(data, commits, queueFooter(data))
+  }
+
+  if (event.type === "permission.replied") {
+    if (event.properties.sessionID !== input.sessionID) {
+      return out(data, commits)
+    }
+
+    if (!remove(data.permissions, event.properties.requestID)) {
+      return out(data, commits)
+    }
+
+    return out(data, commits, queueFooter(data))
+  }
+
+  if (event.type === "question.asked") {
+    if (event.properties.sessionID !== input.sessionID) {
+      return out(data, commits)
+    }
+
+    upsert(data.questions, event.properties)
+    return out(data, commits, queueFooter(data))
+  }
+
+  if (event.type === "question.replied" || event.type === "question.rejected") {
+    if (event.properties.sessionID !== input.sessionID) {
+      return out(data, commits)
+    }
+
+    if (!remove(data.questions, event.properties.requestID)) {
+      return out(data, commits)
+    }
+
+    return out(data, commits, queueFooter(data))
+  }
+
+  if (event.type === "session.error") {
+    if (event.properties.sessionID !== input.sessionID || !event.properties.error) {
+      return out(data, commits)
+    }
+
+    commits.push({
+      kind: "error",
+      text: formatError(event.properties.error),
+      phase: "start",
+      source: "system",
+    })
+    return out(data, commits)
+  }
+
+  return out(data, commits)
+}

+ 94 - 0
packages/opencode/src/cli/cmd/run/session.shared.ts

@@ -0,0 +1,94 @@
+// Session message extraction and prompt history.
+//
+// Fetches session messages from the SDK and extracts user turn text for
+// the prompt history ring. Also finds the most recently used variant for
+// the current model so the footer can pre-select it.
+import type { RunInput } from "./types"
+
+const LIMIT = 200
+
+export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
+
+type Turn = {
+  text: string
+  provider: string | undefined
+  model: string | undefined
+  variant: string | undefined
+}
+
+export type RunSession = {
+  first: boolean
+  turns: Turn[]
+}
+
+function text(msg: SessionMessages[number]): string {
+  return msg.parts
+    .filter((part) => part.type === "text")
+    .map((part) => part.text.trim())
+    .filter((part) => part.length > 0)
+    .join("\n")
+}
+
+function turn(msg: SessionMessages[number]): Turn | undefined {
+  if (msg.info.role !== "user") {
+    return
+  }
+
+  return {
+    text: text(msg),
+    provider: msg.info.model.providerID,
+    model: msg.info.model.modelID,
+    variant: msg.info.variant,
+  }
+}
+
+export function createSession(messages: SessionMessages): RunSession {
+  return {
+    first: messages.length === 0,
+    turns: messages.flatMap((msg) => {
+      const item = turn(msg)
+      return item ? [item] : []
+    }),
+  }
+}
+
+export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise<RunSession> {
+  const response = await sdk.session.messages({
+    sessionID,
+    limit,
+  })
+  return createSession(response.data ?? [])
+}
+
+export function sessionHistory(session: RunSession, limit = LIMIT): string[] {
+  const out: string[] = []
+
+  for (const turn of session.turns) {
+    if (!turn.text) {
+      continue
+    }
+
+    if (out[out.length - 1] === turn.text) {
+      continue
+    }
+
+    out.push(turn.text)
+  }
+
+  return out.slice(-limit)
+}
+
+export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
+  if (!model) {
+    return
+  }
+
+  for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
+    const turn = session.turns[idx]
+    if (turn.provider !== model.providerID || turn.model !== model.modelID) {
+      continue
+    }
+
+    return turn.variant
+  }
+}

+ 291 - 0
packages/opencode/src/cli/cmd/run/splash.ts

@@ -0,0 +1,291 @@
+// Entry and exit splash banners for direct interactive mode scrollback.
+//
+// Renders the opencode ASCII logo with half-block shadow characters, the
+// session title, and contextual hints (entry: "/exit to finish", exit:
+// "opencode -s <id>" to resume). These are scrollback snapshots, so they
+// become immutable terminal history once committed.
+//
+// The logo uses a cell-based renderer. cells() classifies each character
+// in the logo template as text, full-block, half-block-mix, or
+// half-block-top, and draw() renders it with foreground/background shadow
+// colors from the theme.
+import {
+  BoxRenderable,
+  type ColorInput,
+  RGBA,
+  TextAttributes,
+  TextRenderable,
+  type ScrollbackRenderContext,
+  type ScrollbackSnapshot,
+  type ScrollbackWriter,
+} from "@opentui/core"
+import { Locale } from "../../../util/locale"
+import { logo } from "../../logo"
+import type { RunEntryTheme } from "./theme"
+
+export const SPLASH_TITLE_LIMIT = 50
+export const SPLASH_TITLE_FALLBACK = "Untitled session"
+
+type SplashInput = {
+  title: string | undefined
+  session_id: string
+}
+
+type SplashWriterInput = SplashInput & {
+  theme: RunEntryTheme
+  background: ColorInput
+  showSession?: boolean
+}
+
+export type SplashMeta = {
+  title: string
+  session_id: string
+}
+
+type Cell = {
+  char: string
+  mark: "text" | "full" | "mix" | "top"
+}
+
+let id = 0
+
+function cells(line: string): Cell[] {
+  const list: Cell[] = []
+  for (const char of line) {
+    if (char === "_") {
+      list.push({ char: " ", mark: "full" })
+      continue
+    }
+
+    if (char === "^") {
+      list.push({ char: "▀", mark: "mix" })
+      continue
+    }
+
+    if (char === "~") {
+      list.push({ char: "▀", mark: "top" })
+      continue
+    }
+
+    list.push({ char, mark: "text" })
+  }
+
+  return list
+}
+
+function title(text: string | undefined): string {
+  if (!text) {
+    return SPLASH_TITLE_FALLBACK
+  }
+
+  if (!text.trim()) {
+    return SPLASH_TITLE_FALLBACK
+  }
+
+  return Locale.truncate(text.trim(), SPLASH_TITLE_LIMIT)
+}
+
+function write(
+  root: BoxRenderable,
+  ctx: ScrollbackRenderContext,
+  line: {
+    left: number
+    top: number
+    text: string
+    fg: ColorInput
+    bg?: ColorInput
+    attrs?: number
+  },
+): void {
+  if (line.left >= ctx.width) {
+    return
+  }
+
+  root.add(
+    new TextRenderable(ctx.renderContext, {
+      id: `run-direct-splash-line-${id++}`,
+      position: "absolute",
+      left: line.left,
+      top: line.top,
+      width: Math.max(1, ctx.width - line.left),
+      height: 1,
+      wrapMode: "none",
+      content: line.text,
+      fg: line.fg,
+      bg: line.bg,
+      attributes: line.attrs,
+    }),
+  )
+}
+
+function push(
+  lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
+  left: number,
+  top: number,
+  text: string,
+  fg: ColorInput,
+  bg?: ColorInput,
+  attrs?: number,
+): void {
+  lines.push({ left, top, text, fg, bg, attrs })
+}
+
+function color(input: ColorInput, fallback: RGBA): RGBA {
+  if (input instanceof RGBA) {
+    return input
+  }
+
+  if (typeof input === "string") {
+    if (input === "transparent" || input === "none") {
+      return RGBA.fromValues(0, 0, 0, 0)
+    }
+
+    if (input.startsWith("#")) {
+      return RGBA.fromHex(input)
+    }
+  }
+
+  return fallback
+}
+
+function shade(base: RGBA, overlay: RGBA, alpha: number): RGBA {
+  const r = base.r + (overlay.r - base.r) * alpha
+  const g = base.g + (overlay.g - base.g) * alpha
+  const b = base.b + (overlay.b - base.b) * alpha
+  return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
+}
+
+function draw(
+  lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
+  row: string,
+  input: {
+    left: number
+    top: number
+    fg: ColorInput
+    shadow: ColorInput
+    attrs?: number
+  },
+) {
+  let x = input.left
+  for (const cell of cells(row)) {
+    if (cell.mark === "full") {
+      push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
+      x += 1
+      continue
+    }
+
+    if (cell.mark === "mix") {
+      push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
+      x += 1
+      continue
+    }
+
+    if (cell.mark === "top") {
+      push(lines, x, input.top, cell.char, input.shadow, undefined, input.attrs)
+      x += 1
+      continue
+    }
+
+    push(lines, x, input.top, cell.char, input.fg, undefined, input.attrs)
+    x += 1
+  }
+}
+
+function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: ScrollbackRenderContext): ScrollbackSnapshot {
+  const width = Math.max(1, ctx.width)
+  const meta = splashMeta(input)
+  const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
+  const bg = color(input.background, RGBA.fromValues(0, 0, 0, 0))
+  const left = color(input.theme.system.body, RGBA.fromInts(100, 116, 139))
+  const right = color(input.theme.assistant.body, RGBA.fromInts(248, 250, 252))
+  const leftShadow = shade(bg, left, 0.25)
+  const rightShadow = shade(bg, right, 0.25)
+  let y = 0
+
+  for (let i = 0; i < logo.left.length; i += 1) {
+    const leftText = logo.left[i] ?? ""
+    const rightText = logo.right[i] ?? ""
+
+    draw(lines, leftText, {
+      left: 2,
+      top: y,
+      fg: left,
+      shadow: leftShadow,
+    })
+    draw(lines, rightText, {
+      left: 2 + leftText.length + 1,
+      top: y,
+      fg: right,
+      shadow: rightShadow,
+      attrs: TextAttributes.BOLD,
+    })
+    y += 1
+  }
+
+  y += 1
+
+  if (input.showSession !== false) {
+    const label = "Session".padEnd(10, " ")
+    push(lines, 2, y, label, input.theme.system.body, undefined, TextAttributes.DIM)
+    push(lines, 2 + label.length, y, meta.title, input.theme.assistant.body, undefined, TextAttributes.BOLD)
+    y += 1
+  }
+
+  if (kind === "entry") {
+    push(lines, 2, y, "Type /exit or /quit to finish.", input.theme.system.body, undefined, undefined)
+    y += 1
+  }
+
+  if (kind === "exit") {
+    const next = "Continue".padEnd(10, " ")
+    push(lines, 2, y, next, input.theme.system.body, undefined, TextAttributes.DIM)
+    push(
+      lines,
+      2 + next.length,
+      y,
+      `opencode -s ${meta.session_id}`,
+      input.theme.assistant.body,
+      undefined,
+      TextAttributes.BOLD,
+    )
+    y += 1
+  }
+
+  const height = Math.max(1, y + 1)
+  const root = new BoxRenderable(ctx.renderContext, {
+    id: `run-direct-splash-${kind}-${id++}`,
+    position: "absolute",
+    left: 0,
+    top: 0,
+    width,
+    height,
+  })
+
+  for (const line of lines) {
+    write(root, ctx, line)
+  }
+
+  return {
+    root,
+    width,
+    height,
+    rowColumns: width,
+    startOnNewLine: true,
+    trailingNewline: false,
+  }
+}
+
+export function splashMeta(input: SplashInput): SplashMeta {
+  return {
+    title: title(input.title),
+    session_id: input.session_id,
+  }
+}
+
+export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
+  return (ctx) => build(input, "entry", ctx)
+}
+
+export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
+  return (ctx) => build(input, "exit", ctx)
+}

+ 376 - 0
packages/opencode/src/cli/cmd/run/stream.transport.ts

@@ -0,0 +1,376 @@
+// SDK event subscription and prompt turn coordination.
+//
+// Creates a long-lived event stream subscription and feeds every event
+// through the session-data reducer. The reducer produces scrollback commits
+// and footer patches, which get forwarded to the footer through stream.ts.
+//
+// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the
+// SDK, arms a deferred Wait, and resolves when a session.status idle event
+// arrives for this session. If the turn is aborted (user interrupt), it
+// flushes any in-progress parts as interrupted entries.
+//
+// The tick counter prevents stale idle events from resolving the wrong turn
+// -- each turn gets a monotonically increasing tick, and idle events only
+// resolve the wait if the tick matches.
+import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
+import { createSessionData, flushInterrupted, reduceSessionData } from "./session-data"
+import { writeSessionOutput } from "./stream"
+import type { FooterApi, RunFilePart, RunInput, StreamCommit } from "./types"
+
+type Trace = {
+  write(type: string, data?: unknown): void
+}
+
+type StreamInput = {
+  sdk: OpencodeClient
+  sessionID: string
+  thinking: boolean
+  limits: () => Record<string, number>
+  footer: FooterApi
+  trace?: Trace
+  signal?: AbortSignal
+}
+
+type Wait = {
+  tick: number
+  armed: boolean
+  done: Promise<void>
+  resolve: () => void
+  reject: (error: unknown) => void
+}
+
+export type SessionTurnInput = {
+  agent: string | undefined
+  model: RunInput["model"]
+  variant: string | undefined
+  prompt: string
+  files: RunFilePart[]
+  includeFiles: boolean
+  signal?: AbortSignal
+}
+
+export type SessionTransport = {
+  runPromptTurn(input: SessionTurnInput): Promise<void>
+  close(): Promise<void>
+}
+
+// Creates a deferred promise tied to a specific turn tick.
+function defer(tick: number): Wait {
+  let resolve: () => void = () => {}
+  let reject: (error: unknown) => void = () => {}
+  const done = new Promise<void>((next, fail) => {
+    resolve = next
+    reject = fail
+  })
+
+  return {
+    tick,
+    armed: false,
+    done,
+    resolve,
+    reject,
+  }
+}
+
+// Races the turn's deferred promise against an abort signal.
+function waitTurn(done: Promise<void>, signal: AbortSignal): Promise<"idle" | "abort"> {
+  return new Promise((resolve, reject) => {
+    if (signal.aborted) {
+      resolve("abort")
+      return
+    }
+
+    const onAbort = () => {
+      signal.removeEventListener("abort", onAbort)
+      resolve("abort")
+    }
+
+    signal.addEventListener("abort", onAbort, { once: true })
+    done.then(
+      () => {
+        signal.removeEventListener("abort", onAbort)
+        resolve("idle")
+      },
+      (error) => {
+        signal.removeEventListener("abort", onAbort)
+        reject(error)
+      },
+    )
+  })
+}
+
+export function formatUnknownError(error: unknown): string {
+  if (typeof error === "string") {
+    return error
+  }
+
+  if (error instanceof Error) {
+    return error.message || error.name
+  }
+
+  if (error && typeof error === "object") {
+    const value = error as { message?: unknown; name?: unknown }
+    if (typeof value.message === "string" && value.message.trim()) {
+      return value.message
+    }
+
+    if (typeof value.name === "string" && value.name.trim()) {
+      return value.name
+    }
+  }
+
+  return "unknown error"
+}
+
+// Opens an SDK event subscription and returns a SessionTransport.
+//
+// The background `watch` loop consumes every SDK event, runs it through the
+// reducer, and writes output to the footer. When a session.status idle
+// event arrives, it resolves the current turn's Wait so runPromptTurn()
+// can return.
+//
+// The transport is single-turn: only one runPromptTurn() call can be active
+// at a time. The prompt queue enforces this from above.
+export async function createSessionTransport(input: StreamInput): Promise<SessionTransport> {
+  const abort = new AbortController()
+  const halt = () => {
+    abort.abort()
+  }
+  input.signal?.addEventListener("abort", halt, { once: true })
+
+  const events = await input.sdk.event.subscribe(undefined, {
+    signal: abort.signal,
+  })
+  input.trace?.write("recv.subscribe", {
+    sessionID: input.sessionID,
+  })
+
+  const closeStream = () => {
+    // Pass undefined explicitly so TS accepts AsyncGenerator.return().
+    void events.stream.return(undefined).catch(() => {})
+  }
+
+  let data = createSessionData()
+  let wait: Wait | undefined
+  let tick = 0
+  let fault: unknown
+  let closed = false
+
+  const fail = (error: unknown) => {
+    if (fault) {
+      return
+    }
+
+    fault = error
+    const next = wait
+    wait = undefined
+    next?.reject(error)
+  }
+
+  const mark = (event: Event) => {
+    if (
+      event.type !== "session.status" ||
+      event.properties.sessionID !== input.sessionID ||
+      event.properties.status.type !== "idle"
+    ) {
+      return
+    }
+
+    const next = wait
+    if (!next || !next.armed) {
+      return
+    }
+
+    tick = next.tick + 1
+    wait = undefined
+    next.resolve()
+  }
+
+  const flush = (type: "turn.abort" | "turn.cancel") => {
+    const commits: StreamCommit[] = []
+    flushInterrupted(data, commits)
+    writeSessionOutput(
+      {
+        footer: input.footer,
+        trace: input.trace,
+      },
+      {
+        data,
+        commits,
+      },
+    )
+    input.trace?.write(type, {
+      sessionID: input.sessionID,
+    })
+  }
+
+  const watch = (async () => {
+    try {
+      for await (const item of events.stream) {
+        if (input.footer.isClosed) {
+          break
+        }
+
+        const event = item as Event
+        input.trace?.write("recv.event", event)
+        const next = reduceSessionData({
+          data,
+          event,
+          sessionID: input.sessionID,
+          thinking: input.thinking,
+          limits: input.limits(),
+        })
+        data = next.data
+
+        if (next.commits.length > 0 || next.footer?.patch || next.footer?.view) {
+          input.trace?.write("reduce.output", {
+            commits: next.commits,
+            footer: next.footer,
+          })
+        }
+
+        writeSessionOutput(
+          {
+            footer: input.footer,
+            trace: input.trace,
+          },
+          next,
+        )
+
+        mark(event)
+      }
+    } catch (error) {
+      if (!abort.signal.aborted) {
+        fail(error)
+      }
+    } finally {
+      if (!abort.signal.aborted && !fault) {
+        fail(new Error("session event stream closed"))
+      }
+      closeStream()
+    }
+  })()
+
+  const runPromptTurn = async (next: SessionTurnInput): Promise<void> => {
+    if (next.signal?.aborted || input.footer.isClosed) {
+      return
+    }
+
+    if (fault) {
+      throw fault
+    }
+
+    if (wait) {
+      throw new Error("prompt already running")
+    }
+
+    const item = defer(tick)
+    wait = item
+    data.announced = false
+
+    const turn = new AbortController()
+    const stop = () => {
+      turn.abort()
+    }
+    next.signal?.addEventListener("abort", stop, { once: true })
+    abort.signal.addEventListener("abort", stop, { once: true })
+
+    try {
+      const req = {
+        sessionID: input.sessionID,
+        agent: next.agent,
+        model: next.model,
+        variant: next.variant,
+        parts: [...(next.includeFiles ? next.files : []), { type: "text" as const, text: next.prompt }],
+      }
+      input.trace?.write("send.prompt", req)
+      await input.sdk.session.prompt(req, {
+        signal: turn.signal,
+      })
+      input.trace?.write("send.prompt.ok", {
+        sessionID: input.sessionID,
+      })
+
+      item.armed = true
+
+      if (turn.signal.aborted || next.signal?.aborted || input.footer.isClosed) {
+        if (wait === item) {
+          wait = undefined
+        }
+        flush("turn.abort")
+        return
+      }
+
+      if (!input.footer.isClosed && !data.announced) {
+        input.trace?.write("ui.patch", {
+          phase: "running",
+          status: "waiting for assistant",
+        })
+        input.footer.event({
+          type: "turn.wait",
+        })
+      }
+
+      if (tick > item.tick) {
+        if (wait === item) {
+          wait = undefined
+        }
+        return
+      }
+
+      const state = await waitTurn(item.done, turn.signal)
+      if (wait === item) {
+        wait = undefined
+      }
+
+      if (state === "abort") {
+        flush("turn.abort")
+      }
+
+      return
+    } catch (error) {
+      if (wait === item) {
+        wait = undefined
+      }
+
+      const canceled = turn.signal.aborted || next.signal?.aborted === true || input.footer.isClosed
+      if (canceled) {
+        flush("turn.cancel")
+        return
+      }
+
+      if (error === fault) {
+        throw error
+      }
+
+      input.trace?.write("send.prompt.error", {
+        sessionID: input.sessionID,
+        error: formatUnknownError(error),
+      })
+      throw error
+    } finally {
+      input.trace?.write("turn.end", {
+        sessionID: input.sessionID,
+      })
+      next.signal?.removeEventListener("abort", stop)
+      abort.signal.removeEventListener("abort", stop)
+    }
+  }
+
+  const close = async () => {
+    if (closed) {
+      return
+    }
+
+    closed = true
+    input.signal?.removeEventListener("abort", halt)
+    abort.abort()
+    closeStream()
+    await watch.catch(() => {})
+  }
+
+  return {
+    runPromptTurn,
+    close,
+  }
+}

+ 59 - 0
packages/opencode/src/cli/cmd/run/stream.ts

@@ -0,0 +1,59 @@
+// Thin bridge between the session-data reducer output and the footer API.
+//
+// The reducer produces StreamCommit[] and an optional FooterOutput (patch +
+// view change). This module forwards them to footer.append() and
+// footer.event() respectively, adding trace writes along the way. It also
+// defaults status updates to phase "running" if the caller didn't set a
+// phase -- a convenience so reducer code doesn't have to repeat that.
+import type { FooterApi, FooterPatch } from "./types"
+import type { SessionDataOutput } from "./session-data"
+
+type Trace = {
+  write(type: string, data?: unknown): void
+}
+
+type OutputInput = {
+  footer: FooterApi
+  trace?: Trace
+}
+
+// Default to "running" phase when a status string arrives without an explicit phase.
+function patch(next: FooterPatch): FooterPatch {
+  if (typeof next.status === "string" && next.phase === undefined) {
+    return {
+      phase: "running",
+      ...next,
+    }
+  }
+
+  return next
+}
+
+// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
+export function writeSessionOutput(input: OutputInput, out: SessionDataOutput): void {
+  for (const commit of out.commits) {
+    input.trace?.write("ui.commit", commit)
+    input.footer.append(commit)
+  }
+
+  if (out.footer?.patch) {
+    const next = patch(out.footer.patch)
+    input.trace?.write("ui.patch", next)
+    input.footer.event({
+      type: "stream.patch",
+      patch: next,
+    })
+  }
+
+  if (!out.footer?.view) {
+    return
+  }
+
+  input.trace?.write("ui.patch", {
+    view: out.footer.view,
+  })
+  input.footer.event({
+    type: "stream.view",
+    view: out.footer.view,
+  })
+}

+ 218 - 0
packages/opencode/src/cli/cmd/run/theme.ts

@@ -0,0 +1,218 @@
+// Theme resolution for direct interactive mode.
+//
+// Derives scrollback and footer colors from the terminal's actual palette.
+// resolveRunTheme() queries the renderer for the terminal's 16-color palette,
+// detects dark/light mode, and maps through the TUI's theme system to produce
+// a RunTheme. Falls back to a hardcoded dark-mode palette if detection fails.
+//
+// The theme has three parts:
+//   entry  → per-EntryKind colors for plain scrollback text
+//   footer → highlight, muted, text, surface, and line colors for the footer
+//   block  → richer text/syntax/diff colors for static tool snapshots
+import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput } from "@opentui/core"
+import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
+import type { EntryKind } from "./types"
+
+type Tone = {
+  body: ColorInput
+}
+
+export type RunEntryTheme = Record<EntryKind, Tone>
+
+export type RunFooterTheme = {
+  highlight: ColorInput
+  warning: ColorInput
+  success: ColorInput
+  error: ColorInput
+  muted: ColorInput
+  text: ColorInput
+  surface: ColorInput
+  pane: ColorInput
+  line: ColorInput
+}
+
+export type RunBlockTheme = {
+  text: ColorInput
+  muted: ColorInput
+  syntax?: SyntaxStyle
+  diffAdded: ColorInput
+  diffRemoved: ColorInput
+  diffAddedBg: ColorInput
+  diffRemovedBg: ColorInput
+  diffContextBg: ColorInput
+  diffHighlightAdded: ColorInput
+  diffHighlightRemoved: ColorInput
+  diffLineNumber: ColorInput
+  diffAddedLineNumberBg: ColorInput
+  diffRemovedLineNumberBg: ColorInput
+}
+
+export type RunTheme = {
+  background: ColorInput
+  footer: RunFooterTheme
+  entry: RunEntryTheme
+  block: RunBlockTheme
+}
+
+function alpha(color: RGBA, value: number): RGBA {
+  const a = Math.max(0, Math.min(1, value))
+  return RGBA.fromValues(color.r, color.g, color.b, a)
+}
+
+function rgba(hex: string, value?: number): RGBA {
+  const color = RGBA.fromHex(hex)
+  if (value === undefined) {
+    return color
+  }
+
+  return alpha(color, value)
+}
+
+function mode(bg: RGBA): "dark" | "light" {
+  const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
+  if (lum > 0.5) {
+    return "light"
+  }
+
+  return "dark"
+}
+
+function fade(color: RGBA, fallback: number, scale: number, limit: number): RGBA {
+  if (color.a === 0) {
+    return alpha(color, fallback)
+  }
+
+  return alpha(color, Math.min(limit, color.a * scale))
+}
+
+function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
+  const pane = theme.backgroundElement
+  const surface = fade(pane, 0.18, 0.76, 0.9)
+  const line = fade(pane, 0.24, 0.90, 0.98)
+
+  return {
+    background: theme.background,
+    footer: {
+      highlight: theme.primary,
+      warning: theme.warning,
+      success: theme.success,
+      error: theme.error,
+      muted: theme.textMuted,
+      text: theme.text,
+      surface,
+      pane,
+      line,
+    },
+    entry: {
+      system: {
+        body: theme.textMuted,
+      },
+      user: {
+        body: theme.primary,
+      },
+      assistant: {
+        body: theme.text,
+      },
+      reasoning: {
+        body: theme.textMuted,
+      },
+      tool: {
+        body: theme.warning,
+      },
+      error: {
+        body: theme.error,
+      },
+    },
+    block: {
+      text: theme.text,
+      muted: theme.textMuted,
+      syntax,
+      diffAdded: theme.diffAdded,
+      diffRemoved: theme.diffRemoved,
+      diffAddedBg: theme.diffAddedBg,
+      diffRemovedBg: theme.diffRemovedBg,
+      diffContextBg: theme.diffContextBg,
+      diffHighlightAdded: theme.diffHighlightAdded,
+      diffHighlightRemoved: theme.diffHighlightRemoved,
+      diffLineNumber: theme.diffLineNumber,
+      diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
+      diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
+    },
+  }
+}
+
+const seed = {
+  highlight: rgba("#38bdf8"),
+  muted: rgba("#64748b"),
+  text: rgba("#f8fafc"),
+  panel: rgba("#0f172a"),
+  success: rgba("#22c55e"),
+  warning: rgba("#f59e0b"),
+  error: rgba("#ef4444"),
+}
+
+function tone(body: ColorInput): Tone {
+  return {
+    body,
+  }
+}
+
+export const RUN_THEME_FALLBACK: RunTheme = {
+  background: RGBA.fromValues(0, 0, 0, 0),
+  footer: {
+    highlight: seed.highlight,
+    warning: seed.warning,
+    success: seed.success,
+    error: seed.error,
+    muted: seed.muted,
+    text: seed.text,
+    surface: alpha(seed.panel, 0.86),
+    pane: seed.panel,
+    line: alpha(seed.panel, 0.96),
+  },
+  entry: {
+    system: tone(seed.muted),
+    user: tone(seed.highlight),
+    assistant: tone(seed.text),
+    reasoning: tone(seed.muted),
+    tool: tone(seed.warning),
+    error: tone(seed.error),
+  },
+  block: {
+    text: seed.text,
+    muted: seed.muted,
+    diffAdded: seed.success,
+    diffRemoved: seed.error,
+    diffAddedBg: alpha(seed.success, 0.18),
+    diffRemovedBg: alpha(seed.error, 0.18),
+    diffContextBg: alpha(seed.panel, 0.72),
+    diffHighlightAdded: seed.success,
+    diffHighlightRemoved: seed.error,
+    diffLineNumber: seed.muted,
+    diffAddedLineNumberBg: alpha(seed.success, 0.12),
+    diffRemovedLineNumberBg: alpha(seed.error, 0.12),
+  },
+}
+
+export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
+  try {
+    const colors = await renderer.getPalette({
+      size: 16,
+    })
+    const bg = colors.defaultBackground ?? colors.palette[0]
+    if (!bg) {
+      return RUN_THEME_FALLBACK
+    }
+
+    const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
+    const mod = await import("../tui/context/theme")
+    const theme = mod.resolveTheme(mod.generateSystem(colors, pick), pick) as TuiThemeCurrent
+    try {
+      return map(theme, mod.generateSyntax(theme))
+    } catch {
+      return map(theme)
+    }
+  } catch {
+    return RUN_THEME_FALLBACK
+  }
+}

+ 1465 - 0
packages/opencode/src/cli/cmd/run/tool.ts

@@ -0,0 +1,1465 @@
+// Per-tool display rules for direct interactive mode.
+//
+// Each known tool (bash, edit, write, task, etc.) has a ToolRule that controls
+// four rendering contexts:
+//
+//   view       → controls which phases produce scrollback output (output for
+//                progress, final for completion, snap for rich snapshots)
+//   run        → inline summary for the non-interactive `run` command output
+//   scroll     → text formatting for start/progress/final scrollback entries
+//   permission → display info for the permission UI (icon, title, diff)
+//   snap       → structured snapshot (code block, diff, task card) for the
+//                rich scrollback writer
+//
+// Tools not in TOOL_RULES get fallback formatting. The registry is typed
+// against the actual tool parameter/metadata types so each formatter gets
+// proper type inference.
+import os from "os"
+import path from "path"
+import stripAnsi from "strip-ansi"
+import type { ToolPart } from "@opencode-ai/sdk/v2"
+import type { Tool } from "../../../tool/tool"
+import type { ApplyPatchTool } from "../../../tool/apply_patch"
+import type { BatchTool } from "../../../tool/batch"
+import type { BashTool } from "../../../tool/bash"
+import type { CodeSearchTool } from "../../../tool/codesearch"
+import type { EditTool } from "../../../tool/edit"
+import type { GlobTool } from "../../../tool/glob"
+import type { GrepTool } from "../../../tool/grep"
+import type { InvalidTool } from "../../../tool/invalid"
+import type { ListTool } from "../../../tool/ls"
+import type { LspTool } from "../../../tool/lsp"
+import type { PlanExitTool } from "../../../tool/plan"
+import type { QuestionTool } from "../../../tool/question"
+import type { ReadTool } from "../../../tool/read"
+import type { SkillTool } from "../../../tool/skill"
+import type { TaskTool } from "../../../tool/task"
+import type { TodoWriteTool } from "../../../tool/todo"
+import type { WebFetchTool } from "../../../tool/webfetch"
+import type { WebSearchTool } from "../../../tool/websearch"
+import type { WriteTool } from "../../../tool/write"
+import { LANGUAGE_EXTENSIONS } from "../../../lsp/language"
+import { Locale } from "../../../util/locale"
+import type { RunDiffStyle, StreamCommit } from "./types"
+
+export type ToolView = {
+  output: boolean
+  final: boolean
+  snap?: "code" | "diff" | "structured"
+}
+
+export type ToolPhase = "start" | "progress" | "final"
+
+export type ToolDict = Record<string, unknown>
+
+export type ToolFrame = {
+  raw: string
+  name: string
+  input: ToolDict
+  meta: ToolDict
+  state: ToolDict
+  status: string
+  error: string
+}
+
+export type ToolInline = {
+  icon: string
+  title: string
+  description?: string
+  mode?: "inline" | "block"
+  body?: string
+}
+
+export type ToolPermissionInfo = {
+  icon: string
+  title: string
+  lines: string[]
+  diff?: string
+  file?: string
+}
+
+export type ToolCodeSnapshot = {
+  kind: "code"
+  title: string
+  content: string
+  file?: string
+}
+
+export type ToolDiffSnapshot = {
+  kind: "diff"
+  items: Array<{
+    title: string
+    diff: string
+    file?: string
+    deletions?: number
+  }>
+}
+
+export type ToolTaskSnapshot = {
+  kind: "task"
+  title: string
+  rows: string[]
+  tail: string
+}
+
+export type ToolTodoSnapshot = {
+  kind: "todo"
+  items: Array<{
+    status: string
+    content: string
+  }>
+  tail: string
+}
+
+export type ToolQuestionSnapshot = {
+  kind: "question"
+  items: Array<{
+    question: string
+    answer: string
+  }>
+  tail: string
+}
+
+export type ToolSnapshot =
+  | ToolCodeSnapshot
+  | ToolDiffSnapshot
+  | ToolTaskSnapshot
+  | ToolTodoSnapshot
+  | ToolQuestionSnapshot
+
+export type ToolProps<T extends Tool.Info> = {
+  input: Partial<Tool.InferParameters<T>>
+  metadata: Partial<Tool.InferMetadata<T>>
+  frame: ToolFrame
+}
+
+type ToolPermissionProps<T extends Tool.Info> = {
+  input: Partial<Tool.InferParameters<T>>
+  metadata: Partial<Tool.InferMetadata<T>>
+  patterns: string[]
+}
+
+type ToolPermissionCtx = {
+  input: ToolDict
+  meta: ToolDict
+  patterns: string[]
+}
+
+type ToolDefs = {
+  invalid: typeof InvalidTool
+  bash: typeof BashTool
+  write: typeof WriteTool
+  edit: typeof EditTool
+  apply_patch: typeof ApplyPatchTool
+  batch: typeof BatchTool
+  task: typeof TaskTool
+  todowrite: typeof TodoWriteTool
+  question: typeof QuestionTool
+  read: typeof ReadTool
+  glob: typeof GlobTool
+  grep: typeof GrepTool
+  list: typeof ListTool
+  lsp: typeof LspTool
+  webfetch: typeof WebFetchTool
+  codesearch: typeof CodeSearchTool
+  websearch: typeof WebSearchTool
+  skill: typeof SkillTool
+  plan_exit: typeof PlanExitTool
+}
+
+type ToolName = keyof ToolDefs
+
+type ToolRule<T extends Tool.Info> = {
+  view: ToolView
+  run: (props: ToolProps<T>) => ToolInline
+  scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
+  permission?: (props: ToolPermissionProps<T>) => ToolPermissionInfo
+  snap?: (props: ToolProps<T>) => ToolSnapshot | undefined
+}
+
+type ToolRegistry = {
+  [K in ToolName]: ToolRule<ToolDefs[K]>
+}
+
+type AnyToolRule = ToolRule<Tool.Info>
+
+function dict(v: unknown): ToolDict {
+  if (!v || typeof v !== "object" || Array.isArray(v)) {
+    return {}
+  }
+
+  return v as ToolDict
+}
+
+function props<T extends Tool.Info = Tool.Info>(frame: ToolFrame): ToolProps<T> {
+  return {
+    input: frame.input as Partial<Tool.InferParameters<T>>,
+    metadata: frame.meta as Partial<Tool.InferMetadata<T>>,
+    frame,
+  }
+}
+
+function permission<T extends Tool.Info = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
+  return {
+    input: ctx.input as Partial<Tool.InferParameters<T>>,
+    metadata: ctx.meta as Partial<Tool.InferMetadata<T>>,
+    patterns: ctx.patterns,
+  }
+}
+
+function text(v: unknown): string {
+  return typeof v === "string" ? v : ""
+}
+
+function num(v: unknown): number | undefined {
+  if (typeof v !== "number" || !Number.isFinite(v)) {
+    return
+  }
+
+  return v
+}
+
+function list<T>(v: unknown): T[] {
+  return Array.isArray(v) ? (v as T[]) : []
+}
+
+function done(name: string, time: string): string {
+  if (!time) {
+    return `└ ${name} completed`
+  }
+
+  return `└ ${name} completed · ${time}`
+}
+
+function info(data: ToolDict, skip: string[] = []): string {
+  const list = Object.entries(data).filter(([key, val]) => {
+    if (skip.includes(key)) {
+      return false
+    }
+
+    return typeof val === "string" || typeof val === "number" || typeof val === "boolean"
+  })
+
+  if (list.length === 0) {
+    return ""
+  }
+
+  return `[${list.map(([key, val]) => `${key}=${val}`).join(", ")}]`
+}
+
+function span(state: ToolDict): string {
+  const time = dict(state.time)
+  const start = num(time.start)
+  const end = num(time.end)
+  if (start === undefined || end === undefined || end <= start) {
+    return ""
+  }
+
+  return Locale.duration(end - start)
+}
+
+function fail(ctx: ToolFrame): string {
+  if (ctx.error) {
+    return `✖ ${ctx.name} failed: ${ctx.error}`
+  }
+
+  const state = text(ctx.state.error).trim()
+  if (state) {
+    return `✖ ${ctx.name} failed: ${state}`
+  }
+
+  const raw = ctx.raw.trim()
+  if (raw) {
+    return `✖ ${ctx.name} failed: ${raw}`
+  }
+
+  return `✖ ${ctx.name} failed`
+}
+
+function fallbackStart(ctx: ToolFrame): string {
+  const extra = info(ctx.input)
+  if (!extra) {
+    return `⚙ ${ctx.name}`
+  }
+
+  return `⚙ ${ctx.name} ${extra}`
+}
+
+function fallbackFinal(ctx: ToolFrame): string {
+  if (ctx.status === "error") {
+    return fail(ctx)
+  }
+
+  if (ctx.status && ctx.status !== "completed") {
+    return ctx.raw.trim()
+  }
+
+  return done(ctx.name, span(ctx.state))
+}
+
+export function toolPath(input?: string, opts: { home?: boolean } = {}): string {
+  if (!input) {
+    return ""
+  }
+
+  const cwd = process.cwd()
+  const home = os.homedir()
+  const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input)
+  const rel = path.relative(cwd, abs)
+
+  if (!rel) {
+    return "."
+  }
+
+  if (!rel.startsWith("..")) {
+    return rel
+  }
+
+  if (opts.home && home && (abs === home || abs.startsWith(home + path.sep))) {
+    return abs.replace(home, "~")
+  }
+
+  return abs
+}
+
+function fallbackInline(ctx: ToolFrame): ToolInline {
+  const title = text(ctx.state.title) || (Object.keys(ctx.input).length > 0 ? JSON.stringify(ctx.input) : "Unknown")
+
+  return {
+    icon: "⚙",
+    title: `${ctx.name} ${title}`,
+  }
+}
+
+function count(n: number, label: string): string {
+  return `${n} ${label}${n === 1 ? "" : "es"}`
+}
+
+function runGlob(p: ToolProps<typeof GlobTool>): ToolInline {
+  const root = p.input.path ?? ""
+  const title = `Glob "${p.input.pattern ?? ""}"`
+  const suffix = root ? `in ${toolPath(root)}` : ""
+  const matches = p.metadata.count
+  const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
+  return {
+    icon: "✱",
+    title,
+    ...(description && { description }),
+  }
+}
+
+function runGrep(p: ToolProps<typeof GrepTool>): ToolInline {
+  const root = p.input.path ?? ""
+  const title = `Grep "${p.input.pattern ?? ""}"`
+  const suffix = root ? `in ${toolPath(root)}` : ""
+  const matches = p.metadata.matches
+  const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
+  return {
+    icon: "✱",
+    title,
+    ...(description && { description }),
+  }
+}
+
+function runList(p: ToolProps<typeof ListTool>): ToolInline {
+  const dir = p.input.path ?? ""
+  return {
+    icon: "→",
+    title: dir ? `List ${toolPath(dir)}` : "List",
+  }
+}
+
+function runRead(p: ToolProps<typeof ReadTool>): ToolInline {
+  const file = toolPath(p.input.filePath)
+  const description = info(p.frame.input, ["filePath"]) || undefined
+  return {
+    icon: "→",
+    title: `Read ${file}`,
+    ...(description && { description }),
+  }
+}
+
+function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
+  return {
+    icon: "←",
+    title: `Write ${toolPath(p.input.filePath)}`,
+    mode: "block",
+    body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
+  }
+}
+
+function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
+  const url = p.input.url ?? ""
+  return {
+    icon: "%",
+    title: url ? `WebFetch ${url}` : "WebFetch",
+  }
+}
+
+function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
+  return {
+    icon: "←",
+    title: `Edit ${toolPath(p.input.filePath)}`,
+    mode: "block",
+    body: p.metadata.diff,
+  }
+}
+
+function runCodeSearch(p: ToolProps<typeof CodeSearchTool>): ToolInline {
+  return {
+    icon: "◇",
+    title: `Exa Code Search "${p.input.query ?? ""}"`,
+  }
+}
+
+function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
+  return {
+    icon: "◈",
+    title: `Exa Web Search "${p.input.query ?? ""}"`,
+  }
+}
+
+function runTask(p: ToolProps<typeof TaskTool>): ToolInline {
+  const kind = Locale.titlecase(p.input.subagent_type || "unknown")
+  const desc = p.input.description
+  const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓"
+  return {
+    icon,
+    title: desc || `${kind} Task`,
+    description: desc ? `${kind} Agent` : undefined,
+  }
+}
+
+function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
+  return {
+    icon: "#",
+    title: "Todos",
+    mode: "block",
+    body: list<{ status?: string; content?: string }>(p.frame.input.todos)
+      .flatMap((item) => {
+        const body = typeof item?.content === "string" ? item.content : ""
+        if (!body) {
+          return []
+        }
+
+        return [`${item.status === "completed" ? "[x]" : "[ ]"} ${body}`]
+      })
+      .join("\n"),
+  }
+}
+
+function runSkill(p: ToolProps<typeof SkillTool>): ToolInline {
+  return {
+    icon: "→",
+    title: `Skill "${p.input.name ?? ""}"`,
+  }
+}
+
+function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline {
+  const files = p.metadata.files?.length ?? 0
+  if (files === 0) {
+    return {
+      icon: "%",
+      title: "Patch",
+    }
+  }
+
+  return {
+    icon: "%",
+    title: `Patch ${files} file${files === 1 ? "" : "s"}`,
+  }
+}
+
+function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
+  const total = list(p.frame.input.questions).length
+  return {
+    icon: "→",
+    title: `Asked ${total} question${total === 1 ? "" : "s"}`,
+  }
+}
+
+function runInvalid(p: ToolProps<typeof InvalidTool>): ToolInline {
+  return {
+    icon: "✗",
+    title: text(p.frame.state.title) || "Invalid Tool",
+    mode: "block",
+    body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
+  }
+}
+
+function runBatch(p: ToolProps<typeof BatchTool>): ToolInline {
+  const calls = list(p.input.tool_calls).length
+  return {
+    icon: "#",
+    title: text(p.frame.state.title) || (calls > 0 ? `Batch ${calls} tool${calls === 1 ? "" : "s"}` : "Batch"),
+    mode: "block",
+    body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
+  }
+}
+
+function lspTitle(
+  input: {
+    operation?: string
+    filePath?: string
+    line?: number
+    character?: number
+  },
+  opts: { home?: boolean } = {},
+): string {
+  const op = input.operation || "request"
+  const file = input.filePath ? toolPath(input.filePath, opts) : ""
+  const line = typeof input.line === "number" ? input.line : undefined
+  const char = typeof input.character === "number" ? input.character : undefined
+  const pos = line !== undefined && char !== undefined ? `:${line}:${char}` : ""
+  if (!file) {
+    return `LSP ${op}`
+  }
+
+  return `LSP ${op} ${file}${pos}`
+}
+
+function runLsp(p: ToolProps<typeof LspTool>): ToolInline {
+  return {
+    icon: "→",
+    title: text(p.frame.state.title) || lspTitle(p.input),
+  }
+}
+
+function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline {
+  return {
+    icon: "→",
+    title: text(p.frame.state.title) || "Switching to build agent",
+    mode: "block",
+    body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
+  }
+}
+
+type PatchFile = Tool.InferMetadata<typeof ApplyPatchTool>["files"][number]
+
+function patchTitle(file: PatchFile): string {
+  const rel = file.relativePath
+  const from = file.filePath
+  if (file.type === "add") {
+    return `# Created ${rel || toolPath(from)}`
+  }
+  if (file.type === "delete") {
+    return `# Deleted ${rel || toolPath(from)}`
+  }
+  if (file.type === "move") {
+    return `# Moved ${toolPath(from)} -> ${rel || toolPath(file.movePath)}`
+  }
+
+  return `← Patched ${rel || toolPath(from)}`
+}
+
+function snapWrite(p: ToolProps<typeof WriteTool>): ToolSnapshot | undefined {
+  const file = p.input.filePath || ""
+  const content = p.input.content || ""
+  if (!file && !content) {
+    return
+  }
+
+  return {
+    kind: "code",
+    title: `# Wrote ${toolPath(file)}`,
+    content,
+    file,
+  }
+}
+
+function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined {
+  const file = p.input.filePath || ""
+  const diff = p.metadata.diff || ""
+  if (!file || !diff.trim()) {
+    return
+  }
+
+  return {
+    kind: "diff",
+    items: [
+      {
+        title: `← Edit ${toolPath(file)}`,
+        diff,
+        file,
+      },
+    ],
+  }
+}
+
+function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefined {
+  const files = list<PatchFile>(p.frame.meta.files)
+  if (files.length === 0) {
+    return
+  }
+
+  return {
+    kind: "diff",
+    items: files
+      .map((file) => {
+        if (!file || typeof file !== "object") {
+          return
+        }
+
+        const diff = typeof file.diff === "string" ? file.diff : ""
+        if (!diff.trim()) {
+          return
+        }
+
+        const name = file.movePath || file.filePath || file.relativePath
+        return {
+          title: patchTitle(file),
+          diff,
+          file: name,
+          deletions: typeof file.deletions === "number" ? file.deletions : 0,
+        }
+      })
+      .filter((item): item is NonNullable<typeof item> => Boolean(item)),
+  }
+}
+
+function snapTask(p: ToolProps<typeof TaskTool>): ToolSnapshot {
+  const kind = Locale.titlecase(p.input.subagent_type || "general")
+  const rows: string[] = []
+  const desc = p.input.description
+  if (desc) {
+    rows.push(`◉ ${desc}`)
+  }
+  const title = text(p.frame.state.title)
+  if (title) {
+    rows.push(`↳ ${title}`)
+  }
+  const calls = num(p.frame.meta.toolcalls) ?? num(p.frame.meta.toolCalls) ?? num(p.frame.meta.calls)
+  if (calls !== undefined) {
+    rows.push(`↳ ${Locale.number(calls)} toolcall${calls === 1 ? "" : "s"}`)
+  }
+  const sid = text(p.frame.meta.sessionId) || text(p.frame.meta.sessionID)
+  if (sid) {
+    rows.push(`↳ session ${sid}`)
+  }
+
+  return {
+    kind: "task",
+    title: `# ${kind} Task`,
+    rows,
+    tail: done(`${kind} task`, span(p.frame.state)),
+  }
+}
+
+function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
+  const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => {
+    const content = typeof item?.content === "string" ? item.content : ""
+    if (!content) {
+      return []
+    }
+
+    return [
+      {
+        status: typeof item.status === "string" ? item.status : "",
+        content,
+      },
+    ]
+  })
+  const doneN = items.filter((item) => item.status === "completed").length
+  const runN = items.filter((item) => item.status === "in_progress").length
+  const left = items.length - doneN - runN
+  const tail = [`${items.length} total`]
+  if (doneN > 0) {
+    tail.push(`${doneN} done`)
+  }
+  if (runN > 0) {
+    tail.push(`${runN} active`)
+  }
+  if (left > 0) {
+    tail.push(`${left} pending`)
+  }
+
+  return {
+    kind: "todo",
+    items,
+    tail: `${done("todos", span(p.frame.state))} · ${tail.join(" · ")}`,
+  }
+}
+
+function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
+  const answers = list<unknown[]>(p.frame.meta.answers)
+  const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
+    const answer = list<string>(answers[i]).filter((entry) => typeof entry === "string")
+    return {
+      question: item.question || `Question ${i + 1}`,
+      answer: answer.length > 0 ? answer.join(", ") : "(no answer)",
+    }
+  })
+
+  return {
+    kind: "question",
+    items,
+    tail: done("questions", span(p.frame.state)),
+  }
+}
+
+function scrollBashStart(p: ToolProps<typeof BashTool>): string {
+  const cmd = p.input.command ?? ""
+  const desc = p.input.description || "Shell"
+  const wd = p.input.workdir ?? ""
+  const dir = wd && wd !== "." ? toolPath(wd) : ""
+  const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc
+
+  if (!cmd) {
+    return `# ${title}`
+  }
+
+  return `# ${title}\n$ ${cmd}`
+}
+
+function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
+  const out = stripAnsi(p.frame.raw)
+  const cmd = (p.input.command ?? "").trim()
+  if (!cmd) {
+    return out
+  }
+
+  const wdRaw = (p.input.workdir ?? "").trim()
+  const wd = wdRaw ? toolPath(wdRaw) : ""
+  const lines = out.split("\n")
+  const first = (lines[0] || "").trim()
+  const second = (lines[1] || "").trim()
+
+  if (wd && (first === wd || first === wdRaw) && second === cmd) {
+    const body = lines.slice(2).join("\n")
+    if (body.length > 0) {
+      return body
+    }
+    return out
+  }
+
+  if (first === cmd || first === `$ ${cmd}`) {
+    const body = lines.slice(1).join("\n")
+    if (body.length > 0) {
+      return body
+    }
+    return out
+  }
+
+  if (wd && (first === `${wd} ${cmd}` || first === `${wdRaw} ${cmd}`)) {
+    const body = lines.slice(1).join("\n")
+    if (body.length > 0) {
+      return body
+    }
+    return out
+  }
+
+  return out
+}
+
+function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
+  const code = p.metadata.exit ?? num(p.frame.meta.exitCode) ?? num(p.frame.meta.exit_code)
+  const time = span(p.frame.state)
+  if (code === undefined) {
+    return done("bash", time)
+  }
+
+  return `└ bash completed (exit ${code})${time ? ` · ${time}` : ""}`
+}
+
+function scrollReadStart(p: ToolProps<typeof ReadTool>): string {
+  const file = toolPath(p.input.filePath)
+  const extra = info(p.frame.input, ["filePath"])
+  const tail = extra ? ` ${extra}` : ""
+  return `→ Read ${file}${tail}`.trim()
+}
+
+function scrollWriteStart(p: ToolProps<typeof WriteTool>): string {
+  return `← Write ${toolPath(p.input.filePath)}`.trim()
+}
+
+function scrollEditStart(p: ToolProps<typeof EditTool>): string {
+  const flag = info({ replaceAll: p.input.replaceAll })
+  const tail = flag ? ` ${flag}` : ""
+  return `← Edit ${toolPath(p.input.filePath)}${tail}`.trim()
+}
+
+function scrollPatchStart(p: ToolProps<typeof ApplyPatchTool>): string {
+  const files = list<PatchFile>(p.frame.meta.files)
+  if (files.length === 0) {
+    return "% Patch"
+  }
+
+  return `% Patch ${files.length} file${files.length === 1 ? "" : "s"}`
+}
+
+function patchLine(file: PatchFile): string {
+  const type = file.type
+  const rel = file.relativePath
+  const from = file.filePath
+
+  if (type === "add") {
+    return `+ Created ${rel || toolPath(from)}`
+  }
+
+  if (type === "delete") {
+    return `- Deleted ${rel || toolPath(from)}`
+  }
+
+  if (type === "move") {
+    return `→ Moved ${toolPath(from)} → ${rel || toolPath(file.movePath)}`
+  }
+
+  return `~ Patched ${rel || toolPath(from)}`
+}
+
+function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
+  const files = list<PatchFile>(p.frame.meta.files)
+  const head = done("patch", span(p.frame.state))
+  if (files.length === 0) {
+    return head
+  }
+
+  const rows = [head, ...files.slice(0, 6).map(patchLine)]
+  if (files.length > 6) {
+    rows.push(`... and ${files.length - 6} more`)
+  }
+
+  return rows.join("\n")
+}
+
+function scrollTaskStart(p: ToolProps<typeof TaskTool>): string {
+  const kind = Locale.titlecase(p.input.subagent_type || "general")
+  const desc = p.input.description
+  if (!desc) {
+    return `│ ${kind} Task`
+  }
+
+  return `│ ${kind} Task — ${desc}`
+}
+
+function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
+  const kind = Locale.titlecase(p.input.subagent_type || "general")
+  const head = done(`${kind} task`, span(p.frame.state))
+  const rows: string[] = [head]
+
+  const title = text(p.frame.state.title)
+  if (title) {
+    rows.push(`↳ ${title}`)
+  }
+
+  const calls = num(p.frame.meta.toolcalls) ?? num(p.frame.meta.toolCalls) ?? num(p.frame.meta.calls)
+  if (calls !== undefined) {
+    rows.push(`↳ ${Locale.number(calls)} toolcall${calls === 1 ? "" : "s"}`)
+  }
+
+  const sid = text(p.frame.meta.sessionId) || text(p.frame.meta.sessionID)
+  if (sid) {
+    rows.push(`↳ session ${sid}`)
+  }
+
+  return rows.join("\n")
+}
+
+function scrollTodoStart(p: ToolProps<typeof TodoWriteTool>): string {
+  const todos = p.input.todos ?? []
+  if (todos.length === 0) {
+    return "⚙ Updating todos..."
+  }
+
+  return `⚙ Updating ${todos.length} todo${todos.length === 1 ? "" : "s"}`
+}
+
+function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
+  const list = p.input.todos ?? []
+  if (list.length === 0) {
+    return done("todos", span(p.frame.state))
+  }
+
+  const doneN = list.filter((item) => item.status === "completed").length
+  const runN = list.filter((item) => item.status === "in_progress").length
+  const left = list.length - doneN - runN
+  const tail = [`${list.length} total`]
+  if (doneN > 0) {
+    tail.push(`${doneN} done`)
+  }
+  if (runN > 0) {
+    tail.push(`${runN} active`)
+  }
+  if (left > 0) {
+    tail.push(`${left} pending`)
+  }
+
+  return `${done("todos", span(p.frame.state))} · ${tail.join(" · ")}`
+}
+
+function scrollQuestionStart(p: ToolProps<typeof QuestionTool>): string {
+  const total = list(p.frame.input.questions).length
+  return `→ Asked ${total} question${total === 1 ? "" : "s"}`
+}
+
+function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
+  const q = p.input.questions ?? []
+  const a = p.metadata.answers ?? []
+  if (q.length === 0) {
+    return done("questions", span(p.frame.state))
+  }
+
+  const rows = [done("questions", span(p.frame.state))]
+  for (const [i, item] of q.slice(0, 4).entries()) {
+    const prompt = item.question
+    const reply = a[i] ?? []
+    rows.push(`? ${prompt || `Question ${i + 1}`}`)
+    rows.push(`  ${reply.length > 0 ? reply.join(", ") : "(no answer)"}`)
+  }
+
+  if (q.length > 4) {
+    rows.push(`... and ${q.length - 4} more`)
+  }
+
+  return rows.join("\n")
+}
+
+function scrollLspStart(p: ToolProps<typeof LspTool>): string {
+  return `→ ${lspTitle(p.input)}`
+}
+
+function scrollSkillStart(p: ToolProps<typeof SkillTool>): string {
+  return `→ Skill "${p.input.name ?? ""}"`
+}
+
+function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
+  const pattern = p.input.pattern ?? ""
+  const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob"
+  const dir = p.input.path ?? ""
+  if (!dir) {
+    return head
+  }
+
+  return `${head} in ${toolPath(dir)}`
+}
+
+function scrollGrepStart(p: ToolProps<typeof GrepTool>): string {
+  const pattern = p.input.pattern ?? ""
+  const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep"
+  const dir = p.input.path ?? ""
+  if (!dir) {
+    return head
+  }
+
+  return `${head} in ${toolPath(dir)}`
+}
+
+function scrollListStart(p: ToolProps<typeof ListTool>): string {
+  const dir = p.input.path ?? ""
+  if (!dir) {
+    return "→ List"
+  }
+
+  return `→ List ${toolPath(dir)}`
+}
+
+function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
+  const url = p.input.url ?? ""
+  if (!url) {
+    return "% WebFetch"
+  }
+
+  return `% WebFetch ${url}`
+}
+
+function scrollCodeSearchStart(p: ToolProps<typeof CodeSearchTool>): string {
+  const query = p.input.query ?? ""
+  if (!query) {
+    return "◇ Exa Code Search"
+  }
+
+  return `◇ Exa Code Search "${query}"`
+}
+
+function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
+  const query = p.input.query ?? ""
+  if (!query) {
+    return "◈ Exa Web Search"
+  }
+
+  return `◈ Exa Web Search "${query}"`
+}
+
+function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo {
+  const input = p.input as { filePath?: string; filepath?: string; diff?: string }
+  const file = input.filePath || input.filepath || p.patterns[0] || ""
+  return {
+    icon: "→",
+    title: `Edit ${toolPath(file, { home: true })}`,
+    lines: [],
+    diff: p.metadata.diff ?? input.diff,
+    file,
+  }
+}
+
+function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
+  const file = p.input.filePath || p.patterns[0] || ""
+  return {
+    icon: "→",
+    title: `Read ${toolPath(file, { home: true })}`,
+    lines: file ? [`Path: ${toolPath(file, { home: true })}`] : [],
+  }
+}
+
+function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
+  const pattern = p.input.pattern || p.patterns[0] || ""
+  return {
+    icon: "✱",
+    title: `Glob "${pattern}"`,
+    lines: pattern ? [`Pattern: ${pattern}`] : [],
+  }
+}
+
+function permGrep(p: ToolPermissionProps<typeof GrepTool>): ToolPermissionInfo {
+  const pattern = p.input.pattern || p.patterns[0] || ""
+  return {
+    icon: "✱",
+    title: `Grep "${pattern}"`,
+    lines: pattern ? [`Pattern: ${pattern}`] : [],
+  }
+}
+
+function permList(p: ToolPermissionProps<typeof ListTool>): ToolPermissionInfo {
+  const dir = p.input.path || p.patterns[0] || ""
+  return {
+    icon: "→",
+    title: `List ${toolPath(dir, { home: true })}`,
+    lines: dir ? [`Path: ${toolPath(dir, { home: true })}`] : [],
+  }
+}
+
+function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
+  const title = p.input.description || "Shell command"
+  const cmd = p.input.command || ""
+  return {
+    icon: "#",
+    title,
+    lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`),
+  }
+}
+
+function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
+  const type = p.input.subagent_type || "general"
+  const desc = p.input.description
+  return {
+    icon: "#",
+    title: `${Locale.titlecase(type)} Task`,
+    lines: desc ? [`◉ ${desc}`] : [],
+  }
+}
+
+function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissionInfo {
+  const url = p.input.url || ""
+  return {
+    icon: "%",
+    title: `WebFetch ${url}`,
+    lines: url ? [`URL: ${url}`] : [],
+  }
+}
+
+function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermissionInfo {
+  const query = p.input.query || ""
+  return {
+    icon: "◈",
+    title: `Exa Web Search "${query}"`,
+    lines: query ? [`Query: ${query}`] : [],
+  }
+}
+
+function permCodeSearch(p: ToolPermissionProps<typeof CodeSearchTool>): ToolPermissionInfo {
+  const query = p.input.query || ""
+  return {
+    icon: "◇",
+    title: `Exa Code Search "${query}"`,
+    lines: query ? [`Query: ${query}`] : [],
+  }
+}
+
+function permLsp(p: ToolPermissionProps<typeof LspTool>): ToolPermissionInfo {
+  const file = p.input.filePath || ""
+  const line = typeof p.input.line === "number" ? p.input.line : undefined
+  const char = typeof p.input.character === "number" ? p.input.character : undefined
+  const pos = line !== undefined && char !== undefined ? `${line}:${char}` : undefined
+  return {
+    icon: "→",
+    title: lspTitle(p.input, { home: true }),
+    lines: [
+      ...(p.input.operation ? [`Operation: ${p.input.operation}`] : []),
+      ...(file ? [`Path: ${toolPath(file, { home: true })}`] : []),
+      ...(pos ? [`Position: ${pos}`] : []),
+    ],
+  }
+}
+
+const TOOL_RULES = {
+  invalid: {
+    view: {
+      output: true,
+      final: false,
+    },
+    run: runInvalid,
+    scroll: {
+      start: () => "",
+    },
+  },
+  bash: {
+    view: {
+      output: true,
+      final: false,
+    },
+    run: runBash,
+    scroll: {
+      start: scrollBashStart,
+      progress: scrollBashProgress,
+      final: scrollBashFinal,
+    },
+    permission: permBash,
+  },
+  write: {
+    view: {
+      output: false,
+      final: true,
+      snap: "code",
+    },
+    run: runWrite,
+    snap: snapWrite,
+    scroll: {
+      start: scrollWriteStart,
+    },
+  },
+  edit: {
+    view: {
+      output: false,
+      final: true,
+      snap: "diff",
+    },
+    run: runEdit,
+    snap: snapEdit,
+    scroll: {
+      start: scrollEditStart,
+    },
+    permission: permEdit,
+  },
+  apply_patch: {
+    view: {
+      output: false,
+      final: true,
+      snap: "diff",
+    },
+    run: runPatch,
+    snap: snapPatch,
+    scroll: {
+      start: scrollPatchStart,
+      final: scrollPatchFinal,
+    },
+  },
+  batch: {
+    view: {
+      output: true,
+      final: false,
+    },
+    run: runBatch,
+    scroll: {
+      start: () => "",
+    },
+  },
+  task: {
+    view: {
+      output: false,
+      final: true,
+      snap: "structured",
+    },
+    run: runTask,
+    snap: snapTask,
+    scroll: {
+      start: scrollTaskStart,
+      final: scrollTaskFinal,
+    },
+    permission: permTask,
+  },
+  todowrite: {
+    view: {
+      output: false,
+      final: true,
+      snap: "structured",
+    },
+    run: runTodo,
+    snap: snapTodo,
+    scroll: {
+      start: scrollTodoStart,
+      final: scrollTodoFinal,
+    },
+  },
+  question: {
+    view: {
+      output: false,
+      final: true,
+      snap: "structured",
+    },
+    run: runQuestion,
+    snap: snapQuestion,
+    scroll: {
+      start: scrollQuestionStart,
+      final: scrollQuestionFinal,
+    },
+  },
+  read: {
+    view: {
+      output: false,
+      final: false,
+    },
+    run: runRead,
+    scroll: {
+      start: scrollReadStart,
+    },
+    permission: permRead,
+  },
+  glob: {
+    view: {
+      output: false,
+      final: false,
+    },
+    run: runGlob,
+    scroll: {
+      start: scrollGlobStart,
+    },
+    permission: permGlob,
+  },
+  grep: {
+    view: {
+      output: false,
+      final: false,
+    },
+    run: runGrep,
+    scroll: {
+      start: scrollGrepStart,
+    },
+    permission: permGrep,
+  },
+  list: {
+    view: {
+      output: false,
+      final: false,
+    },
+    run: runList,
+    scroll: {
+      start: scrollListStart,
+    },
+    permission: permList,
+  },
+  lsp: {
+    view: {
+      output: false,
+      final: false,
+    },
+    run: runLsp,
+    scroll: {
+      start: scrollLspStart,
+    },
+    permission: permLsp,
+  },
+  webfetch: {
+    view: {
+      output: false,
+      final: false,
+    },
+    run: runWebfetch,
+    scroll: {
+      start: scrollWebfetchStart,
+    },
+    permission: permWebfetch,
+  },
+  codesearch: {
+    view: {
+      output: false,
+      final: false,
+    },
+    run: runCodeSearch,
+    scroll: {
+      start: scrollCodeSearchStart,
+    },
+    permission: permCodeSearch,
+  },
+  websearch: {
+    view: {
+      output: false,
+      final: false,
+    },
+    run: runWebSearch,
+    scroll: {
+      start: scrollWebSearchStart,
+    },
+    permission: permWebSearch,
+  },
+  skill: {
+    view: {
+      output: false,
+      final: false,
+    },
+    run: runSkill,
+    scroll: {
+      start: scrollSkillStart,
+    },
+  },
+  plan_exit: {
+    view: {
+      output: true,
+      final: false,
+    },
+    run: runPlanExit,
+    scroll: {
+      start: () => "",
+    },
+  },
+} as const satisfies ToolRegistry
+
+function key(name: string): name is ToolName {
+  return Object.prototype.hasOwnProperty.call(TOOL_RULES, name)
+}
+
+function rule(name?: string): AnyToolRule | undefined {
+  if (!name || !key(name)) {
+    return
+  }
+
+  return TOOL_RULES[name] as AnyToolRule
+}
+
+function frame(part: ToolPart): ToolFrame {
+  const state = dict(part.state)
+  return {
+    raw: "",
+    name: part.tool,
+    input: dict(state.input),
+    meta: dict(state.metadata),
+    state,
+    status: text(state.status),
+    error: text(state.error),
+  }
+}
+
+export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
+  const state = dict(commit.part?.state)
+  return {
+    raw,
+    name: commit.tool || commit.part?.tool || "tool",
+    input: dict(state.input),
+    meta: dict(state.metadata),
+    state,
+    status: commit.toolState ?? text(state.status),
+    error: (commit.toolError ?? "").trim(),
+  }
+}
+
+function runBash(p: ToolProps<typeof BashTool>): ToolInline {
+  return {
+    icon: "$",
+    title: p.input.command || "",
+    mode: "block",
+    body: p.frame.status === "completed" ? text(p.frame.state.output).trim() : undefined,
+  }
+}
+
+export function toolView(name?: string): ToolView {
+  return (
+    rule(name)?.view ?? {
+      output: true,
+      final: true,
+    }
+  )
+}
+
+export function toolInlineInfo(part: ToolPart): ToolInline {
+  const ctx = frame(part)
+  const draw = rule(ctx.name)?.run
+  try {
+    if (draw) {
+      return draw(props(ctx))
+    }
+  } catch {
+    return fallbackInline(ctx)
+  }
+
+  return fallbackInline(ctx)
+}
+
+export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string {
+  const draw = rule(ctx.name)?.scroll?.[phase]
+  try {
+    if (draw) {
+      return draw(props(ctx))
+    }
+  } catch {
+    if (phase === "start") {
+      return fallbackStart(ctx)
+    }
+    if (phase === "progress") {
+      return ctx.raw
+    }
+    return fallbackFinal(ctx)
+  }
+
+  if (phase === "start") {
+    return fallbackStart(ctx)
+  }
+
+  if (phase === "progress") {
+    return ctx.raw
+  }
+
+  return fallbackFinal(ctx)
+}
+
+export function toolPermissionInfo(
+  name: string,
+  input: ToolDict,
+  meta: ToolDict,
+  patterns: string[],
+): ToolPermissionInfo | undefined {
+  const draw = rule(name)?.permission
+  if (!draw) {
+    return
+  }
+
+  try {
+    return draw(permission({ input, meta, patterns }))
+  } catch {
+    return
+  }
+}
+
+export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
+  const ctx = toolFrame(commit, raw)
+  const draw = rule(ctx.name)?.snap
+  if (!draw) {
+    return
+  }
+
+  try {
+    return draw(props(ctx))
+  } catch {
+    return
+  }
+}
+
+export function toolFiletype(input?: string): string | undefined {
+  if (!input) {
+    return
+  }
+
+  const ext = path.extname(input)
+  const lang = LANGUAGE_EXTENSIONS[ext]
+  if (["typescriptreact", "javascriptreact", "javascript"].includes(lang)) {
+    return "typescript"
+  }
+
+  return lang
+}
+
+export function toolDiffView(width: number, style: RunDiffStyle | undefined): "unified" | "split" {
+  if (style === "stacked") {
+    return "unified"
+  }
+
+  return width > 120 ? "split" : "unified"
+}

+ 94 - 0
packages/opencode/src/cli/cmd/run/trace.ts

@@ -0,0 +1,94 @@
+// Dev-only JSONL event trace for direct interactive mode.
+//
+// Enable with OPENCODE_DIRECT_TRACE=1. Writes one JSON line per event to
+// ~/.local/share/opencode/log/direct/<timestamp>-<pid>.jsonl. Also writes
+// a latest.json pointer so you can quickly find the most recent trace.
+//
+// The trace captures the full closed loop: outbound prompts, inbound SDK
+// events, reducer output, footer commits, and turn lifecycle markers.
+// Useful for debugging stream ordering, permission behavior, and
+// footer/transcript mismatches.
+//
+// Lazy-initialized: the first call to trace() decides whether tracing is
+// active based on the env var, and subsequent calls return the cached result.
+import fs from "fs"
+import path from "path"
+import { Global } from "../../../global"
+
+export type Trace = {
+  write(type: string, data?: unknown): void
+}
+
+let state: Trace | false | undefined
+
+function stamp() {
+  return new Date()
+    .toISOString()
+    .replace(/[-:]/g, "")
+    .replace(/\.\d+Z$/, "Z")
+}
+
+function file() {
+  return path.join(Global.Path.log, "direct", `${stamp()}-${process.pid}.jsonl`)
+}
+
+function latest() {
+  return path.join(Global.Path.log, "direct", "latest.json")
+}
+
+function text(data: unknown) {
+  return JSON.stringify(
+    data,
+    (_key, value) => {
+      if (typeof value === "bigint") {
+        return String(value)
+      }
+
+      return value
+    },
+    0,
+  )
+}
+
+export function trace() {
+  if (state !== undefined) {
+    return state || undefined
+  }
+
+  if (!process.env.OPENCODE_DIRECT_TRACE) {
+    state = false
+    return
+  }
+
+  const target = file()
+  fs.mkdirSync(path.dirname(target), { recursive: true })
+  fs.writeFileSync(
+    latest(),
+    text({
+      time: new Date().toISOString(),
+      pid: process.pid,
+      cwd: process.cwd(),
+      argv: process.argv.slice(2),
+      path: target,
+    }) + "\n",
+  )
+  state = {
+    write(type: string, data?: unknown) {
+      fs.appendFileSync(
+        target,
+        text({
+          time: new Date().toISOString(),
+          pid: process.pid,
+          type,
+          data,
+        }) + "\n",
+      )
+    },
+  }
+  state.write("trace.start", {
+    argv: process.argv.slice(2),
+    cwd: process.cwd(),
+    path: target,
+  })
+  return state
+}

+ 180 - 0
packages/opencode/src/cli/cmd/run/types.ts

@@ -0,0 +1,180 @@
+// Shared type vocabulary for the direct interactive mode (`run --interactive`).
+//
+// Direct mode uses a split-footer terminal layout: immutable scrollback for the
+// session transcript, and a mutable footer for prompt input, status, and
+// permission/question UI. Every module in run/* shares these types to stay
+// aligned on that two-lane model.
+//
+// Data flow through the system:
+//
+//   SDK events → session-data reducer → StreamCommit[] + FooterOutput
+//     → stream.ts bridges to footer API
+//       → footer.ts queues commits and patches the footer view
+//         → OpenTUI split-footer renderer writes to terminal
+import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
+
+export type RunFilePart = {
+  type: "file"
+  url: string
+  filename: string
+  mime: string
+}
+
+type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
+
+export type RunInput = {
+  sdk: OpencodeClient
+  sessionID: string
+  sessionTitle?: string
+  resume?: boolean
+  agent: string | undefined
+  model: PromptModel | undefined
+  variant: string | undefined
+  files: RunFilePart[]
+  initialInput?: string
+  thinking: boolean
+  demo?: RunDemo
+  demoText?: string
+}
+
+export type RunDemo = "on" | "permission" | "question" | "mix" | "text"
+
+// The semantic role of a scrollback entry. Maps 1:1 to theme colors.
+export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
+
+// Whether the assistant is actively processing a turn.
+export type FooterPhase = "idle" | "running"
+
+// Full snapshot of footer status bar state. Every update replaces the whole
+// object in the SolidJS signal so the view re-renders atomically.
+export type FooterState = {
+  phase: FooterPhase
+  status: string
+  queue: number
+  model: string
+  duration: string
+  usage: string
+  first: boolean
+  interrupt: number
+  exit: number
+}
+
+// A partial update to FooterState. The footer merges this onto the current state.
+export type FooterPatch = Partial<FooterState>
+
+export type RunDiffStyle = "auto" | "stacked"
+
+export type ScrollbackOptions = {
+  diffStyle?: RunDiffStyle
+}
+
+// Which interactive surface the footer is showing. Only one view is active at
+// a time. The reducer drives transitions: when a permission arrives the view
+// switches to "permission", and when the permission resolves it falls back to
+// "prompt".
+export type FooterView =
+  | { type: "prompt" }
+  | { type: "permission"; request: PermissionRequest }
+  | { type: "question"; request: QuestionRequest }
+
+// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
+export type FooterOutput = {
+  patch?: FooterPatch
+  view?: FooterView
+}
+
+// Typed messages sent to RunFooter.event(). The prompt queue and stream
+// transport both emit these to update footer state without reaching into
+// internal signals directly.
+export type FooterEvent =
+  | {
+      type: "queue"
+      queue: number
+    }
+  | {
+      type: "first"
+      first: boolean
+    }
+  | {
+      type: "model"
+      model: string
+    }
+  | {
+      type: "turn.send"
+      queue: number
+    }
+  | {
+      type: "turn.wait"
+    }
+  | {
+      type: "turn.idle"
+      queue: number
+    }
+  | {
+      type: "turn.duration"
+      duration: string
+    }
+  | {
+      type: "stream.patch"
+      patch: FooterPatch
+    }
+  | {
+      type: "stream.view"
+      view: FooterView
+    }
+
+export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
+
+export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
+
+export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
+
+export type FooterKeybinds = {
+  leader: string
+  variantCycle: string
+  interrupt: string
+  historyPrevious: string
+  historyNext: string
+  inputSubmit: string
+  inputNewline: string
+}
+
+// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
+// appends content (coalesced in the footer queue), "final" closes it.
+export type StreamPhase = "start" | "progress" | "final"
+
+export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
+
+export type StreamToolState = "running" | "completed" | "error"
+
+// A single append-only commit to scrollback. The session-data reducer produces
+// these from SDK events, and RunFooter.append() queues them for the next
+// microtask flush. Once flushed, they become immutable terminal scrollback
+// rows -- they cannot be rewritten.
+export type StreamCommit = {
+  kind: EntryKind
+  text: string
+  phase: StreamPhase
+  source: StreamSource
+  messageID?: string
+  partID?: string
+  tool?: string
+  part?: ToolPart
+  interrupted?: boolean
+  toolState?: StreamToolState
+  toolError?: string
+}
+
+// The public contract between the stream transport / prompt queue and
+// the footer. RunFooter implements this. The transport and queue never
+// touch the renderer directly -- they go through this interface.
+export type FooterApi = {
+  readonly isClosed: boolean
+  onPrompt(fn: (text: string) => void): () => void
+  onClose(fn: () => void): () => void
+  event(next: FooterEvent): void
+  append(commit: StreamCommit): void
+  idle(): Promise<void>
+  close(): void
+  destroy(): void
+}

+ 126 - 0
packages/opencode/src/cli/cmd/run/variant.shared.ts

@@ -0,0 +1,126 @@
+// Model variant resolution and persistence.
+//
+// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
+// Resolution priority: CLI --variant flag > saved preference > session history.
+//
+// The saved variant persists across sessions in ~/.local/state/opencode/model.json
+// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
+// variant and the persisted file.
+import path from "path"
+import { Global } from "../../../global"
+import { Filesystem } from "../../../util/filesystem"
+import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
+import type { RunInput } from "./types"
+
+const MODEL_FILE = path.join(Global.Path.state, "model.json")
+
+type ModelState = {
+  variant?: Record<string, string | undefined>
+}
+
+function modelKey(provider: string, model: string): string {
+  return `${provider}/${model}`
+}
+
+function variantKey(model: NonNullable<RunInput["model"]>): string {
+  return modelKey(model.providerID, model.modelID)
+}
+
+export function formatModelLabel(model: NonNullable<RunInput["model"]>, variant: string | undefined): string {
+  const label = variant ? ` · ${variant}` : ""
+  return `${model.modelID} · ${model.providerID}${label}`
+}
+
+export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
+  if (variants.length === 0) {
+    return undefined
+  }
+
+  if (!current) {
+    return variants[0]
+  }
+
+  const idx = variants.indexOf(current)
+  if (idx === -1 || idx === variants.length - 1) {
+    return undefined
+  }
+
+  return variants[idx + 1]
+}
+
+export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
+  return sessionVariant(Array.isArray(input) ? createSession(input) : input, model)
+}
+
+function fitVariant(value: string | undefined, variants: string[]): string | undefined {
+  if (!value) {
+    return undefined
+  }
+
+  if (variants.length === 0 || variants.includes(value)) {
+    return value
+  }
+
+  return undefined
+}
+
+// Picks the active variant. CLI flag wins, then saved preference, then session
+// history. fitVariant() checks saved and session values against the available
+// variants list -- if the provider doesn't offer a variant, it drops.
+export function resolveVariant(
+  input: string | undefined,
+  session: string | undefined,
+  saved: string | undefined,
+  variants: string[],
+): string | undefined {
+  if (input !== undefined) {
+    return input
+  }
+
+  const fallback = fitVariant(saved, variants)
+  const current = fitVariant(session, variants)
+  if (current !== undefined) {
+    return current
+  }
+
+  return fallback
+}
+
+export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
+  if (!model) {
+    return undefined
+  }
+
+  try {
+    const state = await Filesystem.readJson<ModelState>(MODEL_FILE)
+    return state.variant?.[variantKey(model)]
+  } catch {
+    return undefined
+  }
+}
+
+export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
+  if (!model) {
+    return
+  }
+
+  void (async () => {
+    const state = await Filesystem.readJson<ModelState>(MODEL_FILE).catch(() => ({}) as ModelState)
+    const map = {
+      ...(state.variant ?? {}),
+    }
+    const key = variantKey(model)
+    if (variant) {
+      map[key] = variant
+    }
+
+    if (!variant) {
+      delete map[key]
+    }
+
+    await Filesystem.writeJson(MODEL_FILE, {
+      ...state,
+      variant: map,
+    })
+  })().catch(() => {})
+}

+ 1 - 0
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -255,6 +255,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
   const route = useRoute()
   const dimensions = useTerminalDimensions()
   const renderer = useRenderer()
+
   const dialog = useDialog()
   const local = useLocal()
   const kv = useKV()

+ 2 - 2
packages/opencode/src/cli/cmd/tui/attach.ts

@@ -1,6 +1,5 @@
 import { cmd } from "../cmd"
 import { UI } from "@/cli/ui"
-import { tui } from "./app"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 import { TuiConfig } from "@/config/tui"
 import { Instance } from "@/project/instance"
@@ -70,7 +69,8 @@ export const AttachCommand = cmd({
         directory: directory && existsSync(directory) ? directory : process.cwd(),
         fn: () => TuiConfig.get(),
       })
-      await tui({
+      const app = await import("./app")
+      await app.tui({
         url: args.url,
         config,
         args: {

+ 6 - 4
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -509,7 +509,9 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
   return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
 }
 
-function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
+// TODO: i exported this, just for keeping it simple for now, but this should
+// probably go into something shared if we decide to use this in opencode run
+export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
   const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
   const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
   const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
@@ -703,11 +705,11 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
   return RGBA.fromInts(grayValue, grayValue, grayValue)
 }
 
-function generateSyntax(theme: Theme) {
+export function generateSyntax(theme: TuiThemeCurrent) {
   return SyntaxStyle.fromTheme(getSyntaxRules(theme))
 }
 
-function generateSubtleSyntax(theme: Theme) {
+function generateSubtleSyntax(theme: TuiThemeCurrent) {
   const rules = getSyntaxRules(theme)
   return SyntaxStyle.fromTheme(
     rules.map((rule) => {
@@ -731,7 +733,7 @@ function generateSubtleSyntax(theme: Theme) {
   )
 }
 
-function getSyntaxRules(theme: Theme) {
+function getSyntaxRules(theme: TuiThemeCurrent) {
   return [
     {
       scope: ["default"],

+ 2 - 2
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -1,5 +1,4 @@
 import { cmd } from "@/cli/cmd/cmd"
-import { tui } from "./app"
 import { Rpc } from "@/util/rpc"
 import { type rpc } from "./worker"
 import path from "path"
@@ -201,7 +200,8 @@ export const TuiThreadCommand = cmd({
       }, 1000).unref?.()
 
       try {
-        await tui({
+        const app = await import("./app")
+        await app.tui({
           url: transport.url,
           async onSnapshot() {
             const tui = writeHeapSnapshot("tui.heapsnapshot")

+ 6 - 1
packages/opencode/src/tool/lsp.ts

@@ -36,7 +36,12 @@ export const LspTool = Tool.define("lsp", {
       permission: "lsp",
       patterns: ["*"],
       always: ["*"],
-      metadata: {},
+      metadata: {
+        operation: args.operation,
+        filePath: file,
+        line: args.line,
+        character: args.character,
+      },
     })
     const uri = pathToFileURL(file).href
     const position = {