Compare commits

...

7 Commits

Author SHA1 Message Date
Simon Klee
d5b17bfeb1 pin opentui 2026-04-07 21:57:34 +02:00
Simon Klee
a8e1d01a12 tool colors 2026-04-07 12:33:46 +02:00
Simon Klee
91a18af727 splash screen new lines 2026-04-07 12:13:27 +02:00
Simon Klee
4fb315297c tighten up splash screen 2026-04-07 12:08:16 +02:00
Simon Klee
fbf80ce803 left align splash 2026-04-07 11:43:30 +02:00
Simon Klee
db31f66923 fade without transparency 2026-04-07 09:19:45 +02:00
Simon Klee
1405c958eb 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
2026-04-07 08:49:31 +02:00
35 changed files with 10301 additions and 341 deletions

View File

@@ -341,8 +341,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.96",
"@opentui/solid": "0.1.96",
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@22d015f",
"@opentui/solid": "https://pkg.pr.new/@opentui/solid@22d015f",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -436,8 +436,8 @@
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "0.1.96",
"@opentui/solid": "0.1.96",
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@22d015f",
"@opentui/solid": "https://pkg.pr.new/@opentui/solid@22d015f",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -1506,21 +1506,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.96", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.96", "@opentui/core-darwin-x64": "0.1.96", "@opentui/core-linux-arm64": "0.1.96", "@opentui/core-linux-x64": "0.1.96", "@opentui/core-win32-arm64": "0.1.96", "@opentui/core-win32-x64": "0.1.96", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-VBO5zRiGM6fhibG3AwTMpf0JgbYWG0sXP5AsSJAYw8tQ18OCPj+EDLXGZ1DFmMnJWEi+glKYjmqnIp4yRCqi+Q=="],
"@opentui/core": ["@opentui/core@https://pkg.pr.new/anomalyco/opentui/@opentui/core@22d015f", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@22d015f", "@opentui/core-darwin-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@22d015f", "@opentui/core-linux-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@22d015f", "@opentui/core-linux-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@22d015f", "@opentui/core-win32-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@22d015f", "@opentui/core-win32-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@22d015f", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wm81gV1yKsbZ61Ls9MwV1o8MeHe/bF0slyMx4cTG7xE+XNGTz85YVb6c2HdBVnx6y6xIBbyAU0RINs96NJBttA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.96", "", { "os": "darwin", "cpu": "arm64" }, "sha512-909i75uhLmlUFCK3LK4iICaymiA7QaB45X9IDX94KaDyHL3Y1PgYTzoRZLJlqeOfOBjVfEjMAh/zA5XexWDMpA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@22d015f", {}, "sha512-+1meom2zIi1YglSbdk1c+mUKFsUrkZuSIndUyGO5odSaUXPQwc6t+l1xNuB2aadFergQX/BJJmH+zzfMxO5RiA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.96", "", { "os": "darwin", "cpu": "x64" }, "sha512-qukQjjScKldZAfgY9qVMPv4ZA6Ko7oXjNBUcSMGDgUiOitH6INT1cJQVUnAIu14DY15yEl08MEQ8soLDaSAHcg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@22d015f", {}, "sha512-FpQYVPP20A/tOu2suVOWQ+rFebnTgu6DC5VwnBAuJ+IYphl/IPvamoPJp9X998r8k3oszcG+ogM3w10cj/RUiA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.96", "", { "os": "linux", "cpu": "arm64" }, "sha512-9ktmyS24nfSmlFPX0GMWEaEYSjtEPbRn59y4KBhHVhzPsl+YKlzstyHomTBu51IAPu6oL3+t3Lu4gU+k1gFOQQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@22d015f", {}, "sha512-l6D2Hp1+wwiz7o+taoyIubVHe5cR9uiXEcTE+NvxbIr/CNnn9GnAD79ue2YFbKYSqZvYn1sVFq/HZegJTzjWMA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.96", "", { "os": "linux", "cpu": "x64" }, "sha512-m2pVhIdtqFYO+QSMc2VZgSSCNxRGPL+U+aKYYbvJjPzqCnIkHB9eO0ePU4b3t+V7GaWCcCP3vDCy3g1J5/FreA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@22d015f", {}, "sha512-ZcArJ32FIGt8bCUdmXIEs7G6OfKn82QQuk1s/z81XHFGnTziMunAKX2qWjIetvAA2b5xF37aRJt+zCtjfCy1Rw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.96", "", { "os": "win32", "cpu": "arm64" }, "sha512-OybZ4jvX6H6RKYyGpZqzy3ZrwKaxaXKWwFsmG6pC2J+GRhf5oCIIEy3Y5573h7zy1cq3T9cb225KzBANq9j5BA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@22d015f", {}, "sha512-Xgh2kvONV8Z6vCY5wK+aEXsBYW5AsI2IaHviZxckCZy+ymh5zFJFRnoF0i3q1QEbQyjaM76fczj6yfbnJF6mzw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.96", "", { "os": "win32", "cpu": "x64" }, "sha512-3YKjg90j14I7dJ94yN0pAYcTf4ogCoohv6ptRdG96XUyzrYhQiDMP398vCIOMjaLBjtMtFmTxSf+W46zm96BCQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@22d015f", {}, "sha512-YL25sjPE93SwtdzzBAhWHdsvlFJfRpsRPqJydEPGtFdE15z9m2F633BhSkoL0FVIFbznLpZSUCURJbs8qm+QKg=="],
"@opentui/solid": ["@opentui/solid@0.1.96", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.96", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-NGiVvG1ylswMjF9fzvpSaWLcZKQsPw67KRkIZgsdf4ZIKUZEZ94NktabCA92ti4WVGXhPvyM3SIX5S2+HvnJFg=="],
"@opentui/solid": ["@opentui/solid@https://pkg.pr.new/@opentui/solid@22d015f", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@22d015f", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-gG+5MCpvaI07CfAQsFe8oVhoUwvM9VXznoPKU3XKwWiJruhyagscAB++ozlY6I4lFUfqjC4soQioswlKirUchw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5676,6 +5676,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.96", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.96", "@opentui/core-darwin-x64": "0.1.96", "@opentui/core-linux-arm64": "0.1.96", "@opentui/core-linux-x64": "0.1.96", "@opentui/core-win32-arm64": "0.1.96", "@opentui/core-win32-x64": "0.1.96", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-VBO5zRiGM6fhibG3AwTMpf0JgbYWG0sXP5AsSJAYw8tQ18OCPj+EDLXGZ1DFmMnJWEi+glKYjmqnIp4yRCqi+Q=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.96", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.96", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-NGiVvG1ylswMjF9fzvpSaWLcZKQsPw67KRkIZgsdf4ZIKUZEZ94NktabCA92ti4WVGXhPvyM3SIX5S2+HvnJFg=="],
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6392,6 +6396,22 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.96", "", { "os": "darwin", "cpu": "arm64" }, "sha512-909i75uhLmlUFCK3LK4iICaymiA7QaB45X9IDX94KaDyHL3Y1PgYTzoRZLJlqeOfOBjVfEjMAh/zA5XexWDMpA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.96", "", { "os": "darwin", "cpu": "x64" }, "sha512-qukQjjScKldZAfgY9qVMPv4ZA6Ko7oXjNBUcSMGDgUiOitH6INT1cJQVUnAIu14DY15yEl08MEQ8soLDaSAHcg=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.96", "", { "os": "linux", "cpu": "arm64" }, "sha512-9ktmyS24nfSmlFPX0GMWEaEYSjtEPbRn59y4KBhHVhzPsl+YKlzstyHomTBu51IAPu6oL3+t3Lu4gU+k1gFOQQ=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.96", "", { "os": "linux", "cpu": "x64" }, "sha512-m2pVhIdtqFYO+QSMc2VZgSSCNxRGPL+U+aKYYbvJjPzqCnIkHB9eO0ePU4b3t+V7GaWCcCP3vDCy3g1J5/FreA=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.96", "", { "os": "win32", "cpu": "arm64" }, "sha512-OybZ4jvX6H6RKYyGpZqzy3ZrwKaxaXKWwFsmG6pC2J+GRhf5oCIIEy3Y5573h7zy1cq3T9cb225KzBANq9j5BA=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.96", "", { "os": "win32", "cpu": "x64" }, "sha512-3YKjg90j14I7dJ94yN0pAYcTf4ogCoohv6ptRdG96XUyzrYhQiDMP398vCIOMjaLBjtMtFmTxSf+W46zm96BCQ=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -6734,6 +6754,8 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],

View File

@@ -106,8 +106,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.96",
"@opentui/solid": "0.1.96",
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@22d015f",
"@opentui/solid": "https://pkg.pr.new/@opentui/solid@22d015f",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View File

@@ -1,3 +1,16 @@
// CLI entry point for `opencode run`.
//
// Handles three modes:
// 1. Non-interactive (default): sends a single prompt, streams events to
// stdout, and exits when the session goes idle.
// 2. Interactive local (`--interactive`): boots the split-footer direct mode
// with an in-process server (no external HTTP).
// 3. Interactive attach (`--interactive --attach`): connects to a running
// opencode server and runs interactive mode against it.
//
// Also supports `--command` for slash-command execution, `--format json` for
// raw event streaming, `--continue` / `--session` for session resumption,
// and `--fork` for forking before continuing.
import type { Argv } from "yargs"
import path from "path"
import { pathToFileURL } from "url"
@@ -7,40 +20,27 @@ import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Permission } from "../../permission"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ListTool } from "../../tool/ls"
import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
import type { RunDemo } from "./run/types"
type ToolProps<T> = {
input: Tool.InferParameters<T>
metadata: Tool.InferMetadata<T>
part: ToolPart
const runtimeTask = import("./run/runtime")
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
function pick(value: string | undefined): ModelInput | undefined {
if (!value) return undefined
const [providerID, ...rest] = value.split("/")
return {
providerID,
modelID: rest.join("/"),
} as ModelInput
}
function props<T>(part: ToolPart): ToolProps<T> {
const state = part.state
return {
input: state.input as Tool.InferParameters<T>,
metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
part,
}
type FilePart = {
type: "file"
url: string
filename: string
mime: string
}
type Inline = {
@@ -49,6 +49,11 @@ type Inline = {
description?: string
}
type SessionInfo = {
id: string
title?: string
}
function inline(info: Inline) {
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
@@ -62,160 +67,22 @@ function block(info: Inline, output?: string) {
UI.empty()
}
function fallback(part: ToolPart) {
const state = part.state
const input = "input" in state ? state.input : undefined
const title =
("title" in state && state.title ? state.title : undefined) ||
(input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
inline({
icon: "⚙",
title: `${part.tool} ${title}`,
})
}
async function tool(part: ToolPart) {
try {
const { toolInlineInfo } = await import("./run/tool")
const next = toolInlineInfo(part)
if (next.mode === "block") {
block(next, next.body)
return
}
function glob(info: ToolProps<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"),
)
}
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 (baseID && args.fork) {
const forked = await sdk.session.fork({ sessionID: baseID })
return forked.data?.id
if (!current?.data) {
UI.error("Session not found")
process.exit(1)
}
if (args.fork) {
const forked = await sdk.session.fork({
sessionID: args.session,
})
const id = forked.data?.id
if (!id) {
return
}
return {
id,
title: forked.data?.title ?? current.data.title,
}
}
return {
id: current.data.id,
title: current.data.title,
}
}
if (baseID) return baseID
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
if (base && args.fork) {
const forked = await sdk.session.fork({
sessionID: base.id,
})
const id = forked.data?.id
if (!id) {
return
}
return {
id,
title: forked.data?.title ?? base.title,
}
}
if (base) {
return {
id: base.id,
title: base.title,
}
}
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
const result = await sdk.session.create({
title: name,
permission: rules,
})
const id = result.data?.id
if (!id) {
return
}
return {
id,
title: result.data?.title ?? name,
}
}
async function share(sdk: OpencodeClient, sessionID: string) {
@@ -408,45 +383,101 @@ export const RunCommand = cmd({
}
}
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
try {
if (part.tool === "bash") return bash(props<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)
})
},

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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,
}
}

View File

@@ -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>
)
}

View File

@@ -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"
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,256 @@
// Pure state machine for the permission UI.
//
// Lives outside the JSX component so it can be tested independently. The
// machine has three stages:
//
// permission → initial view with Allow once / Always / Reject options
// always → confirmation step (Confirm / Cancel)
// reject → text input for rejection message
//
// permissionRun() is the main transition: given the current state and the
// selected option, it returns a new state and optionally a PermissionReply
// to send to the SDK. The component calls this on enter/click.
//
// permissionInfo() extracts display info (icon, title, lines, diff) from
// the request, delegating to tool.ts for tool-specific formatting.
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import type { PermissionReply } from "./types"
import { toolPath, toolPermissionInfo } from "./tool"
type Dict = Record<string, unknown>
export type PermissionStage = "permission" | "always" | "reject"
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
export type PermissionBodyState = {
requestID: string
stage: PermissionStage
selected: PermissionOption
message: string
submitting: boolean
}
export type PermissionInfo = {
icon: string
title: string
lines: string[]
diff?: string
file?: string
}
export type PermissionStep = {
state: PermissionBodyState
reply?: PermissionReply
}
function dict(v: unknown): Dict {
if (!v || typeof v !== "object" || Array.isArray(v)) {
return {}
}
return v 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",
}
}

View File

@@ -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,
}
}

View File

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

View File

@@ -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"
}
}

View File

@@ -0,0 +1,248 @@
// 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; sessionTitle?: string }): Promise<void>
}
// Gracefully tears down the renderer. Order matters: switch external output
// back to passthrough before leaving split-footer mode, so pending stdout
// doesn't get captured into the now-dead scrollback pipeline.
function shutdown(renderer: CliRenderer): void {
if (renderer.isDestroyed) {
return
}
if (renderer.externalOutputMode === "capture-stdout") {
renderer.externalOutputMode = "passthrough"
}
if (renderer.screenMode === "split-footer") {
renderer.screenMode = "main-screen"
}
if (!renderer.isDestroyed) {
renderer.destroy()
}
}
function 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 splashSession(title: string | undefined, history: string[]): boolean {
if (title && !DEFAULT_TITLE.test(title)) {
return true
}
return !!history.find((item) => item.trim().length > 0)
}
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 showSession = splashSession(input.sessionTitle, input.history)
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,
showSession,
}),
)
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; sessionTitle?: string }) => {
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({
...splashMeta({
title: splashTitle(next.sessionTitle ?? input.sessionTitle, input.history),
session_id: input.sessionID,
}),
theme: theme.entry,
background: theme.background,
}),
)
await renderer.idle().catch(() => {})
}
} finally {
footer.close()
footer.destroy()
shutdown(renderer)
}
}
return {
footer,
close,
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,302 @@
// 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 {
const title = shown
? await ctx.sdk.session
.get({
sessionID: ctx.sessionID,
})
.then((x) => x.data?.title)
.catch(() => undefined)
: undefined
await shell.close({
showExit: shown,
sessionTitle: title,
})
}
}
// 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,
}),
})
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -0,0 +1,641 @@
// 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 === "tool" && commit.phase === "start") {
return {
fg: theme.tool.start ?? theme.tool.body,
}
}
if (commit.kind === "assistant") {
return { fg: theme.assistant.body }
}
if (commit.kind === "reasoning") {
return {
fg: theme.reasoning.body,
attrs: TextAttributes.DIM,
}
}
if (commit.kind === "error") {
return {
fg: theme.error.body,
attrs: TextAttributes.BOLD,
}
}
if (commit.kind === "tool") {
return { fg: theme.tool.body }
}
return { fg: theme.system.body }
}
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()
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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: 0,
top: y,
fg: left,
shadow: leftShadow,
})
draw(lines, rightText, {
left: 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, 0, y, label, input.theme.system.body, undefined, TextAttributes.DIM)
push(lines, label.length, y, meta.title, input.theme.assistant.body, undefined, TextAttributes.BOLD)
y += 1
}
if (kind === "entry") {
push(lines, 0, 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, 0, y, next, input.theme.system.body, undefined, TextAttributes.DIM)
push(
lines,
next.length,
y,
`opencode -s ${meta.session_id}`,
input.theme.assistant.body,
undefined,
TextAttributes.BOLD,
)
y += 1
}
const height = Math.max(1, y)
const root = new BoxRenderable(ctx.renderContext, {
id: `run-direct-splash-${kind}-${id++}`,
position: "absolute",
left: 0,
top: 0,
width,
height,
})
for (const line of lines) {
write(root, ctx, line)
}
return {
root,
width,
height,
rowColumns: width,
startOnNewLine: true,
trailingNewline: false,
}
}
export function splashMeta(input: SplashInput): SplashMeta {
return {
title: title(input.title),
session_id: input.session_id,
}
}
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
return (ctx) => build(input, "entry", ctx)
}
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
return (ctx) => build(input, "exit", ctx)
}

View File

@@ -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,
}
}

View File

@@ -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,
})
}

View File

@@ -0,0 +1,232 @@
// 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
start?: 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
}
export const transparent = RGBA.fromValues(0, 0, 0, 0)
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, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
if (color.a === 0) {
return alpha(color, fallback)
}
const target = Math.min(limit, color.a * scale)
const mix = Math.min(1, target / color.a)
return RGBA.fromValues(
base.r + (color.r - base.r) * mix,
base.g + (color.g - base.g) * mix,
base.b + (color.b - base.b) * mix,
color.a,
)
}
function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
const bg = theme.background
const pane = theme.backgroundElement
const surface = fade(pane, bg, 0.18, 0.76, 0.9)
const line = fade(pane, bg, 0.24, 0.9, 0.98)
return {
background: theme.background,
footer: {
highlight: theme.primary,
warning: theme.warning,
success: theme.success,
error: theme.error,
muted: theme.textMuted,
text: theme.text,
surface,
pane,
line,
},
entry: {
system: {
body: theme.textMuted,
},
user: {
body: theme.primary,
},
assistant: {
body: theme.text,
},
reasoning: {
body: theme.textMuted,
},
tool: {
body: theme.text,
start: theme.textMuted,
},
error: {
body: theme.error,
},
},
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, start?: ColorInput): Tone {
return {
body,
start,
}
}
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.text, seed.muted),
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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
// Dev-only JSONL event trace for direct interactive mode.
//
// Enable with OPENCODE_DIRECT_TRACE=1. Writes one JSON line per event to
// ~/.local/share/opencode/log/direct/<timestamp>-<pid>.jsonl. Also writes
// a latest.json pointer so you can quickly find the most recent trace.
//
// The trace captures the full closed loop: outbound prompts, inbound SDK
// events, reducer output, footer commits, and turn lifecycle markers.
// Useful for debugging stream ordering, permission behavior, and
// footer/transcript mismatches.
//
// Lazy-initialized: the first call to trace() decides whether tracing is
// active based on the env var, and subsequent calls return the cached result.
import fs from "fs"
import path from "path"
import { Global } from "../../../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
}

View File

@@ -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
}

View File

@@ -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(() => {})
}

View File

@@ -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()

View File

@@ -1,6 +1,5 @@
import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/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: {

View File

@@ -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"],

View File

@@ -1,5 +1,4 @@
import { cmd } from "@/cli/cmd/cmd"
import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
@@ -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")

View File

@@ -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 = {

View File

@@ -33,8 +33,8 @@
}
},
"devDependencies": {
"@opentui/core": "0.1.96",
"@opentui/solid": "0.1.96",
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@22d015f",
"@opentui/solid": "https://pkg.pr.new/@opentui/solid@22d015f",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",