Просмотр исходного кода

Apply PR #23557: cli: add interactive split-footer mode to run

opencode-agent[bot] 5 часов назад
Родитель
Сommit
53a3bfb31e
58 измененных файлов с 19494 добавлено и 385 удалено
  1. 23 27
      bun.lock
  2. 2 2
      package.json
  3. 2 2
      packages/opencode/package.json
  4. 407 329
      packages/opencode/src/cli/cmd/run.ts
  5. 1295 0
      packages/opencode/src/cli/cmd/run/demo.ts
  6. 183 0
      packages/opencode/src/cli/cmd/run/entry.body.ts
  7. 487 0
      packages/opencode/src/cli/cmd/run/footer.permission.tsx
  8. 977 0
      packages/opencode/src/cli/cmd/run/footer.prompt.tsx
  9. 591 0
      packages/opencode/src/cli/cmd/run/footer.question.tsx
  10. 192 0
      packages/opencode/src/cli/cmd/run/footer.subagent.tsx
  11. 705 0
      packages/opencode/src/cli/cmd/run/footer.ts
  12. 516 0
      packages/opencode/src/cli/cmd/run/footer.view.tsx
  13. 119 0
      packages/opencode/src/cli/cmd/run/otel.ts
  14. 256 0
      packages/opencode/src/cli/cmd/run/permission.shared.ts
  15. 271 0
      packages/opencode/src/cli/cmd/run/prompt.shared.ts
  16. 340 0
      packages/opencode/src/cli/cmd/run/question.shared.ts
  17. 202 0
      packages/opencode/src/cli/cmd/run/runtime.boot.ts
  18. 292 0
      packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
  19. 235 0
      packages/opencode/src/cli/cmd/run/runtime.queue.ts
  20. 17 0
      packages/opencode/src/cli/cmd/run/runtime.shared.ts
  21. 586 0
      packages/opencode/src/cli/cmd/run/runtime.ts
  22. 92 0
      packages/opencode/src/cli/cmd/run/scrollback.shared.ts
  23. 370 0
      packages/opencode/src/cli/cmd/run/scrollback.surface.ts
  24. 322 0
      packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
  25. 942 0
      packages/opencode/src/cli/cmd/run/session-data.ts
  26. 196 0
      packages/opencode/src/cli/cmd/run/session.shared.ts
  27. 291 0
      packages/opencode/src/cli/cmd/run/splash.ts
  28. 876 0
      packages/opencode/src/cli/cmd/run/stream.transport.ts
  29. 175 0
      packages/opencode/src/cli/cmd/run/stream.ts
  30. 746 0
      packages/opencode/src/cli/cmd/run/subagent-data.ts
  31. 275 0
      packages/opencode/src/cli/cmd/run/theme.ts
  32. 1472 0
      packages/opencode/src/cli/cmd/run/tool.ts
  33. 94 0
      packages/opencode/src/cli/cmd/run/trace.ts
  34. 289 0
      packages/opencode/src/cli/cmd/run/types.ts
  35. 200 0
      packages/opencode/src/cli/cmd/run/variant.shared.ts
  36. 1 0
      packages/opencode/src/cli/cmd/tui/app.tsx
  37. 7 3
      packages/opencode/src/cli/cmd/tui/attach.ts
  38. 2 2
      packages/opencode/src/cli/cmd/tui/component/spinner.tsx
  39. 60 8
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  40. 6 5
      packages/opencode/src/cli/cmd/tui/thread.ts
  41. 12 3
      packages/opencode/src/tool/lsp.ts
  42. 392 0
      packages/opencode/test/cli/run/entry.body.test.ts
  43. 224 0
      packages/opencode/test/cli/run/footer.test.ts
  44. 96 0
      packages/opencode/test/cli/run/footer.view.test.tsx
  45. 144 0
      packages/opencode/test/cli/run/permission.shared.test.ts
  46. 115 0
      packages/opencode/test/cli/run/prompt.shared.test.ts
  47. 115 0
      packages/opencode/test/cli/run/question.shared.test.ts
  48. 155 0
      packages/opencode/test/cli/run/runtime.boot.test.ts
  49. 248 0
      packages/opencode/test/cli/run/runtime.queue.test.ts
  50. 1281 0
      packages/opencode/test/cli/run/scrollback.surface.test.ts
  51. 661 0
      packages/opencode/test/cli/run/session-data.test.ts
  52. 187 0
      packages/opencode/test/cli/run/session.shared.test.ts
  53. 164 0
      packages/opencode/test/cli/run/stream.test.ts
  54. 941 0
      packages/opencode/test/cli/run/stream.transport.test.ts
  55. 394 0
      packages/opencode/test/cli/run/subagent-data.test.ts
  56. 81 0
      packages/opencode/test/cli/run/theme.test.ts
  57. 166 0
      packages/opencode/test/cli/run/variant.shared.test.ts
  58. 4 4
      packages/plugin/package.json

+ 23 - 27
bun.lock

@@ -443,8 +443,8 @@
         "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
         "@opentelemetry/sdk-trace-base": "2.6.1",
         "@opentelemetry/sdk-trace-node": "2.6.1",
-        "@opentui/core": "0.1.101",
-        "@opentui/solid": "0.1.101",
+        "@opentui/core": "0.1.102",
+        "@opentui/solid": "0.1.102",
         "@parcel/watcher": "2.5.1",
         "@pierre/diffs": "catalog:",
         "@solid-primitives/event-bus": "1.1.2",
@@ -544,16 +544,16 @@
         "zod": "catalog:",
       },
       "devDependencies": {
-        "@opentui/core": "0.1.101",
-        "@opentui/solid": "0.1.101",
+        "@opentui/core": "0.1.102",
+        "@opentui/solid": "0.1.102",
         "@tsconfig/node22": "catalog:",
         "@types/node": "catalog:",
         "@typescript/native-preview": "catalog:",
         "typescript": "catalog:",
       },
       "peerDependencies": {
-        "@opentui/core": ">=0.1.101",
-        "@opentui/solid": ">=0.1.101",
+        "@opentui/core": ">=0.1.102",
+        "@opentui/solid": ">=0.1.102",
       },
       "optionalPeers": [
         "@opentui/core",
@@ -755,8 +755,8 @@
     "@npmcli/arborist": "9.4.0",
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
-    "@opentui/core": "0.1.99",
-    "@opentui/solid": "0.1.99",
+    "@opentui/core": "0.1.102",
+    "@opentui/solid": "0.1.102",
     "@pierre/diffs": "1.1.0-beta.18",
     "@playwright/test": "1.59.1",
     "@sentry/solid": "10.36.0",
@@ -1916,21 +1916,21 @@
 
     "@opentelemetry/semantic-conventions": ["@opentelemetry/[email protected]", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
 
-    "@opentui/core": ["@opentui/[email protected]1", "", { "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.101", "@opentui/core-darwin-x64": "0.1.101", "@opentui/core-linux-arm64": "0.1.101", "@opentui/core-linux-x64": "0.1.101", "@opentui/core-win32-arm64": "0.1.101", "@opentui/core-win32-x64": "0.1.101", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8jUhNKnwCDO3Y2iiEmagoQLjgX5l1WbddQiwky8B5JU4FW0/WRHairBmU1kRAQBmhdeg57dVinSG4iu2PAtKEA=="],
+    "@opentui/core": ["@opentui/[email protected]2", "", { "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.102", "@opentui/core-darwin-x64": "0.1.102", "@opentui/core-linux-arm64": "0.1.102", "@opentui/core-linux-x64": "0.1.102", "@opentui/core-win32-arm64": "0.1.102", "@opentui/core-win32-x64": "0.1.102", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-gNbU4XnSifo429nZ6T4jcxSmp5pFDrh0AsGJ73Vmlpc4YVWnLJ25RGsZXRsJhvxUK9OtAWJj2wq/kmiFcPLBCw=="],
 
-    "@opentui/core-darwin-arm64": ["@opentui/[email protected]1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HtqZh8TIKCH1Nge5J0etBCpzYfPY4fVcq110uJm2As6D/dTTPv8r4J+KkrqoSphkpj/Y2b4t7KpqNHthXA0EVw=="],
+    "@opentui/core-darwin-arm64": ["@opentui/[email protected]2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vAYNfWhCIGnGGLRu4janrdE4z/WGeRVVJ7rRRmxWliFZfn+i29Pvrc+orKo0He6UO9nB1dLVIhmW8dlINPvhgQ=="],
 
-    "@opentui/core-darwin-x64": ["@opentui/[email protected]1", "", { "os": "darwin", "cpu": "x64" }, "sha512-o5ClQWnGG1inRE2YZAatPw1jPEAJni00amcoIfKBj8e1WS+fQA+iQTq1xFunNcyNPObLDCVuW1X+NrbK9xmPvQ=="],
+    "@opentui/core-darwin-x64": ["@opentui/[email protected]2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BSiZx0QzQ+MUm+XMrktBMyYQKifvy4FFvxqVIhKJv9xlHTTdsFtN/RZ/vuExLn6Xxv9d19psqSbTEJ+HGe76mw=="],
 
-    "@opentui/core-linux-arm64": ["@opentui/[email protected]1", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/weY7DQpaPWGYDPD0CROHowUotqnVlk7Kb6l9+iZCrxm9s7HPRHkcMDVmcWDqHEqa/J879EJcqaUDzDArqC+w=="],
+    "@opentui/core-linux-arm64": ["@opentui/[email protected]2", "", { "os": "linux", "cpu": "arm64" }, "sha512-/49i8RnBQ28o30G6/5Ni4CCtgwOzGn3/NzJbUcfIctXMBiNdoH3v1iSS+XlUREolMNz7l1zcJ6+9OvCRaV7DdA=="],
 
-    "@opentui/core-linux-x64": ["@opentui/[email protected]1", "", { "os": "linux", "cpu": "x64" }, "sha512-+Bfr8jLbbR1WREUMCCvSZ44G1+WU2lPqJx7x1StTa9iFNEdicxCdd0QQsO6cnKn5yW+2Pr/FdrqHbxSQw3ejbA=="],
+    "@opentui/core-linux-x64": ["@opentui/[email protected]2", "", { "os": "linux", "cpu": "x64" }, "sha512-XpkQ+nUaa4HY6poJqfJEkSFil5CDyXzpQsnU5nUAwJXrHLUJOh8Gwx+F1UdoD/JNwBFlJ1Xhzpp3CdmuFXPdyQ=="],
 
-    "@opentui/core-win32-arm64": ["@opentui/[email protected]1", "", { "os": "win32", "cpu": "arm64" }, "sha512-LTMIHJzJrVqS8mgpp+tuyVHuqYlicQTvFi/sTsJ6Xswf1asatsvZYsbQByhBLpFT80j10G7uvDa361S5gjCUDA=="],
+    "@opentui/core-win32-arm64": ["@opentui/[email protected]2", "", { "os": "win32", "cpu": "arm64" }, "sha512-AeoCvtlb/RhDRukFDSmblT5EJFvSwftRp2DGEwZ1leu+zkmp0Ffykn3mGWR2UQUyPTVOGRWKGGyp/Kg2SeniAA=="],
 
-    "@opentui/core-win32-x64": ["@opentui/[email protected]1", "", { "os": "win32", "cpu": "x64" }, "sha512-VaMs5bg6y0tYKptaEK8Hy5wTp4m//wJRKUdW8uvrS9cFgxyovZGuw0+TfK3NgbdeX+8jWm8LEAiak4jle5BABg=="],
+    "@opentui/core-win32-x64": ["@opentui/[email protected]2", "", { "os": "win32", "cpu": "x64" }, "sha512-9Lzd+XkLOJRqhe54bDdorviHAFuO1SPIQMqPieE4bszkiUwekEdgg5x9ymwHh5OUCh0Wiwstl42G4f7BjvMIcA=="],
 
-    "@opentui/solid": ["@opentui/[email protected]1", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.101", "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-STY2FQYtVS2rhUgpslG6mM0EAkgobBDF91+B+SNmvXIkJwP+ydP6UVgcuIo5McIbb9GIbAODx5X2Q48PSR7hgw=="],
+    "@opentui/solid": ["@opentui/[email protected]2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.102", "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-Tgy2QXROyRvJw+8gS282ZOdk9mrAZar9ECQgEBf4V6kCkgAAUAk5OymbqK3GQFIbuMW+Bouv2Rc8YuPEzJC4tg=="],
 
     "@oslojs/asn1": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 
@@ -5966,7 +5966,7 @@
 
     "xml2js": ["[email protected]", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
 
-    "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
+    "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
 
     "xmlhttprequest-ssl": ["[email protected]", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
 
@@ -6366,6 +6366,8 @@
 
     "@expo/package-manager/npm-package-arg": ["[email protected]", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="],
 
+    "@expo/plist/xmlbuilder": ["[email protected]", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
+
     "@expo/prebuild-config/xml2js": ["[email protected]", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
 
     "@expo/xcpretty/chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6732,6 +6734,8 @@
 
     "@testing-library/dom/pretty-format": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
 
+    "@types/plist/xmlbuilder": ["[email protected]", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
+
     "@vitest/expect/@vitest/utils": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
 
     "@vitest/expect/tinyrainbow": ["[email protected]", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
@@ -7114,6 +7118,8 @@
 
     "playwright/fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
 
+    "plist/xmlbuilder": ["[email protected]", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
+
     "postcss-css-variables/balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
 
     "postcss-css-variables/escape-string-regexp": ["[email protected]", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -7292,8 +7298,6 @@
 
     "xml2js/sax": ["[email protected]", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
 
-    "xml2js/xmlbuilder": ["[email protected]", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
-
     "yaml-language-server/request-light": ["[email protected]", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
 
     "yaml-language-server/yaml": ["[email protected]", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],
@@ -7532,8 +7536,6 @@
 
     "@azure/core-http/xml2js/sax": ["[email protected]", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
 
-    "@azure/core-http/xml2js/xmlbuilder": ["[email protected]", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
-
     "@azure/core-xml/fast-xml-parser/strnum": ["[email protected]", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
 
     "@azure/identity/open/wsl-utils": ["[email protected]", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
@@ -7592,8 +7594,6 @@
 
     "@expo/config-plugins/xml2js/sax": ["[email protected]", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
 
-    "@expo/config-plugins/xml2js/xmlbuilder": ["[email protected]", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
-
     "@expo/metro-config/hermes-parser/hermes-estree": ["[email protected]", "", {}, "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg=="],
 
     "@expo/metro/metro-source-map/ob1": ["[email protected]", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-vNKPYC8L5ycVANANpF/S+WZHpfnRWKx/F3AYP4QMn6ZJTh+l2HOrId0clNkEmua58NB9vmI9Qh7YOoV/4folYg=="],
@@ -7608,8 +7608,6 @@
 
     "@expo/prebuild-config/xml2js/sax": ["[email protected]", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
 
-    "@expo/prebuild-config/xml2js/xmlbuilder": ["[email protected]", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
-
     "@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
 
     "@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
@@ -8104,8 +8102,6 @@
 
     "parse-bmfont-xml/xml2js/sax": ["[email protected]", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
 
-    "parse-bmfont-xml/xml2js/xmlbuilder": ["[email protected]", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
-
     "pkg-dir/find-up/locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
 
     "pkg-up/find-up/locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],

+ 2 - 2
package.json

@@ -34,8 +34,8 @@
       "@types/cross-spawn": "6.0.6",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
-      "@opentui/core": "0.1.99",
-      "@opentui/solid": "0.1.99",
+      "@opentui/core": "0.1.102",
+      "@opentui/solid": "0.1.102",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
       "@types/luxon": "3.7.1",

+ 2 - 2
packages/opencode/package.json

@@ -124,8 +124,8 @@
     "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
     "@opentelemetry/sdk-trace-base": "2.6.1",
     "@opentelemetry/sdk-trace-node": "2.6.1",
-    "@opentui/core": "0.1.101",
-    "@opentui/solid": "0.1.101",
+    "@opentui/core": "0.1.102",
+    "@opentui/solid": "0.1.102",
     "@parcel/watcher": "2.5.1",
     "@pierre/diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",

+ 407 - 329
packages/opencode/src/cli/cmd/run.ts

@@ -1,46 +1,48 @@
+// 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"
 import { UI } from "../ui"
 import { cmd } from "./cmd"
-import { Flag } from "../../flag/flag"
+import { Flag } from "@/flag/flag"
 import { bootstrap } from "../bootstrap"
 import { EOL } from "os"
-import { Filesystem } from "../../util"
+import { Filesystem } from "@/util"
 import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
-import { Server } from "../../server/server"
-import { Provider } from "../../provider"
-import { Agent } from "../../agent/agent"
-import { Permission } from "../../permission"
-import { Tool } from "../../tool"
-import { GlobTool } from "../../tool/glob"
-import { GrepTool } from "../../tool/grep"
-import { ReadTool } from "../../tool/read"
-import { WebFetchTool } from "../../tool/webfetch"
-import { EditTool } from "../../tool/edit"
-import { WriteTool } from "../../tool/write"
-import { 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"
+import { Agent } from "@/agent/agent"
+import { Permission } from "@/permission"
 import { AppRuntime } from "@/effect/app-runtime"
+import type { RunDemo } from "./run/types"
 
-type ToolProps<T> = {
-  input: Tool.InferParameters<T>
-  metadata: Tool.InferMetadata<T>
-  part: ToolPart
-}
+const runtimeTask = import("./run/runtime")
+type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
 
-function props<T>(part: ToolPart): ToolProps<T> {
-  const state = part.state
+function pick(value: string | undefined): ModelInput | undefined {
+  if (!value) return undefined
+  const [providerID, ...rest] = value.split("/")
   return {
-    input: state.input as Tool.InferParameters<T>,
-    metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
-    part,
-  }
+    providerID,
+    modelID: rest.join("/"),
+  } as ModelInput
+}
+
+type FilePart = {
+  type: "file"
+  url: string
+  filename: string
+  mime: string
 }
 
 type Inline = {
@@ -49,6 +51,12 @@ type Inline = {
   description?: string
 }
 
+type SessionInfo = {
+  id: string
+  title?: string
+  directory?: string
+}
+
 function inline(info: Inline) {
   const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
   UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
@@ -62,152 +70,22 @@ function block(info: Inline, output?: string) {
   UI.empty()
 }
 
-function fallback(part: ToolPart) {
-  const state = part.state
-  const input = "input" in state ? state.input : undefined
-  const title =
-    ("title" in state && state.title ? state.title : undefined) ||
-    (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
-  inline({
-    icon: "⚙",
-    title: `${part.tool} ${title}`,
-  })
-}
-
-function glob(info: ToolProps<typeof GlobTool>) {
-  const root = info.input.path ?? ""
-  const title = `Glob "${info.input.pattern}"`
-  const suffix = root ? `in ${normalizePath(root)}` : ""
-  const num = info.metadata.count
-  const description =
-    num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
-  inline({
-    icon: "✱",
-    title,
-    ...(description && { description }),
-  })
-}
-
-function grep(info: ToolProps<typeof GrepTool>) {
-  const root = info.input.path ?? ""
-  const title = `Grep "${info.input.pattern}"`
-  const suffix = root ? `in ${normalizePath(root)}` : ""
-  const num = info.metadata.matches
-  const description =
-    num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
-  inline({
-    icon: "✱",
-    title,
-    ...(description && { description }),
-  })
-}
-
-function read(info: ToolProps<typeof ReadTool>) {
-  const file = normalizePath(info.input.filePath)
-  const pairs = Object.entries(info.input).filter(([key, value]) => {
-    if (key === "filePath") return false
-    return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
-  })
-  const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined
-  inline({
-    icon: "→",
-    title: `Read ${file}`,
-    ...(description && { description }),
-  })
-}
-
-function write(info: ToolProps<typeof WriteTool>) {
-  block(
-    {
-      icon: "←",
-      title: `Write ${normalizePath(info.input.filePath)}`,
-    },
-    info.part.state.status === "completed" ? info.part.state.output : undefined,
-  )
-}
-
-function webfetch(info: ToolProps<typeof WebFetchTool>) {
-  inline({
-    icon: "%",
-    title: `WebFetch ${info.input.url}`,
-  })
-}
-
-function edit(info: ToolProps<typeof EditTool>) {
-  const title = normalizePath(info.input.filePath)
-  const diff = info.metadata.diff
-  block(
-    {
-      icon: "←",
-      title: `Edit ${title}`,
-    },
-    diff,
-  )
-}
-
-function codesearch(info: ToolProps<typeof CodeSearchTool>) {
-  inline({
-    icon: "◇",
-    title: `Exa Code Search "${info.input.query}"`,
-  })
-}
-
-function websearch(info: ToolProps<typeof WebSearchTool>) {
-  inline({
-    icon: "◈",
-    title: `Exa Web Search "${info.input.query}"`,
-  })
-}
-
-function task(info: ToolProps<typeof TaskTool>) {
-  const input = info.part.state.input
-  const status = info.part.state.status
-  const subagent =
-    typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown"
-  const agent = Locale.titlecase(subagent)
-  const desc =
-    typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined
-  const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓"
-  const name = desc ?? `${agent} Task`
-  inline({
-    icon,
-    title: name,
-    description: desc ? `${agent} Agent` : undefined,
-  })
-}
-
-function skill(info: ToolProps<typeof SkillTool>) {
-  inline({
-    icon: "→",
-    title: `Skill "${info.input.name}"`,
-  })
-}
-
-function bash(info: ToolProps<typeof BashTool>) {
-  const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
-  block(
-    {
-      icon: "$",
-      title: `${info.input.command}`,
-    },
-    output,
-  )
-}
-
-function todo(info: ToolProps<typeof TodoWriteTool>) {
-  block(
-    {
-      icon: "#",
-      title: "Todos",
-    },
-    info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"),
-  )
-}
+async function tool(part: ToolPart) {
+  try {
+    const { toolInlineInfo } = await import("./run/tool")
+    const next = toolInlineInfo(part)
+    if (next.mode === "block") {
+      block(next, next.body)
+      return
+    }
 
-function normalizePath(input?: string) {
-  if (!input) return ""
-  if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
-  return input
+    inline(next)
+  } catch {
+    inline({
+      icon: "⚙",
+      title: part.tool,
+    })
+  }
 }
 
 export const RunCommand = cmd({
@@ -292,6 +170,11 @@ export const RunCommand = cmd({
       .option("thinking", {
         type: "boolean",
         describe: "show thinking blocks",
+      })
+      .option("interactive", {
+        alias: ["i"],
+        type: "boolean",
+        describe: "run in direct interactive split-footer mode",
         default: false,
       })
       .option("dangerously-skip-permissions", {
@@ -299,30 +182,87 @@ export const RunCommand = cmd({
         describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
         default: false,
       })
+      .option("demo", {
+        type: "string",
+        choices: ["on", "permission", "question", "mix", "text"],
+        describe: "enable direct interactive demo slash commands",
+      })
+      .option("demo-text", {
+        type: "string",
+        describe: "text used with --demo text",
+      })
   },
   handler: async (args) => {
+    const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
+    const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
+    const die = (message: string): never => {
+      UI.error(message)
+      process.exit(1)
+    }
+
     let message = [...args.message, ...(args["--"] || [])]
       .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
       .join(" ")
 
+    if (args.interactive && args.command) {
+      die("--interactive cannot be used with --command")
+    }
+
+    if (args.demo && !args.interactive) {
+      die("--demo requires --interactive")
+    }
+
+    if (args.demoText && args.demo !== "text") {
+      die("--demo-text requires --demo text")
+    }
+
+    if (args.interactive && args.format === "json") {
+      die("--interactive cannot be used with --format json")
+    }
+
+    if (args.interactive && !process.stdin.isTTY) {
+      die("--interactive requires a TTY")
+    }
+
+    if (args.interactive && !process.stdout.isTTY) {
+      die("--interactive requires a TTY stdout")
+    }
+
+    const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
     const directory = (() => {
-      if (!args.dir) return undefined
+      if (!args.dir) return args.attach ? undefined : root
       if (args.attach) return args.dir
+
       try {
-        process.chdir(args.dir)
+        process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))
         return process.cwd()
       } catch {
         UI.error("Failed to change directory to " + args.dir)
         process.exit(1)
       }
     })()
+    const attachHeaders = (() => {
+      if (!args.attach) return undefined
+      const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
+      if (!password) return undefined
+      const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
+      const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
+      return { Authorization: auth }
+    })()
+    const attachSDK = (dir?: string) => {
+      return createOpencodeClient({
+        baseUrl: args.attach!,
+        directory: dir,
+        headers: attachHeaders,
+      })
+    }
 
-    const files: { type: "file"; url: string; filename: string; mime: string }[] = []
+    const files: FilePart[] = []
     if (args.file) {
       const list = Array.isArray(args.file) ? args.file : [args.file]
 
       for (const filePath of list) {
-        const resolvedPath = path.resolve(process.cwd(), filePath)
+        const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath)
         if (!(await Filesystem.exists(resolvedPath))) {
           UI.error(`File not found: ${filePath}`)
           process.exit(1)
@@ -341,7 +281,7 @@ export const RunCommand = cmd({
 
     if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
 
-    if (message.trim().length === 0 && !args.command) {
+    if (message.trim().length === 0 && !args.command && !args.interactive) {
       UI.error("You must provide a message or a command")
       process.exit(1)
     }
@@ -351,28 +291,30 @@ 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: "*",
-      },
-      {
-        permission: "edit",
-        action: "allow",
-        pattern: "*",
-      },
-    ]
+    const rules: Permission.Ruleset = args.interactive
+      ? []
+      : [
+          {
+            permission: "question",
+            action: "deny",
+            pattern: "*",
+          },
+          {
+            permission: "plan_enter",
+            action: "deny",
+            pattern: "*",
+          },
+          {
+            permission: "plan_exit",
+            action: "deny",
+            pattern: "*",
+          },
+          {
+            permission: "edit",
+            action: "allow",
+            pattern: "*",
+          },
+        ]
 
     function title() {
       if (args.title === undefined) return
@@ -380,19 +322,83 @@ export const RunCommand = cmd({
       return message.slice(0, 50) + (message.length > 50 ? "..." : "")
     }
 
-    async function session(sdk: OpencodeClient) {
-      const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
+    async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
+      if (args.session) {
+        const current = await sdk.session
+          .get({
+            sessionID: args.session,
+          })
+          .catch(() => undefined)
+
+        if (!current?.data) {
+          UI.error("Session not found")
+          process.exit(1)
+        }
+
+        if (args.fork) {
+          const forked = await sdk.session.fork({
+            sessionID: args.session,
+          })
+          const id = forked.data?.id
+          if (!id) {
+            return
+          }
+
+          return {
+            id,
+            title: forked.data?.title ?? current.data.title,
+            directory: forked.data?.directory ?? current.data.directory,
+          }
+        }
+
+        return {
+          id: current.data.id,
+          title: current.data.title,
+          directory: current.data.directory,
+        }
+      }
+
+      const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
 
-      if (baseID && args.fork) {
-        const forked = await sdk.session.fork({ sessionID: baseID })
-        return forked.data?.id
+      if (base && args.fork) {
+        const forked = await sdk.session.fork({
+          sessionID: base.id,
+        })
+        const id = forked.data?.id
+        if (!id) {
+          return
+        }
+
+        return {
+          id,
+          title: forked.data?.title ?? base.title,
+          directory: forked.data?.directory ?? base.directory,
+        }
       }
 
-      if (baseID) return baseID
+      if (base) {
+        return {
+          id: base.id,
+          title: base.title,
+          directory: base.directory,
+        }
+      }
 
       const name = title()
-      const result = await sdk.session.create({ title: name, permission: rules })
-      return result.data?.id
+      const result = await sdk.session.create({
+        title: name,
+        permission: rules,
+      })
+      const id = result.data?.id
+      if (!id) {
+        return
+      }
+
+      return {
+        id,
+        title: result.data?.title ?? name,
+        directory: result.data?.directory,
+      }
     }
 
     async function share(sdk: OpencodeClient, sessionID: string) {
@@ -410,44 +416,131 @@ export const RunCommand = cmd({
       }
     }
 
+    async function current(sdk: OpencodeClient): Promise<string> {
+      if (!args.attach) {
+        return directory ?? root
+      }
+
+      const next = await sdk.path
+        .get()
+        .then((x) => x.data?.directory)
+        .catch(() => undefined)
+      if (next) {
+        return next
+      }
+
+      UI.error("Failed to resolve remote directory")
+      process.exit(1)
+    }
+
+    async function localAgent() {
+      if (!args.agent) return undefined
+      const name = args.agent
+
+      const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
+      if (!entry) {
+        UI.println(
+          UI.Style.TEXT_WARNING_BOLD + "!",
+          UI.Style.TEXT_NORMAL,
+          `agent "${name}" not found. Falling back to default agent`,
+        )
+        return undefined
+      }
+      if (entry.mode === "subagent") {
+        UI.println(
+          UI.Style.TEXT_WARNING_BOLD + "!",
+          UI.Style.TEXT_NORMAL,
+          `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
+        )
+        return undefined
+      }
+      return name
+    }
+
+    async function attachAgent(sdk: OpencodeClient) {
+      if (!args.agent) return undefined
+      const name = args.agent
+
+      const modes = await sdk.app
+        .agents(undefined, { throwOnError: true })
+        .then((x) => x.data ?? [])
+        .catch(() => undefined)
+
+      if (!modes) {
+        UI.println(
+          UI.Style.TEXT_WARNING_BOLD + "!",
+          UI.Style.TEXT_NORMAL,
+          `failed to list agents from ${args.attach}. Falling back to default agent`,
+        )
+        return undefined
+      }
+
+      const agent = modes.find((a) => a.name === name)
+      if (!agent) {
+        UI.println(
+          UI.Style.TEXT_WARNING_BOLD + "!",
+          UI.Style.TEXT_NORMAL,
+          `agent "${name}" not found. Falling back to default agent`,
+        )
+        return undefined
+      }
+
+      if (agent.mode === "subagent") {
+        UI.println(
+          UI.Style.TEXT_WARNING_BOLD + "!",
+          UI.Style.TEXT_NORMAL,
+          `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
+        )
+        return undefined
+      }
+
+      return name
+    }
+
+    async function pickAgent(sdk: OpencodeClient) {
+      if (!args.agent) return undefined
+      if (args.attach) {
+        return attachAgent(sdk)
+      }
+
+      return localAgent()
+    }
+
     async function execute(sdk: OpencodeClient) {
-      function tool(part: ToolPart) {
-        try {
-          if (part.tool === "bash") return bash(props<typeof BashTool>(part))
-          if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
-          if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
-          if (part.tool === "read") return read(props<typeof ReadTool>(part))
-          if (part.tool === "write") return write(props<typeof WriteTool>(part))
-          if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
-          if (part.tool === "edit") return edit(props<typeof EditTool>(part))
-          if (part.tool === "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)
-        }
+      const sess = await session(sdk)
+      if (!sess?.id) {
+        UI.error("Session not found")
+        process.exit(1)
       }
+      const sessionID = sess.id
 
       function emit(type: string, data: Record<string, unknown>) {
         if (args.format === "json") {
-          process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
+          process.stdout.write(
+            JSON.stringify({
+              type,
+              timestamp: Date.now(),
+              sessionID,
+              ...data,
+            }) + EOL,
+          )
           return true
         }
         return false
       }
 
-      const events = await sdk.event.subscribe()
-      let error: string | undefined
-
-      async function loop() {
+      // Consume one subscribed event stream for the active session and mirror it
+      // to stdout/UI. `client` is passed explicitly because attach mode may
+      // rebind the SDK to the session's directory after the subscription is
+      // created, and replies issued from inside the loop must use that client.
+      async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
         const toggles = new Map<string, boolean>()
+        let error: string | undefined
 
         for await (const event of events.stream) {
           if (
             event.type === "message.updated" &&
+            event.properties.sessionID === sessionID &&
             event.properties.info.role === "assistant" &&
             args.format !== "json" &&
             toggles.get("start") !== true
@@ -465,7 +558,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({
@@ -482,7 +575,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)
             }
 
@@ -547,7 +640,7 @@ export const RunCommand = cmd({
             if (permission.sessionID !== sessionID) continue
 
             if (args["dangerously-skip-permissions"]) {
-              await sdk.permission.reply({
+              await client.permission.reply({
                 requestID: permission.id,
                 reply: "once",
               })
@@ -557,7 +650,7 @@ export const RunCommand = cmd({
                 UI.Style.TEXT_NORMAL +
                   `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
               )
-              await sdk.permission.reply({
+              await client.permission.reply({
                 requestID: permission.id,
                 reply: "reject",
               })
@@ -565,121 +658,106 @@ export const RunCommand = cmd({
           }
         }
       }
+      const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
+      const client = args.attach ? attachSDK(cwd) : sdk
 
       // Validate agent if specified
-      const agent = await (async () => {
-        if (!args.agent) return undefined
-        const name = args.agent
-
-        // 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 = await pickAgent(client)
 
-          const agent = modes.find((a) => a.name === name)
-          if (!agent) {
-            UI.println(
-              UI.Style.TEXT_WARNING_BOLD + "!",
-              UI.Style.TEXT_NORMAL,
-              `agent "${name}" not found. Falling back to default agent`,
-            )
-            return undefined
-          }
+      await share(client, sessionID)
 
-          if (agent.mode === "subagent") {
-            UI.println(
-              UI.Style.TEXT_WARNING_BOLD + "!",
-              UI.Style.TEXT_NORMAL,
-              `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
-            )
-            return undefined
-          }
-
-          return name
-        }
+      if (!args.interactive) {
+        const events = await client.event.subscribe()
+        loop(client, events).catch((e) => {
+          console.error(e)
+          process.exit(1)
+        })
 
-        const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
-        if (!entry) {
-          UI.println(
-            UI.Style.TEXT_WARNING_BOLD + "!",
-            UI.Style.TEXT_NORMAL,
-            `agent "${name}" not found. Falling back to default agent`,
-          )
-          return undefined
+        if (args.command) {
+          await client.session.command({
+            sessionID,
+            agent,
+            model: args.model,
+            command: args.command,
+            arguments: message,
+            variant: args.variant,
+          })
+          return
         }
-        if (entry.mode === "subagent") {
-          UI.println(
-            UI.Style.TEXT_WARNING_BOLD + "!",
-            UI.Style.TEXT_NORMAL,
-            `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
-          )
-          return undefined
-        }
-        return name
-      })()
 
-      const sessionID = await session(sdk)
-      if (!sessionID) {
-        UI.error("Session not found")
-        process.exit(1)
-      }
-      await share(sdk, sessionID)
-
-      loop().catch((e) => {
-        console.error(e)
-        process.exit(1)
-      })
-
-      if (args.command) {
-        await sdk.session.command({
-          sessionID,
-          agent,
-          model: args.model,
-          command: args.command,
-          arguments: message,
-          variant: args.variant,
-        })
-      } else {
-        const model = args.model ? Provider.parseModel(args.model) : undefined
-        await sdk.session.prompt({
+        const model = pick(args.model)
+        await client.session.prompt({
           sessionID,
           agent,
           model,
           variant: args.variant,
           parts: [...files, { type: "text", text: message }],
         })
+        return
       }
+
+      const model = pick(args.model)
+      const { runInteractiveMode } = await runtimeTask
+      await runInteractiveMode({
+        sdk: client,
+        directory: cwd,
+        sessionID,
+        sessionTitle: sess.title,
+        resume: Boolean(args.session || args.continue) && !args.fork,
+        agent,
+        model,
+        variant: args.variant,
+        files,
+        initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined,
+        thinking,
+        demo: args.demo as RunDemo | undefined,
+        demoText: args.demoText,
+      })
+      return
+    }
+
+    if (args.interactive && !args.attach && !args.session && !args.continue) {
+      const model = pick(args.model)
+      const { runInteractiveLocalMode } = await runtimeTask
+      const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
+        const { Server } = await import("@/server/server")
+        const request = new Request(input, init)
+        return Server.Default().app.fetch(request)
+      }) as typeof globalThis.fetch
+
+      return await runInteractiveLocalMode({
+        directory: directory ?? root,
+        fetch: fetchFn,
+        resolveAgent: localAgent,
+        session,
+        share,
+        agent: args.agent,
+        model,
+        variant: args.variant,
+        files,
+        initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined,
+        thinking,
+        demo: args.demo as RunDemo | undefined,
+        demoText: args.demoText,
+      })
     }
 
     if (args.attach) {
-      const headers = (() => {
-        const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
-        if (!password) return undefined
-        const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
-        const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
-        return { Authorization: auth }
-      })()
-      const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
+      const sdk = attachSDK(directory)
       return await execute(sdk)
     }
 
-    await bootstrap(process.cwd(), async () => {
+    await bootstrap(directory ?? root, async () => {
       const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
+        const { Server } = await import("@/server/server")
         const request = new Request(input, init)
         return Server.Default().app.fetch(request)
       }) as typeof globalThis.fetch
-      const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
+      const sdk = createOpencodeClient({
+        baseUrl: "http://opencode.internal",
+        fetch: fetchFn,
+        directory,
+      })
       await execute(sdk)
     })
   },

+ 1295 - 0
packages/opencode/src/cli/cmd/run/demo.ts

@@ -0,0 +1,1295 @@
+// Demo mode for testing direct interactive mode without a real SDK.
+//
+// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic
+// SDK events that feed through the real reducer and footer pipeline. This
+// lets you test scrollback formatting, permission UI, question UI, and tool
+// snapshots without making actual model calls.
+//
+// Slash commands:
+//   /permission [kind] → triggers a permission request variant
+//   /question [kind]   → triggers a question request variant
+//   /fmt <kind>   → emits a specific tool/text type (text, reasoning, bash,
+//                   write, edit, patch, task, todo, question, error, mix)
+//
+// Demo mode also handles permission and question replies locally, completing
+// or failing the synthetic tool parts as appropriate.
+import path from "path"
+import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
+import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
+import { writeSessionOutput } from "./stream"
+import type {
+  FooterApi,
+  PermissionReply,
+  QuestionReject,
+  QuestionReply,
+  RunDemo,
+  RunPrompt,
+  StreamCommit,
+} from "./types"
+
+const KINDS = [
+  "markdown",
+  "table",
+  "text",
+  "reasoning",
+  "bash",
+  "write",
+  "edit",
+  "patch",
+  "task",
+  "todo",
+  "question",
+  "error",
+  "mix",
+]
+const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const
+const QUESTIONS = ["multi", "single", "checklist", "custom"] as const
+
+type PermissionKind = (typeof PERMISSIONS)[number]
+type QuestionKind = (typeof QUESTIONS)[number]
+
+function permissionKind(value: string | undefined): PermissionKind | undefined {
+  const next = (value || "edit").toLowerCase()
+  return PERMISSIONS.find((item) => item === next)
+}
+
+function questionKind(value: string | undefined): QuestionKind | undefined {
+  const next = (value || "multi").toLowerCase()
+  return QUESTIONS.find((item) => item === next)
+}
+
+const SAMPLE_MARKDOWN = [
+  "# Direct Mode Demo",
+  "",
+  "This is a realistic assistant response for direct-mode formatting checks.",
+  "It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
+  "",
+  "## Summary",
+  "",
+  "- Restored the final markdown flush so the last block is committed on idle.",
+  "- Switched markdown scrollback commits back to top-level block boundaries.",
+  "- Added footer-level regression coverage for split-footer rendering.",
+  "",
+  "## Status",
+  "",
+  "| Area | Before | After | Notes |",
+  "| --- | --- | --- | --- |",
+  "| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
+  "| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
+  "| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
+  "",
+  "> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
+  "",
+  "```ts",
+  "const result = { markdown: true, tables: 2, stable: true }",
+  "```",
+  "",
+  "## Files",
+  "",
+  "| File | Change |",
+  "| --- | --- |",
+  "| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
+  "| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
+  "| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
+  "",
+  "Next step: run `/fmt table` if you want a tighter table-only sample.",
+].join("\n")
+
+const SAMPLE_TABLE = [
+  "# Table Sample",
+  "",
+  "| Kind | Example | Notes |",
+  "| --- | --- | --- |",
+  "| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
+  "| Unicode | `漢字` | Wide characters should remain aligned |",
+  "| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
+  "| Status | done | Final row should still appear after idle |",
+].join("\n")
+
+type Ref = {
+  msg: string
+  part: string
+  call: string
+  tool: string
+  input: Record<string, unknown>
+  start: number
+}
+
+type Ask = {
+  ref: Ref
+}
+
+type Perm = {
+  ref: Ref
+  done: {
+    title: string
+    output: string
+    metadata?: Record<string, unknown>
+  }
+}
+
+type Permit = {
+  ref: Ref
+  permission: string
+  patterns: string[]
+  metadata?: Record<string, unknown>
+  always: string[]
+  done: Perm["done"]
+}
+
+type State = {
+  id: string
+  thinking: boolean
+  data: SessionData
+  footer: FooterApi
+  limits: () => Record<string, number>
+  msg: number
+  part: number
+  call: number
+  perm: number
+  ask: number
+  perms: Map<string, Perm>
+  asks: Map<string, Ask>
+}
+
+type Input = {
+  mode: RunDemo
+  text?: string
+  sessionID: string
+  thinking: boolean
+  limits: () => Record<string, number>
+  footer: FooterApi
+}
+
+function note(footer: FooterApi, text: string): void {
+  footer.append({
+    kind: "system",
+    text,
+    phase: "start",
+    source: "system",
+  })
+}
+
+function clearSubagent(footer: FooterApi): void {
+  footer.event({
+    type: "stream.subagent",
+    state: {
+      tabs: [],
+      details: {},
+      permissions: [],
+      questions: [],
+    },
+  })
+}
+
+function showSubagent(
+  state: State,
+  input: {
+    sessionID: string
+    partID: string
+    callID: string
+    label: string
+    description: string
+    status: "running" | "completed" | "error"
+    title?: string
+    toolCalls?: number
+    commits: StreamCommit[]
+  },
+) {
+  state.footer.event({
+    type: "stream.subagent",
+    state: {
+      tabs: [
+        {
+          sessionID: input.sessionID,
+          partID: input.partID,
+          callID: input.callID,
+          label: input.label,
+          description: input.description,
+          status: input.status,
+          title: input.title,
+          toolCalls: input.toolCalls,
+          lastUpdatedAt: Date.now(),
+        },
+      ],
+      details: {
+        [input.sessionID]: {
+          sessionID: input.sessionID,
+          commits: input.commits,
+        },
+      },
+      permissions: [],
+      questions: [],
+    },
+  })
+}
+
+function wait(ms: number, signal?: AbortSignal): Promise<void> {
+  return new Promise((resolve) => {
+    if (!signal) {
+      setTimeout(resolve, ms)
+      return
+    }
+
+    if (signal.aborted) {
+      resolve()
+      return
+    }
+
+    const done = () => {
+      clearTimeout(timer)
+      signal.removeEventListener("abort", done)
+      resolve()
+    }
+
+    const timer = setTimeout(() => {
+      signal.removeEventListener("abort", done)
+      resolve()
+    }, ms)
+
+    signal.addEventListener("abort", done, { once: true })
+  })
+}
+
+function split(text: string): string[] {
+  if (text.length <= 48) {
+    return [text]
+  }
+
+  const size = Math.ceil(text.length / 3)
+  return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)]
+}
+
+function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string {
+  state[key] += 1
+  return `demo_${prefix}_${state[key]}`
+}
+
+function feed(state: State, event: Event): void {
+  const out = reduceSessionData({
+    data: state.data,
+    event,
+    sessionID: state.id,
+    thinking: state.thinking,
+    limits: state.limits(),
+  })
+  state.data = out.data
+  writeSessionOutput(
+    {
+      footer: state.footer,
+    },
+    out,
+  )
+}
+
+function open(state: State): string {
+  const id = take(state, "msg", "msg")
+  feed(state, {
+    type: "message.updated",
+    properties: {
+      sessionID: state.id,
+      info: {
+        id,
+        sessionID: state.id,
+        role: "assistant",
+        time: {
+          created: Date.now(),
+        },
+        parentID: `user_${id}`,
+        modelID: "demo",
+        providerID: "demo",
+        mode: "demo",
+        agent: "demo",
+        path: {
+          cwd: process.cwd(),
+          root: process.cwd(),
+        },
+        cost: 0.001,
+        tokens: {
+          input: 120,
+          output: 320,
+          reasoning: 80,
+          cache: {
+            read: 0,
+            write: 0,
+          },
+        },
+      },
+    },
+  } as Event)
+  return id
+}
+
+async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
+  const msg = open(state)
+  const part = take(state, "part", "part")
+  const start = Date.now()
+
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: part,
+        sessionID: state.id,
+        messageID: msg,
+        type: "text",
+        text: "",
+        time: {
+          start,
+        },
+      },
+    },
+  } as Event)
+
+  let next = ""
+  for (const item of split(body)) {
+    if (signal?.aborted) {
+      return
+    }
+
+    next += item
+    feed(state, {
+      type: "message.part.delta",
+      properties: {
+        sessionID: state.id,
+        messageID: msg,
+        partID: part,
+        field: "text",
+        delta: item,
+      },
+    } as Event)
+    await wait(45, signal)
+  }
+
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: part,
+        sessionID: state.id,
+        messageID: msg,
+        type: "text",
+        text: next,
+        time: {
+          start,
+          end: Date.now(),
+        },
+      },
+    },
+  } as Event)
+}
+
+async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
+  const msg = open(state)
+  const part = take(state, "part", "part")
+  const start = Date.now()
+
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: part,
+        sessionID: state.id,
+        messageID: msg,
+        type: "reasoning",
+        text: "",
+        time: {
+          start,
+        },
+      },
+    },
+  } as Event)
+
+  let next = ""
+  for (const item of split(body)) {
+    if (signal?.aborted) {
+      return
+    }
+
+    next += item
+    feed(state, {
+      type: "message.part.delta",
+      properties: {
+        sessionID: state.id,
+        messageID: msg,
+        partID: part,
+        field: "text",
+        delta: item,
+      },
+    } as Event)
+    await wait(45, signal)
+  }
+
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: part,
+        sessionID: state.id,
+        messageID: msg,
+        type: "reasoning",
+        text: next,
+        time: {
+          start,
+          end: Date.now(),
+        },
+      },
+    },
+  } as Event)
+}
+
+function make(state: State, tool: string, input: Record<string, unknown>): Ref {
+  return {
+    msg: open(state),
+    part: take(state, "part", "part"),
+    call: take(state, "call", "call"),
+    tool,
+    input,
+    start: Date.now(),
+  }
+}
+
+function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: ref.part,
+        sessionID: state.id,
+        messageID: ref.msg,
+        type: "tool",
+        callID: ref.call,
+        tool: ref.tool,
+        state: {
+          status: "running",
+          input: ref.input,
+          metadata,
+          time: {
+            start: ref.start,
+          },
+        },
+      },
+    },
+  } as Event)
+}
+
+function askPermission(state: State, item: Permit): void {
+  startTool(state, item.ref)
+
+  const id = take(state, "perm", "perm")
+  state.perms.set(id, {
+    ref: item.ref,
+    done: item.done,
+  })
+
+  feed(state, {
+    type: "permission.asked",
+    properties: {
+      id,
+      sessionID: state.id,
+      permission: item.permission,
+      patterns: item.patterns,
+      metadata: item.metadata ?? {},
+      always: item.always,
+      tool: {
+        messageID: item.ref.msg,
+        callID: item.ref.call,
+      },
+    },
+  } as Event)
+}
+
+function doneTool(
+  state: State,
+  ref: Ref,
+  output: {
+    title: string
+    output: string
+    metadata?: Record<string, unknown>
+  },
+): void {
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: ref.part,
+        sessionID: state.id,
+        messageID: ref.msg,
+        type: "tool",
+        callID: ref.call,
+        tool: ref.tool,
+        state: {
+          status: "completed",
+          input: ref.input,
+          output: output.output,
+          title: output.title,
+          metadata: output.metadata ?? {},
+          time: {
+            start: ref.start,
+            end: Date.now(),
+          },
+        },
+      },
+    },
+  } as Event)
+}
+
+function failTool(state: State, ref: Ref, error: string): void {
+  feed(state, {
+    type: "message.part.updated",
+    properties: {
+      sessionID: state.id,
+      time: Date.now(),
+      part: {
+        id: ref.part,
+        sessionID: state.id,
+        messageID: ref.msg,
+        type: "tool",
+        callID: ref.call,
+        tool: ref.tool,
+        state: {
+          status: "error",
+          input: ref.input,
+          error,
+          metadata: {},
+          time: {
+            start: ref.start,
+            end: Date.now(),
+          },
+        },
+      },
+    },
+  } as Event)
+}
+
+function emitError(state: State, text: string): void {
+  const event = {
+    type: "session.error",
+    properties: {
+      sessionID: state.id,
+      error: {
+        name: "UnknownError",
+        data: {
+          message: text,
+        },
+      },
+    },
+  } satisfies Event
+  feed(state, event)
+}
+
+async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
+  const ref = make(state, "bash", {
+    command: "git status",
+    workdir: process.cwd(),
+    description: "Show git status",
+  })
+  startTool(state, ref)
+  await wait(70, signal)
+  doneTool(state, ref, {
+    title: "git status",
+    output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
+    metadata: {
+      exitCode: 0,
+    },
+  })
+}
+
+function emitWrite(state: State): void {
+  const file = path.join(process.cwd(), "src", "demo-format.ts")
+  const ref = make(state, "write", {
+    filePath: file,
+    content: "export const demo = 42\n",
+  })
+  doneTool(state, ref, {
+    title: "write",
+    output: "",
+    metadata: {},
+  })
+}
+
+function emitEdit(state: State): void {
+  const file = path.join(process.cwd(), "src", "demo-format.ts")
+  const ref = make(state, "edit", {
+    filePath: file,
+  })
+  doneTool(state, ref, {
+    title: "edit",
+    output: "",
+    metadata: {
+      diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
+    },
+  })
+}
+
+function emitPatch(state: State): void {
+  const file = path.join(process.cwd(), "src", "demo-format.ts")
+  const ref = make(state, "apply_patch", {
+    patchText: "*** Begin Patch\n*** End Patch",
+  })
+  doneTool(state, ref, {
+    title: "apply_patch",
+    output: "",
+    metadata: {
+      files: [
+        {
+          type: "update",
+          filePath: file,
+          relativePath: "src/demo-format.ts",
+          diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
+          deletions: 1,
+        },
+        {
+          type: "add",
+          filePath: path.join(process.cwd(), "README-demo.md"),
+          relativePath: "README-demo.md",
+          diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
+          deletions: 0,
+        },
+      ],
+    },
+  })
+}
+
+function emitTask(state: State): void {
+  const ref = make(state, "task", {
+    description: "Scan run/* for reducer touchpoints",
+    subagent_type: "explore",
+  })
+  doneTool(state, ref, {
+    title: "Reducer touchpoints found",
+    output: "",
+    metadata: {
+      toolcalls: 4,
+      sessionId: "sub_demo_1",
+    },
+  })
+  const part = {
+    id: "sub_demo_tool_1",
+    type: "tool",
+    sessionID: "sub_demo_1",
+    messageID: "sub_demo_msg_tool",
+    callID: "sub_demo_call_1",
+    tool: "read",
+    state: {
+      status: "running",
+      input: {
+        filePath: "packages/opencode/src/cli/cmd/run/stream.ts",
+        offset: 1,
+        limit: 200,
+      },
+      time: {
+        start: Date.now(),
+      },
+    },
+  } satisfies ToolPart
+  showSubagent(state, {
+    sessionID: "sub_demo_1",
+    partID: ref.part,
+    callID: ref.call,
+    label: "Explore",
+    description: "Scan run/* for reducer touchpoints",
+    status: "completed",
+    title: "Reducer touchpoints found",
+    toolCalls: 4,
+    commits: [
+      {
+        kind: "user",
+        text: "Scan run/* for reducer touchpoints",
+        phase: "start",
+        source: "system",
+      },
+      {
+        kind: "reasoning",
+        text: "Thinking: tracing reducer and footer boundaries",
+        phase: "progress",
+        source: "reasoning",
+        messageID: "sub_demo_msg_reasoning",
+        partID: "sub_demo_reasoning_1",
+      },
+      {
+        kind: "tool",
+        text: "running read",
+        phase: "start",
+        source: "tool",
+        messageID: "sub_demo_msg_tool",
+        partID: "sub_demo_tool_1",
+        tool: "read",
+        part,
+      },
+      {
+        kind: "assistant",
+        text: "Footer updates flow through stream.ts into RunFooter",
+        phase: "progress",
+        source: "assistant",
+        messageID: "sub_demo_msg_text",
+        partID: "sub_demo_text_1",
+      },
+    ],
+  })
+}
+
+function emitTodo(state: State): void {
+  const ref = make(state, "todowrite", {
+    todos: [
+      {
+        content: "Trigger permission UI",
+        status: "completed",
+      },
+      {
+        content: "Trigger question UI",
+        status: "in_progress",
+      },
+      {
+        content: "Tune tool formatting",
+        status: "pending",
+      },
+    ],
+  })
+  doneTool(state, ref, {
+    title: "todowrite",
+    output: "",
+    metadata: {},
+  })
+}
+
+function emitQuestionTool(state: State): void {
+  const ref = make(state, "question", {
+    questions: [
+      {
+        header: "Style",
+        question: "Which output style do you want to inspect?",
+        options: [
+          { label: "Diff", description: "Show diff block" },
+          { label: "Code", description: "Show code block" },
+        ],
+        multiple: false,
+      },
+      {
+        header: "Extras",
+        question: "Pick extra rows",
+        options: [
+          { label: "Usage", description: "Add usage row" },
+          { label: "Duration", description: "Add duration row" },
+        ],
+        multiple: true,
+        custom: true,
+      },
+    ],
+  })
+  doneTool(state, ref, {
+    title: "question",
+    output: "",
+    metadata: {
+      answers: [["Diff"], ["Usage", "custom-note"]],
+    },
+  })
+}
+
+function emitPermission(state: State, kind: PermissionKind = "edit"): void {
+  const root = process.cwd()
+  const file = path.join(root, "src", "demo-format.ts")
+
+  if (kind === "bash") {
+    const command = "git status --short"
+    const ref = make(state, "bash", {
+      command,
+      workdir: root,
+      description: "Inspect worktree changes",
+    })
+    askPermission(state, {
+      ref,
+      permission: "bash",
+      patterns: [command],
+      always: ["*"],
+      done: {
+        title: "git status --short",
+        output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
+        metadata: {
+          exitCode: 0,
+        },
+      },
+    })
+    return
+  }
+
+  if (kind === "read") {
+    const target = path.join(root, "package.json")
+    const ref = make(state, "read", {
+      filePath: target,
+      offset: 1,
+      limit: 80,
+    })
+    askPermission(state, {
+      ref,
+      permission: "read",
+      patterns: [target],
+      always: [target],
+      done: {
+        title: "read",
+        output: ["1: {", '2:   "name": "opencode",', '3:   "private": true', "4: }"].join("\n"),
+        metadata: {},
+      },
+    })
+    return
+  }
+
+  if (kind === "task") {
+    const ref = make(state, "task", {
+      description: "Inspect footer spacing across direct-mode prompts",
+      subagent_type: "explore",
+    })
+    askPermission(state, {
+      ref,
+      permission: "task",
+      patterns: ["explore"],
+      always: ["*"],
+      done: {
+        title: "Footer spacing checked",
+        output: "",
+        metadata: {
+          toolcalls: 3,
+          sessionId: "sub_demo_perm_1",
+        },
+      },
+    })
+    return
+  }
+
+  if (kind === "external") {
+    const dir = path.join(path.dirname(root), "demo-shared")
+    const target = path.join(dir, "README.md")
+    const ref = make(state, "read", {
+      filePath: target,
+      offset: 1,
+      limit: 40,
+    })
+    askPermission(state, {
+      ref,
+      permission: "external_directory",
+      patterns: [`${dir}/**`],
+      metadata: {
+        parentDir: dir,
+        filepath: target,
+      },
+      always: [`${dir}/**`],
+      done: {
+        title: "read",
+        output: `1: # External demo\n2: Shared preview file\nPath: ${target}`,
+        metadata: {},
+      },
+    })
+    return
+  }
+
+  if (kind === "doom") {
+    const ref = make(state, "task", {
+      description: "Retry the formatter after repeated failures",
+      subagent_type: "general",
+    })
+    askPermission(state, {
+      ref,
+      permission: "doom_loop",
+      patterns: ["*"],
+      always: ["*"],
+      done: {
+        title: "Retry allowed",
+        output: "Continuing after repeated failures.\n",
+        metadata: {},
+      },
+    })
+    return
+  }
+
+  const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n"
+  const ref = make(state, "edit", {
+    filePath: file,
+    filepath: file,
+    diff,
+  })
+  askPermission(state, {
+    ref,
+    permission: "edit",
+    patterns: [file],
+    always: [file],
+    done: {
+      title: "edit",
+      output: "",
+      metadata: {
+        diff,
+      },
+    },
+  })
+}
+
+function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
+  const questions =
+    kind === "single"
+      ? [
+          {
+            header: "Mode",
+            question: "Which footer should be the reference for spacing checks?",
+            options: [
+              { label: "Permission", description: "Inspect the permission footer" },
+              { label: "Question", description: "Keep this question footer open" },
+              { label: "Prompt", description: "Return to the normal composer" },
+            ],
+            multiple: false,
+            custom: false,
+          },
+        ]
+      : kind === "checklist"
+        ? [
+            {
+              header: "Checks",
+              question: "Select the direct-mode cases you want to inspect next",
+              options: [
+                { label: "Diff", description: "Show an edit diff in the footer" },
+                { label: "Task", description: "Show a structured task summary" },
+                { label: "Todo", description: "Show a todo snapshot" },
+                { label: "Error", description: "Show an error transcript row" },
+              ],
+              multiple: true,
+              custom: false,
+            },
+          ]
+        : kind === "custom"
+          ? [
+              {
+                header: "Reply",
+                question: "What custom answer should appear in the footer preview?",
+                options: [
+                  { label: "Short note", description: "Keep the answer to one line" },
+                  { label: "Wrapped note", description: "Use a longer answer to test wrapping" },
+                ],
+                multiple: false,
+                custom: true,
+              },
+            ]
+          : [
+              {
+                header: "Layout",
+                question: "Which footer view should stay active while testing?",
+                options: [
+                  { label: "Prompt", description: "Return to prompt" },
+                  { label: "Question", description: "Keep question open" },
+                ],
+                multiple: false,
+              },
+              {
+                header: "Rows",
+                question: "Pick formatting previews",
+                options: [
+                  { label: "Diff", description: "Emit edit diff" },
+                  { label: "Task", description: "Emit task card" },
+                  { label: "Todo", description: "Emit todo card" },
+                ],
+                multiple: true,
+                custom: true,
+              },
+            ]
+
+  const ref = make(state, "question", { questions })
+  startTool(state, ref)
+
+  const id = take(state, "ask", "ask")
+  state.asks.set(id, { ref })
+
+  feed(state, {
+    type: "question.asked",
+    properties: {
+      id,
+      sessionID: state.id,
+      questions,
+      tool: {
+        messageID: ref.msg,
+        callID: ref.call,
+      },
+    },
+  } as Event)
+}
+
+async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
+  if (kind === "text") {
+    await emitText(state, body || SAMPLE_MARKDOWN, signal)
+    return true
+  }
+
+  if (kind === "markdown" || kind === "md") {
+    await emitText(state, body || SAMPLE_MARKDOWN, signal)
+    return true
+  }
+
+  if (kind === "table") {
+    await emitText(state, body || SAMPLE_TABLE, signal)
+    return true
+  }
+
+  if (kind === "reasoning") {
+    await emitReasoning(state, body || "Planning next steps [REDACTED] while preserving reducer ordering.", signal)
+    return true
+  }
+
+  if (kind === "bash") {
+    await emitBash(state, signal)
+    return true
+  }
+
+  if (kind === "write") {
+    emitWrite(state)
+    return true
+  }
+
+  if (kind === "edit") {
+    emitEdit(state)
+    return true
+  }
+
+  if (kind === "patch") {
+    emitPatch(state)
+    return true
+  }
+
+  if (kind === "task") {
+    emitTask(state)
+    return true
+  }
+
+  if (kind === "todo") {
+    emitTodo(state)
+    return true
+  }
+
+  if (kind === "question") {
+    emitQuestionTool(state)
+    return true
+  }
+
+  if (kind === "error") {
+    emitError(state, body || "demo error event")
+    return true
+  }
+
+  if (kind === "mix") {
+    await emitText(state, SAMPLE_MARKDOWN, signal)
+    await wait(50, signal)
+    await emitReasoning(state, "Thinking through formatter edge cases [REDACTED].", signal)
+    await wait(50, signal)
+    await emitBash(state, signal)
+    emitWrite(state)
+    emitEdit(state)
+    emitPatch(state)
+    emitTask(state)
+    emitTodo(state)
+    emitQuestionTool(state)
+    emitError(state, "demo mixed scenario error")
+    return true
+  }
+
+  return false
+}
+
+function intro(state: State): void {
+  note(
+    state.footer,
+    [
+      "Demo slash commands enabled for interactive mode.",
+      `- /permission [kind] (${PERMISSIONS.join(", ")})`,
+      `- /question [kind] (${QUESTIONS.join(", ")})`,
+      `- /fmt <kind> (${KINDS.join(", ")})`,
+      "Examples:",
+      "- /permission bash",
+      "- /question custom",
+      "- /fmt markdown",
+      "- /fmt table",
+      "- /fmt text your custom text",
+    ].join("\n"),
+  )
+}
+
+export function createRunDemo(input: Input) {
+  const state: State = {
+    id: input.sessionID,
+    thinking: input.thinking,
+    data: createSessionData(),
+    footer: input.footer,
+    limits: input.limits,
+    msg: 0,
+    part: 0,
+    call: 0,
+    perm: 0,
+    ask: 0,
+    perms: new Map(),
+    asks: new Map(),
+  }
+
+  const start = async (): Promise<void> => {
+    intro(state)
+    if (input.mode === "on") {
+      return
+    }
+
+    if (input.mode === "permission") {
+      emitPermission(state, "edit")
+      return
+    }
+
+    if (input.mode === "question") {
+      emitQuestion(state, "multi")
+      return
+    }
+
+    if (input.mode === "mix") {
+      await emitFmt(state, "mix", "")
+      return
+    }
+
+    if (input.mode === "text") {
+      await emitFmt(state, "text", input.text ?? SAMPLE_MARKDOWN)
+    }
+  }
+
+  const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise<boolean> => {
+    const text = line.text.trim()
+    const list = text.split(/\s+/)
+    const cmd = list[0] || ""
+
+    clearSubagent(state.footer)
+
+    if (cmd === "/help") {
+      intro(state)
+      return true
+    }
+
+    if (cmd === "/permission") {
+      const kind = permissionKind(list[1])
+      if (!kind) {
+        note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`)
+        return true
+      }
+
+      emitPermission(state, kind)
+      return true
+    }
+
+    if (cmd === "/question") {
+      const kind = questionKind(list[1])
+      if (!kind) {
+        note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`)
+        return true
+      }
+
+      emitQuestion(state, kind)
+      return true
+    }
+
+    if (cmd === "/fmt") {
+      const kind = (list[1] || "").toLowerCase()
+      const body = list.slice(2).join(" ")
+      if (!kind) {
+        note(state.footer, `Pick a kind: ${KINDS.join(", ")}`)
+        return true
+      }
+
+      const ok = await emitFmt(state, kind, body, signal)
+      if (ok) {
+        return true
+      }
+
+      note(state.footer, `Unknown kind "${kind}". Use: ${KINDS.join(", ")}`)
+      return true
+    }
+
+    return false
+  }
+
+  const permission = (input: PermissionReply): boolean => {
+    const item = state.perms.get(input.requestID)
+    if (!item || !input.reply) {
+      return false
+    }
+
+    state.perms.delete(input.requestID)
+    const event = {
+      type: "permission.replied",
+      properties: {
+        sessionID: state.id,
+        requestID: input.requestID,
+        reply: input.reply,
+      },
+    } satisfies Event
+    feed(state, event)
+
+    if (input.reply === "reject") {
+      failTool(state, item.ref, input.message || "permission rejected")
+      return true
+    }
+
+    doneTool(state, item.ref, item.done)
+    return true
+  }
+
+  const questionReply = (input: QuestionReply): boolean => {
+    const ask = state.asks.get(input.requestID)
+    if (!ask || !input.answers) {
+      return false
+    }
+
+    state.asks.delete(input.requestID)
+    const event = {
+      type: "question.replied",
+      properties: {
+        sessionID: state.id,
+        requestID: input.requestID,
+        answers: input.answers,
+      },
+    } satisfies Event
+    feed(state, event)
+    doneTool(state, ask.ref, {
+      title: "question",
+      output: "",
+      metadata: {
+        answers: input.answers,
+      },
+    })
+    return true
+  }
+
+  const questionReject = (input: QuestionReject): boolean => {
+    const ask = state.asks.get(input.requestID)
+    if (!ask) {
+      return false
+    }
+
+    state.asks.delete(input.requestID)
+    feed(state, {
+      type: "question.rejected",
+      properties: {
+        sessionID: state.id,
+        requestID: input.requestID,
+      },
+    } as Event)
+    failTool(state, ask.ref, "question rejected")
+    return true
+  }
+
+  return {
+    start,
+    prompt,
+    permission,
+    questionReply,
+    questionReject,
+  }
+}

+ 183 - 0
packages/opencode/src/cli/cmd/run/entry.body.ts

@@ -0,0 +1,183 @@
+import { toolEntryBody } from "./tool"
+import type { RunEntryBody, StreamCommit } from "./types"
+
+export type EntryFlags = {
+  startOnNewLine: boolean
+  trailingNewline: boolean
+}
+
+export const RUN_ENTRY_NONE: RunEntryBody = {
+  type: "none",
+}
+
+export function cleanRunText(text: string): string {
+  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
+}
+
+function textBody(content: string): RunEntryBody {
+  if (!content) {
+    return RUN_ENTRY_NONE
+  }
+
+  return {
+    type: "text",
+    content,
+  }
+}
+
+function codeBody(content: string, filetype?: string): RunEntryBody {
+  if (!content) {
+    return RUN_ENTRY_NONE
+  }
+
+  return {
+    type: "code",
+    content,
+    filetype,
+  }
+}
+
+function markdownBody(content: string): RunEntryBody {
+  if (!content) {
+    return RUN_ENTRY_NONE
+  }
+
+  return {
+    type: "markdown",
+    content,
+  }
+}
+
+function userBody(raw: string): RunEntryBody {
+  if (!raw.trim()) {
+    return RUN_ENTRY_NONE
+  }
+
+  const lead = raw.match(/^\n+/)?.[0] ?? ""
+  const body = lead ? raw.slice(lead.length) : raw
+  return textBody(`${lead}› ${body}`)
+}
+
+function reasoningBody(raw: string): RunEntryBody {
+  const clean = raw.replace(/\[REDACTED\]/g, "")
+  if (!clean) {
+    return RUN_ENTRY_NONE
+  }
+
+  const lead = clean.match(/^\n+/)?.[0] ?? ""
+  const body = lead ? clean.slice(lead.length) : clean
+  const mark = "Thinking:"
+  if (body.startsWith(mark)) {
+    return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown")
+  }
+
+  return codeBody(clean, "markdown")
+}
+
+function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
+  return textBody(phase === "progress" ? raw : raw.trim())
+}
+
+export function entryFlags(commit: StreamCommit): EntryFlags {
+  if (commit.kind === "user") {
+    return {
+      startOnNewLine: true,
+      trailingNewline: false,
+    }
+  }
+
+  if (commit.kind === "tool") {
+    if (commit.phase === "progress") {
+      return {
+        startOnNewLine: false,
+        trailingNewline: false,
+      }
+    }
+
+    return {
+      startOnNewLine: true,
+      trailingNewline: true,
+    }
+  }
+
+  if (commit.kind === "assistant" || commit.kind === "reasoning") {
+    if (commit.phase === "progress") {
+      return {
+        startOnNewLine: false,
+        trailingNewline: false,
+      }
+    }
+
+    return {
+      startOnNewLine: true,
+      trailingNewline: true,
+    }
+  }
+
+  return {
+    startOnNewLine: true,
+    trailingNewline: true,
+  }
+}
+
+export function entryDone(commit: StreamCommit): boolean {
+  if (commit.kind === "assistant" || commit.kind === "reasoning") {
+    return commit.phase === "final"
+  }
+
+  if (commit.kind === "tool") {
+    return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed")
+  }
+
+  return true
+}
+
+export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean {
+  if (commit.phase !== "progress") {
+    return false
+  }
+
+  if (body.type === "none") {
+    return false
+  }
+
+  return commit.kind === "assistant" || commit.kind === "reasoning" || commit.kind === "tool"
+}
+
+export function entryBody(commit: StreamCommit): RunEntryBody {
+  const raw = cleanRunText(commit.text)
+
+  if (commit.kind === "user") {
+    return userBody(raw)
+  }
+
+  if (commit.kind === "tool") {
+    return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE
+  }
+
+  if (commit.kind === "assistant") {
+    if (commit.phase === "start") {
+      return RUN_ENTRY_NONE
+    }
+
+    if (commit.phase === "final") {
+      return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
+    }
+
+    return markdownBody(raw)
+  }
+
+  if (commit.kind === "reasoning") {
+    if (commit.phase === "start") {
+      return RUN_ENTRY_NONE
+    }
+
+    if (commit.phase === "final") {
+      return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE
+    }
+
+    return reasoningBody(raw)
+  }
+
+  return systemBody(raw, commit.phase)
+}

+ 487 - 0
packages/opencode/src/cli/cmd/run/footer.permission.tsx

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

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

@@ -0,0 +1,977 @@
+// Prompt textarea component and its state machine for direct interactive mode.
+//
+// createPromptState() wires keybinds, history navigation, leader-key sequences,
+// and direct-mode `@` autocomplete for files, subagents, and MCP resources.
+// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
+// while RunPromptAutocomplete renders a fixed-height suggestion list below it.
+/** @jsxImportSource @opentui/solid */
+import { pathToFileURL } from "bun"
+import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import fuzzysort from "fuzzysort"
+import path from "path"
+import {
+  Index,
+  Show,
+  createEffect,
+  createMemo,
+  createResource,
+  createSignal,
+  onCleanup,
+  onMount,
+  type Accessor,
+} from "solid-js"
+import * as Locale from "@/util/locale"
+import {
+  createPromptHistory,
+  isExitCommand,
+  movePromptHistory,
+  promptCycle,
+  promptHit,
+  promptInfo,
+  promptKeys,
+  pushPromptHistory,
+} from "./prompt.shared"
+import type { FooterKeybinds, FooterState, RunAgent, RunPrompt, RunPromptPart, RunResource } from "./types"
+import type { RunFooterTheme } from "./theme"
+
+const LEADER_TIMEOUT_MS = 2000
+const AUTOCOMPLETE_ROWS = 6
+
+const EMPTY_BORDER = {
+  topLeft: "",
+  bottomLeft: "",
+  vertical: "",
+  topRight: "",
+  bottomRight: "",
+  horizontal: " ",
+  bottomT: "",
+  topT: "",
+  cross: "",
+  leftT: "",
+  rightT: "",
+}
+
+export const TEXTAREA_MIN_ROWS = 1
+export const TEXTAREA_MAX_ROWS = 6
+export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1
+
+export const HINT_BREAKPOINTS = {
+  send: 50,
+  newline: 66,
+  history: 80,
+  variant: 95,
+}
+
+type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
+
+type Auto = {
+  display: string
+  value: string
+  part: Mention
+  description?: string
+  directory?: boolean
+}
+
+type PromptInput = {
+  directory: string
+  findFiles: (query: string) => Promise<string[]>
+  agents: Accessor<RunAgent[]>
+  resources: Accessor<RunResource[]>
+  keybinds: FooterKeybinds
+  state: Accessor<FooterState>
+  view: Accessor<string>
+  prompt: Accessor<boolean>
+  width: Accessor<number>
+  theme: Accessor<RunFooterTheme>
+  history?: RunPrompt[]
+  onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
+  onCycle: () => void
+  onInterrupt: () => boolean
+  onExitRequest?: () => boolean
+  onExit: () => void
+  onRows: (rows: number) => void
+  onStatus: (text: string) => void
+}
+
+export type PromptState = {
+  placeholder: Accessor<StyledText | string>
+  bindings: Accessor<KeyBinding[]>
+  visible: Accessor<boolean>
+  options: Accessor<Auto[]>
+  selected: Accessor<number>
+  onSubmit: () => void
+  onKeyDown: (event: KeyEvent) => void
+  onContentChange: () => void
+  bind: (area?: TextareaRenderable) => void
+}
+
+function clamp(rows: number): number {
+  return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
+}
+
+function clonePrompt(prompt: RunPrompt): RunPrompt {
+  return {
+    text: prompt.text,
+    parts: structuredClone(prompt.parts),
+  }
+}
+
+function removeLineRange(input: string) {
+  const hash = input.lastIndexOf("#")
+  return hash === -1 ? input : input.slice(0, hash)
+}
+
+function extractLineRange(input: string) {
+  const hash = input.lastIndexOf("#")
+  if (hash === -1) {
+    return { base: input }
+  }
+
+  const base = input.slice(0, hash)
+  const line = input.slice(hash + 1)
+  const match = line.match(/^(\d+)(?:-(\d*))?$/)
+  if (!match) {
+    return { base }
+  }
+
+  const start = Number(match[1])
+  const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined
+  return { base, line: { start, end } }
+}
+
+export function hintFlags(width: number) {
+  return {
+    send: width >= HINT_BREAKPOINTS.send,
+    newline: width >= HINT_BREAKPOINTS.newline,
+    history: width >= HINT_BREAKPOINTS.history,
+    variant: width >= HINT_BREAKPOINTS.variant,
+  }
+}
+
+export function RunPromptBody(props: {
+  theme: () => RunFooterTheme
+  placeholder: () => StyledText | string
+  bindings: () => KeyBinding[]
+  onSubmit: () => void
+  onKeyDown: (event: KeyEvent) => void
+  onContentChange: () => void
+  bind: (area?: TextareaRenderable) => void
+}) {
+  let area: TextareaRenderable | undefined
+
+  onMount(() => {
+    props.bind(area)
+  })
+
+  onCleanup(() => {
+    props.bind(undefined)
+  })
+
+  return (
+    <box id="run-direct-footer-prompt" width="100%">
+      <box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
+        <textarea
+          id="run-direct-footer-composer"
+          width="100%"
+          minHeight={TEXTAREA_MIN_ROWS}
+          maxHeight={TEXTAREA_MAX_ROWS}
+          wrapMode="word"
+          placeholder={props.placeholder()}
+          placeholderColor={props.theme().muted}
+          textColor={props.theme().text}
+          focusedTextColor={props.theme().text}
+          backgroundColor={props.theme().surface}
+          focusedBackgroundColor={props.theme().surface}
+          cursorColor={props.theme().text}
+          keyBindings={props.bindings()}
+          onSubmit={props.onSubmit}
+          onKeyDown={props.onKeyDown}
+          onContentChange={props.onContentChange}
+          ref={(next) => {
+            area = next
+          }}
+        />
+      </box>
+    </box>
+  )
+}
+
+export function RunPromptAutocomplete(props: {
+  theme: () => RunFooterTheme
+  options: () => Auto[]
+  selected: () => number
+}) {
+  return (
+    <box
+      id="run-direct-footer-complete"
+      width="100%"
+      height={AUTOCOMPLETE_ROWS}
+      border={["left"]}
+      borderColor={props.theme().border}
+      customBorderChars={{
+        ...EMPTY_BORDER,
+        vertical: "┃",
+      }}
+    >
+      <box
+        id="run-direct-footer-complete-fill"
+        width="100%"
+        height={AUTOCOMPLETE_ROWS}
+        flexDirection="column"
+        backgroundColor={props.theme().pane}
+      >
+        <Index
+          each={props.options()}
+          fallback={
+            <box paddingLeft={1} paddingRight={1}>
+              <text fg={props.theme().muted}>No matching items</text>
+            </box>
+          }
+        >
+          {(item, index) => (
+            <box
+              paddingLeft={1}
+              paddingRight={1}
+              flexDirection="row"
+              gap={1}
+              backgroundColor={index === props.selected() ? props.theme().highlight : undefined}
+            >
+              <text
+                fg={index === props.selected() ? props.theme().surface : props.theme().text}
+                wrapMode="none"
+                truncate
+              >
+                {item().display}
+              </text>
+              <Show when={item().description}>
+                <text
+                  fg={index === props.selected() ? props.theme().surface : props.theme().muted}
+                  wrapMode="none"
+                  truncate
+                >
+                  {item().description}
+                </text>
+              </Show>
+            </box>
+          )}
+        </Index>
+      </box>
+    </box>
+  )
+}
+
+export function createPromptState(input: PromptInput): PromptState {
+  const keys = createMemo(() => promptKeys(input.keybinds))
+  const bindings = createMemo(() => keys().bindings)
+  const placeholder = createMemo(() => {
+    if (!input.state().first) {
+      return ""
+    }
+
+    return new StyledText([
+      bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
+    ])
+  })
+
+  let history = createPromptHistory(input.history)
+  let draft: RunPrompt = { text: "", parts: [] }
+  let stash: RunPrompt = { text: "", parts: [] }
+  let area: TextareaRenderable | undefined
+  let leader = false
+  let timeout: NodeJS.Timeout | undefined
+  let tick = false
+  let prev = input.view()
+  let type = 0
+  let parts: Mention[] = []
+  let marks = new Map<number, number>()
+
+  const [visible, setVisible] = createSignal(false)
+  const [at, setAt] = createSignal(0)
+  const [selected, setSelected] = createSignal(0)
+  const [query, setQuery] = createSignal("")
+
+  const width = createMemo(() => Math.max(20, input.width() - 8))
+  const agents = createMemo<Auto[]>(() => {
+    return input
+      .agents()
+      .filter((item) => !item.hidden && item.mode !== "primary")
+      .map((item) => ({
+        display: "@" + item.name,
+        value: item.name,
+        part: {
+          type: "agent",
+          name: item.name,
+          source: {
+            start: 0,
+            end: 0,
+            value: "",
+          },
+        },
+      }))
+  })
+  const resources = createMemo<Auto[]>(() => {
+    return input.resources().map((item) => ({
+      display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
+      value: item.name,
+      description: item.description,
+      part: {
+        type: "file",
+        mime: item.mimeType ?? "text/plain",
+        filename: item.name,
+        url: item.uri,
+        source: {
+          type: "resource",
+          clientName: item.client,
+          uri: item.uri,
+          text: {
+            start: 0,
+            end: 0,
+            value: "",
+          },
+        },
+      },
+    }))
+  })
+  const [files] = createResource(
+    query,
+    async (value) => {
+      if (!visible()) {
+        return []
+      }
+
+      const next = extractLineRange(value)
+      const list = await input.findFiles(next.base)
+      return list
+        .sort((a, b) => {
+          const dir = Number(b.endsWith("/")) - Number(a.endsWith("/"))
+          if (dir !== 0) {
+            return dir
+          }
+
+          const depth = a.split("/").length - b.split("/").length
+          if (depth !== 0) {
+            return depth
+          }
+
+          return a.localeCompare(b)
+        })
+        .map((item): Auto => {
+          const url = pathToFileURL(path.resolve(input.directory, item))
+          let filename = item
+          if (next.line && !item.endsWith("/")) {
+            filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
+            url.searchParams.set("start", String(next.line.start))
+            if (next.line.end !== undefined) {
+              url.searchParams.set("end", String(next.line.end))
+            }
+          }
+
+          return {
+            display: Locale.truncateMiddle("@" + filename, width()),
+            value: filename,
+            directory: item.endsWith("/"),
+            part: {
+              type: "file",
+              mime: item.endsWith("/") ? "application/x-directory" : "text/plain",
+              filename,
+              url: url.href,
+              source: {
+                type: "file",
+                path: item,
+                text: {
+                  start: 0,
+                  end: 0,
+                  value: "",
+                },
+              },
+            },
+          }
+        })
+    },
+    { initialValue: [] as Auto[] },
+  )
+  const options = createMemo(() => {
+    const mixed = [...agents(), ...files(), ...resources()]
+    if (!query()) {
+      return mixed.slice(0, AUTOCOMPLETE_ROWS)
+    }
+
+    return fuzzysort
+      .go(removeLineRange(query()), mixed, {
+        keys: [(item) => (item.value || item.display).trimEnd(), "description"],
+        limit: AUTOCOMPLETE_ROWS,
+      })
+      .map((item) => item.obj)
+  })
+  const popup = createMemo(() => {
+    return visible() ? AUTOCOMPLETE_ROWS - 1 : 0
+  })
+
+  const clear = () => {
+    leader = false
+    if (!timeout) {
+      return
+    }
+
+    clearTimeout(timeout)
+    timeout = undefined
+  }
+
+  const arm = () => {
+    clear()
+    leader = true
+    timeout = setTimeout(() => {
+      clear()
+    }, LEADER_TIMEOUT_MS)
+  }
+
+  const hide = () => {
+    setVisible(false)
+    setQuery("")
+    setSelected(0)
+  }
+
+  const syncRows = () => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    input.onRows(clamp(area.virtualLineCount || 1) + popup())
+  }
+
+  const scheduleRows = () => {
+    if (tick) {
+      return
+    }
+
+    tick = true
+    queueMicrotask(() => {
+      tick = false
+      syncRows()
+    })
+  }
+
+  const syncParts = () => {
+    if (!area || area.isDestroyed || type === 0) {
+      return
+    }
+
+    const next: Mention[] = []
+    const map = new Map<number, number>()
+    for (const item of area.extmarks.getAllForTypeId(type)) {
+      const idx = marks.get(item.id)
+      if (idx === undefined) {
+        continue
+      }
+
+      const part = parts[idx]
+      if (!part) {
+        continue
+      }
+
+      const text = area.plainText.slice(item.start, item.end)
+      const prev =
+        part.type === "agent"
+          ? (part.source?.value ?? "@" + part.name)
+          : (part.source?.text.value ?? "@" + (part.filename ?? ""))
+      if (text !== prev) {
+        continue
+      }
+
+      const copy = structuredClone(part)
+      if (copy.type === "agent") {
+        copy.source = {
+          start: item.start,
+          end: item.end,
+          value: text,
+        }
+      }
+      if (copy.type === "file" && copy.source?.text) {
+        copy.source.text.start = item.start
+        copy.source.text.end = item.end
+        copy.source.text.value = text
+      }
+
+      map.set(item.id, next.length)
+      next.push(copy)
+    }
+
+    const stale = map.size !== marks.size
+    parts = next
+    marks = map
+    if (stale) {
+      restoreParts(next)
+    }
+  }
+
+  const clearParts = () => {
+    if (area && !area.isDestroyed) {
+      area.extmarks.clear()
+    }
+    parts = []
+    marks = new Map()
+  }
+
+  const restoreParts = (value: RunPromptPart[]) => {
+    clearParts()
+    parts = value
+      .filter((item): item is Mention => item.type === "file" || item.type === "agent")
+      .map((item) => structuredClone(item))
+    if (!area || area.isDestroyed || type === 0) {
+      return
+    }
+
+    const box = area
+    parts.forEach((item, idx) => {
+      const start = item.type === "agent" ? item.source?.start : item.source?.text.start
+      const end = item.type === "agent" ? item.source?.end : item.source?.text.end
+      if (start === undefined || end === undefined) {
+        return
+      }
+
+      const id = box.extmarks.create({
+        start,
+        end,
+        virtual: true,
+        typeId: type,
+      })
+      marks.set(id, idx)
+    })
+  }
+
+  const restore = (value: RunPrompt, cursor = value.text.length) => {
+    draft = clonePrompt(value)
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    hide()
+    area.setText(value.text)
+    restoreParts(value.parts)
+    area.cursorOffset = Math.min(cursor, area.plainText.length)
+    scheduleRows()
+    area.focus()
+  }
+
+  const refresh = () => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    const cursor = area.cursorOffset
+    const text = area.plainText
+    if (visible()) {
+      if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) {
+        hide()
+        return
+      }
+
+      setQuery(text.slice(at() + 1, cursor))
+      return
+    }
+
+    if (cursor === 0) {
+      return
+    }
+
+    const head = text.slice(0, cursor)
+    const idx = head.lastIndexOf("@")
+    if (idx === -1) {
+      return
+    }
+
+    const before = idx === 0 ? undefined : head[idx - 1]
+    const tail = head.slice(idx)
+    if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) {
+      setAt(idx)
+      setSelected(0)
+      setVisible(true)
+      setQuery(head.slice(idx + 1))
+    }
+  }
+
+  const bind = (next?: TextareaRenderable) => {
+    if (area === next) {
+      return
+    }
+
+    if (area && !area.isDestroyed) {
+      area.off("line-info-change", scheduleRows)
+    }
+
+    area = next
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    if (type === 0) {
+      type = area.extmarks.registerType("run-direct-prompt-part")
+    }
+    area.on("line-info-change", scheduleRows)
+    queueMicrotask(() => {
+      if (!area || area.isDestroyed || !input.prompt()) {
+        return
+      }
+
+      restore(draft)
+      refresh()
+    })
+  }
+
+  const syncDraft = () => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    syncParts()
+    draft = {
+      text: area.plainText,
+      parts: structuredClone(parts),
+    }
+  }
+
+  const push = (value: RunPrompt) => {
+    history = pushPromptHistory(history, value)
+  }
+
+  const move = (dir: -1 | 1, event: KeyEvent) => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    if (history.index === null && dir === -1) {
+      stash = clonePrompt(draft)
+    }
+
+    const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
+    if (!next.apply || next.text === undefined || next.cursor === undefined) {
+      return
+    }
+
+    history = next.state
+    const value =
+      next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
+    restore(value, next.cursor)
+    event.preventDefault()
+  }
+
+  const cycle = (event: KeyEvent): boolean => {
+    const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
+    if (!next.consume) {
+      return false
+    }
+
+    if (next.clear) {
+      clear()
+    }
+
+    if (next.arm) {
+      arm()
+    }
+
+    if (next.cycle) {
+      input.onCycle()
+    }
+
+    event.preventDefault()
+    return true
+  }
+
+  const select = (item?: Auto) => {
+    const next = item ?? options()[selected()]
+    if (!next || !area || area.isDestroyed) {
+      return
+    }
+
+    const cursor = area.cursorOffset
+    const tail = area.plainText.at(cursor)
+    const append = "@" + next.value + (tail === " " ? "" : " ")
+    area.cursorOffset = at()
+    const start = area.logicalCursor
+    area.cursorOffset = cursor
+    const end = area.logicalCursor
+    area.deleteRange(start.row, start.col, end.row, end.col)
+    area.insertText(append)
+
+    const text = "@" + next.value
+    const startOffset = at()
+    const endOffset = startOffset + Bun.stringWidth(text)
+    const part = structuredClone(next.part)
+    if (part.type === "agent") {
+      part.source = {
+        start: startOffset,
+        end: endOffset,
+        value: text,
+      }
+    }
+    if (part.type === "file" && part.source?.text) {
+      part.source.text.start = startOffset
+      part.source.text.end = endOffset
+      part.source.text.value = text
+    }
+
+    if (part.type === "file") {
+      const prev = parts.findIndex((item) => item.type === "file" && item.url === part.url)
+      if (prev !== -1) {
+        const mark = [...marks.entries()].find((item) => item[1] === prev)?.[0]
+        if (mark !== undefined) {
+          area.extmarks.delete(mark)
+        }
+        parts = parts.filter((_, idx) => idx !== prev)
+        marks = new Map(
+          [...marks.entries()]
+            .filter((item) => item[0] !== mark)
+            .map((item) => [item[0], item[1] > prev ? item[1] - 1 : item[1]]),
+        )
+      }
+    }
+
+    const id = area.extmarks.create({
+      start: startOffset,
+      end: endOffset,
+      virtual: true,
+      typeId: type,
+    })
+    marks.set(id, parts.length)
+    parts.push(part)
+    hide()
+    syncDraft()
+    scheduleRows()
+    area.focus()
+  }
+
+  const expand = () => {
+    const next = options()[selected()]
+    if (!next?.directory || !area || area.isDestroyed) {
+      return
+    }
+
+    const cursor = area.cursorOffset
+    area.cursorOffset = at()
+    const start = area.logicalCursor
+    area.cursorOffset = cursor
+    const end = area.logicalCursor
+    area.deleteRange(start.row, start.col, end.row, end.col)
+    area.insertText("@" + next.value)
+    syncDraft()
+    refresh()
+  }
+
+  const onKeyDown = (event: KeyEvent) => {
+    if (visible()) {
+      const name = event.name.toLowerCase()
+      const ctrl = event.ctrl && !event.meta && !event.shift
+      if (name === "up" || (ctrl && name === "p")) {
+        event.preventDefault()
+        if (options().length > 0) {
+          setSelected((selected() - 1 + options().length) % options().length)
+        }
+        return
+      }
+
+      if (name === "down" || (ctrl && name === "n")) {
+        event.preventDefault()
+        if (options().length > 0) {
+          setSelected((selected() + 1) % options().length)
+        }
+        return
+      }
+
+      if (name === "escape") {
+        event.preventDefault()
+        hide()
+        return
+      }
+
+      if (name === "return") {
+        event.preventDefault()
+        select()
+        return
+      }
+
+      if (name === "tab") {
+        event.preventDefault()
+        if (options()[selected()]?.directory) {
+          expand()
+          return
+        }
+
+        select()
+        return
+      }
+    }
+
+    if (event.ctrl && event.name === "c") {
+      const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
+      if (handled) {
+        event.preventDefault()
+      }
+      return
+    }
+
+    const key = promptInfo(event)
+    if (promptHit(keys().interrupts, key)) {
+      if (input.onInterrupt()) {
+        event.preventDefault()
+        return
+      }
+    }
+
+    if (cycle(event)) {
+      return
+    }
+
+    const up = promptHit(keys().previous, key)
+    const down = promptHit(keys().next, key)
+    if (!up && !down) {
+      return
+    }
+
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    const dir = up ? -1 : 1
+    if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
+      move(dir, event)
+      return
+    }
+
+    if (dir === -1 && area.visualCursor.visualRow === 0) {
+      area.cursorOffset = 0
+    }
+
+    const end =
+      typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
+        ? area.height - 1
+        : Math.max(0, area.virtualLineCount - 1)
+    if (dir === 1 && area.visualCursor.visualRow === end) {
+      area.cursorOffset = area.plainText.length
+    }
+  }
+
+  useKeyboard((event) => {
+    if (input.prompt()) {
+      return
+    }
+
+    if (event.ctrl && event.name === "c") {
+      const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
+      if (handled) {
+        event.preventDefault()
+      }
+    }
+  })
+
+  const onSubmit = () => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    if (visible()) {
+      select()
+      return
+    }
+
+    syncDraft()
+    const next = clonePrompt(draft)
+    if (!next.text.trim()) {
+      input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
+      return
+    }
+
+    if (isExitCommand(next.text)) {
+      input.onExit()
+      return
+    }
+
+    area.setText("")
+    clearParts()
+    hide()
+    draft = { text: "", parts: [] }
+    scheduleRows()
+    area.focus()
+    queueMicrotask(async () => {
+      if (await input.onSubmit(next)) {
+        push(next)
+        return
+      }
+
+      restore(next)
+    })
+  }
+
+  onCleanup(() => {
+    clear()
+    if (area && !area.isDestroyed) {
+      area.off("line-info-change", scheduleRows)
+    }
+  })
+
+  createEffect(() => {
+    input.width()
+    popup()
+    if (input.prompt()) {
+      scheduleRows()
+    }
+  })
+
+  createEffect(() => {
+    query()
+    setSelected(0)
+  })
+
+  createEffect(() => {
+    input.state().phase
+    if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
+      return
+    }
+
+    queueMicrotask(() => {
+      if (!area || area.isDestroyed) {
+        return
+      }
+
+      area.focus()
+    })
+  })
+
+  createEffect(() => {
+    const kind = input.view()
+    if (kind === prev) {
+      return
+    }
+
+    if (prev === "prompt") {
+      syncDraft()
+    }
+
+    clear()
+    hide()
+    prev = kind
+    if (kind !== "prompt") {
+      return
+    }
+
+    queueMicrotask(() => {
+      restore(draft)
+    })
+  })
+
+  return {
+    placeholder,
+    bindings,
+    visible,
+    options,
+    selected,
+    onSubmit,
+    onKeyDown,
+    onContentChange: () => {
+      syncDraft()
+      refresh()
+      scheduleRows()
+    },
+    bind,
+  }
+}

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

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

+ 192 - 0
packages/opencode/src/cli/cmd/run/footer.subagent.tsx

@@ -0,0 +1,192 @@
+/** @jsxImportSource @opentui/solid */
+import type { ScrollBoxRenderable } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import "opentui-spinner/solid"
+import { createMemo, mapArray } from "solid-js"
+import { SPINNER_FRAMES } from "../tui/component/spinner"
+import { RunEntryContent, separatorRows } from "./scrollback.writer"
+import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
+import type { RunFooterTheme, RunTheme } from "./theme"
+
+export const SUBAGENT_TAB_ROWS = 2
+export const SUBAGENT_INSPECTOR_ROWS = 8
+
+function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
+  if (status === "completed") {
+    return theme.highlight
+  }
+
+  if (status === "error") {
+    return theme.error
+  }
+
+  return theme.highlight
+}
+
+function statusIcon(status: FooterSubagentTab["status"]) {
+  if (status === "completed") {
+    return "●"
+  }
+
+  if (status === "error") {
+    return "◍"
+  }
+
+  return "◔"
+}
+
+function tabText(tab: FooterSubagentTab, slot: string, count: number, width: number) {
+  const perTab = Math.max(
+    1,
+    Math.floor((width - 4 - Math.max(0, count - 1) * 3) / Math.max(1, count)),
+  )
+  if (count >= 8 || perTab < 12) {
+    return `[${slot}]`
+  }
+
+  const prefix = `[${slot}]`
+  if (count >= 5 || perTab < 24) {
+    return prefix
+  }
+
+  const label = tab.description || tab.title || tab.label
+  return `${prefix} ${label}`
+}
+
+export function RunFooterSubagentTabs(props: {
+  tabs: FooterSubagentTab[]
+  selected?: string
+  theme: RunFooterTheme
+  width: number
+}) {
+  const items = mapArray(
+    () => props.tabs,
+    (tab, index) => {
+      const active = () => props.selected === tab.sessionID
+      const slot = () => String(index() + 1)
+      return (
+        <box paddingRight={1}>
+          <box flexDirection="row" gap={1} width="100%">
+            {tab.status === "running" ? (
+              <box flexShrink={0}>
+                <spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(props.theme, tab.status)} />
+              </box>
+            ) : (
+              <text fg={statusColor(props.theme, tab.status)} wrapMode="none" truncate flexShrink={0}>
+                {statusIcon(tab.status)}
+              </text>
+            )}
+            <text fg={active() ? props.theme.text : props.theme.muted} wrapMode="none" truncate>
+              {tabText(tab, slot(), props.tabs.length, props.width)}
+            </text>
+          </box>
+        </box>
+      )
+    },
+  )
+
+  return (
+    <box
+      id="run-direct-footer-subagent-tabs"
+      width="100%"
+      height={SUBAGENT_TAB_ROWS}
+      paddingLeft={1}
+      paddingRight={2}
+      paddingBottom={1}
+      flexDirection="row"
+      flexShrink={0}
+    >
+      <box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>{items()}</box>
+    </box>
+  )
+}
+
+export function RunFooterSubagentBody(props: {
+  active: () => boolean
+  theme: () => RunTheme
+  detail: () => FooterSubagentDetail | undefined
+  width: () => number
+  diffStyle?: RunDiffStyle
+  onCycle: (dir: -1 | 1) => void
+  onClose: () => void
+}) {
+  const theme = createMemo(() => props.theme())
+  const footer = createMemo(() => theme().footer)
+  const commits = createMemo(() => props.detail()?.commits ?? [])
+  const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
+  const scrollbar = createMemo(() => ({
+    trackOptions: {
+      backgroundColor: footer().surface,
+      foregroundColor: footer().line,
+    },
+  }))
+  const rows = mapArray(commits, (commit, index) => (
+    <box flexDirection="column" gap={0} flexShrink={0}>
+      {index() > 0 && separatorRows(commits()[index() - 1], commit) > 0 ? <box height={1} flexShrink={0} /> : null}
+      <RunEntryContent commit={commit} theme={theme()} opts={opts()} width={props.width()} />
+    </box>
+  ))
+  let scroll: ScrollBoxRenderable | undefined
+
+  useKeyboard((event) => {
+    if (!props.active()) {
+      return
+    }
+
+    if (event.name === "escape") {
+      event.preventDefault()
+      props.onClose()
+      return
+    }
+
+    if (event.name === "tab" && !event.shift) {
+      event.preventDefault()
+      props.onCycle(1)
+      return
+    }
+
+    if (event.name === "up" || event.name === "k") {
+      event.preventDefault()
+      scroll?.scrollBy(-1)
+      return
+    }
+
+    if (event.name === "down" || event.name === "j") {
+      event.preventDefault()
+      scroll?.scrollBy(1)
+    }
+  })
+
+  return (
+    <box
+      id="run-direct-footer-subagent"
+      width="100%"
+      height="100%"
+      flexDirection="column"
+      backgroundColor={footer().surface}
+    >
+      <box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
+        <scrollbox
+          width="100%"
+          height="100%"
+          stickyScroll={true}
+          stickyStart="bottom"
+          verticalScrollbarOptions={scrollbar()}
+          ref={(item) => {
+            scroll = item
+          }}
+        >
+          <box width="100%" flexDirection="column" gap={0}>
+            {commits().length > 0 ? (
+              rows()
+            ) : (
+              <text fg={footer().muted} wrapMode="word">
+                No subagent activity yet
+              </text>
+            )}
+          </box>
+        </scrollbox>
+      </box>
+    </box>
+  )
+}

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

@@ -0,0 +1,705 @@
+// RunFooter -- the mutable control surface for direct interactive mode.
+//
+// In the split-footer architecture, scrollback is immutable (append-only)
+// and the footer is the only region that can repaint. RunFooter owns both
+// sides of that boundary:
+//
+//   Scrollback: append() queues StreamCommit entries and flush() drains them
+//   through retained scrollback surfaces. Commits coalesce in a microtask
+//   queue so direct-mode transcript updates still preserve ordering without
+//   rebuilding the session model.
+//
+//   Footer: event() updates the SolidJS signal-backed FooterState, which
+//   drives the reactive footer view (prompt, status, permission, question).
+//   present() swaps the active footer view and resizes the footer region.
+//
+// Lifecycle:
+//   - close() flushes pending commits and notifies listeners (the prompt
+//     queue uses this to know when to stop).
+//   - destroy() does the same plus tears down event listeners and clears
+//     internal state.
+//   - The renderer's DESTROY event triggers destroy() so the footer
+//     doesn't outlive the renderer.
+//
+// Interrupt and exit use a two-press pattern: first press shows a hint,
+// second press within 5 seconds actually fires the action.
+import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
+import { render } from "@opentui/solid"
+import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { withRunSpan } from "./otel"
+import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
+import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
+import { printableBinding } from "./prompt.shared"
+import { RunFooterView } from "./footer.view"
+import { RunScrollbackStream } from "./scrollback.surface"
+import type { RunTheme } from "./theme"
+import type {
+  RunAgent,
+  FooterApi,
+  FooterEvent,
+  FooterKeybinds,
+  FooterPatch,
+  FooterPromptRoute,
+  RunPrompt,
+  RunResource,
+  FooterState,
+  FooterSubagentState,
+  FooterView,
+  PermissionReply,
+  QuestionReject,
+  QuestionReply,
+  RunDiffStyle,
+  StreamCommit,
+} from "./types"
+
+type CycleResult = {
+  modelLabel?: string
+  status?: string
+}
+
+type RunFooterOptions = {
+  directory: string
+  findFiles: (query: string) => Promise<string[]>
+  agents: RunAgent[]
+  resources: RunResource[]
+  wrote?: boolean
+  sessionID: () => string | undefined
+  agentLabel: string
+  modelLabel: string
+  first: boolean
+  history?: RunPrompt[]
+  theme: RunTheme
+  keybinds: FooterKeybinds
+  diffStyle: RunDiffStyle
+  onPermissionReply: (input: PermissionReply) => void | Promise<void>
+  onQuestionReply: (input: QuestionReply) => void | Promise<void>
+  onQuestionReject: (input: QuestionReject) => void | Promise<void>
+  onCycleVariant?: () => CycleResult | void
+  onInterrupt?: () => void
+  onExit?: () => void
+  onSubagentSelect?: (sessionID: string | undefined) => void
+  treeSitterClient?: TreeSitterClient
+}
+
+const PERMISSION_ROWS = 12
+const QUESTION_ROWS = 14
+
+function createEmptySubagentState(): FooterSubagentState {
+  return {
+    tabs: [],
+    details: {},
+    permissions: [],
+    questions: [],
+  }
+}
+
+function eventPatch(next: FooterEvent): FooterPatch | undefined {
+  if (next.type === "queue") {
+    return { queue: next.queue }
+  }
+
+  if (next.type === "first") {
+    return { first: next.first }
+  }
+
+  if (next.type === "model") {
+    return { model: next.model }
+  }
+
+  if (next.type === "turn.send") {
+    return {
+      phase: "running",
+      status: "sending prompt",
+      queue: next.queue,
+    }
+  }
+
+  if (next.type === "turn.wait") {
+    return {
+      phase: "running",
+      status: "waiting for assistant",
+    }
+  }
+
+  if (next.type === "turn.idle") {
+    return {
+      phase: "idle",
+      status: "",
+      queue: next.queue,
+    }
+  }
+
+  if (next.type === "turn.duration") {
+    return { duration: next.duration }
+  }
+
+  if (next.type === "stream.patch") {
+    return next.patch
+  }
+
+  return undefined
+}
+
+export class RunFooter implements FooterApi {
+  private closed = false
+  private destroyed = false
+  private prompts = new Set<(input: RunPrompt) => void>()
+  private closes = new Set<() => void>()
+  // Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
+  private queue: StreamCommit[] = []
+  private pending = false
+  private flushing: Promise<void> = Promise.resolve()
+  // Fixed portion of footer height above the textarea.
+  private base: number
+  private rows = TEXTAREA_MIN_ROWS
+  private agents: Accessor<RunAgent[]>
+  private setAgents: Setter<RunAgent[]>
+  private resources: Accessor<RunResource[]>
+  private setResources: Setter<RunResource[]>
+  private state: Accessor<FooterState>
+  private setState: Setter<FooterState>
+  private view: Accessor<FooterView>
+  private setView: Setter<FooterView>
+  private subagent: Accessor<FooterSubagentState>
+  private setSubagent: (next: FooterSubagentState) => void
+  private promptRoute: FooterPromptRoute = { type: "composer" }
+  private tabsVisible = false
+  private interruptTimeout: NodeJS.Timeout | undefined
+  private exitTimeout: NodeJS.Timeout | undefined
+  private interruptHint: string
+  private scrollback: RunScrollbackStream
+
+  constructor(
+    private renderer: CliRenderer,
+    private options: RunFooterOptions,
+  ) {
+    const [state, setState] = createSignal<FooterState>({
+      phase: "idle",
+      status: "",
+      queue: 0,
+      model: options.modelLabel,
+      duration: "",
+      usage: "",
+      first: options.first,
+      interrupt: 0,
+      exit: 0,
+    })
+    this.state = state
+    this.setState = setState
+    const [view, setView] = createSignal<FooterView>({ type: "prompt" })
+    this.view = view
+    this.setView = setView
+    const [agents, setAgents] = createSignal(options.agents)
+    this.agents = agents
+    this.setAgents = setAgents
+    const [resources, setResources] = createSignal(options.resources)
+    this.resources = resources
+    this.setResources = setResources
+    const [subagent, setSubagent] = createStore<FooterSubagentState>(createEmptySubagentState())
+    this.subagent = () => subagent
+    this.setSubagent = (next) => {
+      setSubagent("tabs", reconcile(next.tabs, { key: "sessionID" }))
+      setSubagent("details", reconcile(next.details))
+      setSubagent("permissions", reconcile(next.permissions, { key: "id" }))
+      setSubagent("questions", reconcile(next.questions, { key: "id" }))
+    }
+    this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
+    this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
+    this.scrollback = new RunScrollbackStream(renderer, options.theme, {
+      diffStyle: options.diffStyle,
+      wrote: options.wrote,
+      sessionID: options.sessionID,
+      treeSitterClient: options.treeSitterClient,
+    })
+
+    this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
+
+    void render(
+      () =>
+        createComponent(RunFooterView, {
+          directory: options.directory,
+          state: this.state,
+          view: this.view,
+          subagent: this.subagent,
+          findFiles: options.findFiles,
+          agents: this.agents,
+          resources: this.resources,
+          theme: options.theme,
+          diffStyle: options.diffStyle,
+          keybinds: options.keybinds,
+          history: options.history,
+          agent: options.agentLabel,
+          onSubmit: this.handlePrompt,
+          onPermissionReply: this.handlePermissionReply,
+          onQuestionReply: this.handleQuestionReply,
+          onQuestionReject: this.handleQuestionReject,
+          onCycle: this.handleCycle,
+          onInterrupt: this.handleInterrupt,
+          onExitRequest: this.handleExit,
+          onExit: () => this.close(),
+          onRows: this.syncRows,
+          onLayout: this.syncLayout,
+          onStatus: this.setStatus,
+          onSubagentSelect: options.onSubagentSelect,
+        }),
+      this.renderer,
+    ).catch(() => {
+      if (!this.isGone) {
+        this.close()
+      }
+    })
+  }
+
+  public get isClosed(): boolean {
+    return this.closed || this.isGone
+  }
+
+  private get isGone(): boolean {
+    return this.destroyed || this.renderer.isDestroyed
+  }
+
+  public onPrompt(fn: (input: RunPrompt) => void): () => void {
+    this.prompts.add(fn)
+    return () => {
+      this.prompts.delete(fn)
+    }
+  }
+
+  public onClose(fn: () => void): () => void {
+    if (this.isClosed) {
+      fn()
+      return () => {}
+    }
+
+    this.closes.add(fn)
+    return () => {
+      this.closes.delete(fn)
+    }
+  }
+
+  public event(next: FooterEvent): void {
+    if (next.type === "catalog") {
+      if (this.isGone) {
+        return
+      }
+
+      this.setAgents(next.agents)
+      this.setResources(next.resources)
+      return
+    }
+
+    const patch = eventPatch(next)
+    if (patch) {
+      this.patch(patch)
+      return
+    }
+
+    if (next.type === "stream.subagent") {
+      if (this.isGone) {
+        return
+      }
+
+      this.setSubagent(next.state)
+      this.applyHeight()
+      return
+    }
+
+    if (next.type === "stream.view") {
+      this.present(next.view)
+    }
+  }
+
+  private patch(next: FooterPatch): void {
+    if (this.isGone) {
+      return
+    }
+
+    const prev = this.state()
+    const state = {
+      phase: next.phase ?? prev.phase,
+      status: typeof next.status === "string" ? next.status : prev.status,
+      queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
+      model: typeof next.model === "string" ? next.model : prev.model,
+      duration: typeof next.duration === "string" ? next.duration : prev.duration,
+      usage: typeof next.usage === "string" ? next.usage : prev.usage,
+      first: typeof next.first === "boolean" ? next.first : prev.first,
+      interrupt:
+        typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
+          ? Math.max(0, Math.floor(next.interrupt))
+          : prev.interrupt,
+      exit:
+        typeof next.exit === "number" && Number.isFinite(next.exit) ? Math.max(0, Math.floor(next.exit)) : prev.exit,
+    }
+
+    if (state.phase === "idle") {
+      state.interrupt = 0
+    }
+
+    this.setState(state)
+
+    if (prev.phase === "running" && state.phase === "idle") {
+      this.flush()
+      this.completeScrollback()
+    }
+  }
+
+  private completeScrollback(): void {
+    const phase = this.state().phase
+    this.flushing = this.flushing
+      .then(() =>
+        withRunSpan(
+          "RunFooter.completeScrollback",
+          {
+            "opencode.footer.phase": phase,
+            "session.id": this.options.sessionID() || undefined,
+          },
+          async () => {
+            await this.scrollback.complete()
+          },
+        ),
+      )
+      .catch(() => {})
+  }
+
+  private present(view: FooterView): void {
+    if (this.isGone) {
+      return
+    }
+
+    this.setView(view)
+    this.applyHeight()
+  }
+
+  // Queues a scrollback commit. Consecutive progress chunks for the same
+  // part coalesce by appending text, reducing the number of retained-surface
+  // updates. Actual flush happens on the next microtask, so a burst of events
+  // from one reducer pass becomes a single ordered drain.
+  public append(commit: StreamCommit): void {
+    if (this.isGone) {
+      return
+    }
+
+    const last = this.queue.at(-1)
+    if (
+      last &&
+      last.phase === "progress" &&
+      commit.phase === "progress" &&
+      last.kind === commit.kind &&
+      last.source === commit.source &&
+      last.partID === commit.partID &&
+      last.tool === commit.tool
+    ) {
+      last.text += commit.text
+    } else {
+      this.queue.push(commit)
+    }
+
+    if (this.pending) {
+      return
+    }
+
+    this.pending = true
+    queueMicrotask(() => {
+      this.pending = false
+      this.flush()
+    })
+  }
+
+  public idle(): Promise<void> {
+    if (this.isGone) {
+      return Promise.resolve()
+    }
+
+    this.flush()
+    if (this.state().phase === "idle") {
+      this.completeScrollback()
+    }
+
+    return this.flushing.then(async () => {
+      if (this.isGone) {
+        return
+      }
+
+      if (this.queue.length > 0) {
+        return this.idle()
+      }
+
+      await this.renderer.idle().catch(() => {})
+    })
+  }
+
+  public close(): void {
+    if (this.closed) {
+      return
+    }
+
+    this.flush()
+    this.notifyClose()
+  }
+
+  public requestExit(): boolean {
+    return this.handleExit()
+  }
+
+  public destroy(): void {
+    this.handleDestroy()
+  }
+
+  private notifyClose(): void {
+    if (this.closed) {
+      return
+    }
+
+    this.closed = true
+    for (const fn of [...this.closes]) {
+      fn()
+    }
+  }
+
+  private setStatus = (status: string): void => {
+    this.patch({ status })
+  }
+
+  // Resizes the footer to fit the current view. Permission and question views
+  // get fixed extra rows; the prompt view scales with textarea line count.
+  private applyHeight(): void {
+    const type = this.view().type
+    const tabs = this.tabsVisible ? SUBAGENT_TAB_ROWS : 0
+    const height =
+      type === "permission"
+        ? this.base + PERMISSION_ROWS
+        : type === "question"
+          ? this.base + QUESTION_ROWS
+          : this.promptRoute.type === "subagent"
+            ? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
+            : Math.max(
+                this.base + TEXTAREA_MIN_ROWS,
+                Math.min(this.base + tabs + PROMPT_MAX_ROWS, this.base + tabs + this.rows),
+              )
+
+    if (height !== this.renderer.footerHeight) {
+      this.renderer.footerHeight = height
+    }
+  }
+
+  private syncRows = (value: number): void => {
+    if (this.isGone) {
+      return
+    }
+
+    const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
+    if (rows === this.rows) {
+      return
+    }
+
+    this.rows = rows
+    if (this.view().type === "prompt") {
+      this.applyHeight()
+    }
+  }
+
+  private syncLayout = (next: { route: FooterPromptRoute; tabs: boolean }): void => {
+    this.promptRoute = next.route
+    this.tabsVisible = next.tabs
+    if (this.view().type === "prompt") {
+      this.applyHeight()
+    }
+  }
+
+  private handlePrompt = (input: RunPrompt): boolean => {
+    if (this.isClosed) {
+      return false
+    }
+
+    if (this.state().first) {
+      this.patch({ first: false })
+    }
+
+    if (this.prompts.size === 0) {
+      this.patch({ status: "input queue unavailable" })
+      return false
+    }
+
+    for (const fn of [...this.prompts]) {
+      fn(input)
+    }
+
+    return true
+  }
+
+  private handlePermissionReply = async (input: PermissionReply): Promise<void> => {
+    if (this.isClosed) {
+      return
+    }
+
+    await this.options.onPermissionReply(input)
+  }
+
+  private handleQuestionReply = async (input: QuestionReply): Promise<void> => {
+    if (this.isClosed) {
+      return
+    }
+
+    await this.options.onQuestionReply(input)
+  }
+
+  private handleQuestionReject = async (input: QuestionReject): Promise<void> => {
+    if (this.isClosed) {
+      return
+    }
+
+    await this.options.onQuestionReject(input)
+  }
+
+  private handleCycle = (): void => {
+    const result = this.options.onCycleVariant?.()
+    if (!result) {
+      this.patch({ status: "no variants available" })
+      return
+    }
+
+    const patch: FooterPatch = {
+      status: result.status ?? "variant updated",
+    }
+
+    if (result.modelLabel) {
+      patch.model = result.modelLabel
+    }
+
+    this.patch(patch)
+  }
+
+  private clearInterruptTimer(): void {
+    if (!this.interruptTimeout) {
+      return
+    }
+
+    clearTimeout(this.interruptTimeout)
+    this.interruptTimeout = undefined
+  }
+
+  private armInterruptTimer(): void {
+    this.clearInterruptTimer()
+    this.interruptTimeout = setTimeout(() => {
+      this.interruptTimeout = undefined
+      if (this.isGone || this.state().phase !== "running") {
+        return
+      }
+
+      this.patch({ interrupt: 0 })
+    }, 5000)
+  }
+
+  private clearExitTimer(): void {
+    if (!this.exitTimeout) {
+      return
+    }
+
+    clearTimeout(this.exitTimeout)
+    this.exitTimeout = undefined
+  }
+
+  private armExitTimer(): void {
+    this.clearExitTimer()
+    this.exitTimeout = setTimeout(() => {
+      this.exitTimeout = undefined
+      if (this.isGone || this.isClosed) {
+        return
+      }
+
+      this.patch({ exit: 0 })
+    }, 5000)
+  }
+
+  // Two-press interrupt: first press shows a hint ("esc again to interrupt"),
+  // second press within 5 seconds fires onInterrupt. The timer resets the
+  // counter if the user doesn't follow through.
+  private handleInterrupt = (): boolean => {
+    if (this.isClosed || this.state().phase !== "running") {
+      return false
+    }
+
+    const next = this.state().interrupt + 1
+    this.patch({ interrupt: next })
+
+    if (next < 2) {
+      this.armInterruptTimer()
+      this.patch({ status: `${this.interruptHint} again to interrupt` })
+      return true
+    }
+
+    this.clearInterruptTimer()
+    this.patch({ interrupt: 0, status: "interrupting" })
+    this.options.onInterrupt?.()
+    return true
+  }
+
+  private handleExit = (): boolean => {
+    if (this.isClosed) {
+      return true
+    }
+
+    this.clearInterruptTimer()
+    const next = this.state().exit + 1
+    this.patch({ exit: next, interrupt: 0 })
+
+    if (next < 2) {
+      this.armExitTimer()
+      this.patch({ status: "Press Ctrl-c again to exit" })
+      return true
+    }
+
+    this.clearExitTimer()
+    this.patch({ exit: 0, status: "exiting" })
+    this.close()
+    this.options.onExit?.()
+    return true
+  }
+
+  private handleDestroy = (): void => {
+    if (this.destroyed) {
+      return
+    }
+
+    this.flush()
+    this.destroyed = true
+    this.notifyClose()
+    this.clearInterruptTimer()
+    this.clearExitTimer()
+    this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
+    this.prompts.clear()
+    this.closes.clear()
+    this.scrollback.destroy()
+  }
+
+  // Drains the commit queue to scrollback. The surface manager owns grouping,
+  // spacing, and progressive markdown/code settling so direct mode can append
+  // immutable transcript rows without rewriting history.
+  private flush(): void {
+    if (this.isGone || this.queue.length === 0) {
+      this.queue.length = 0
+      return
+    }
+
+    const batch = this.queue.splice(0)
+    const phase = this.state().phase
+    this.flushing = this.flushing
+      .then(() =>
+        withRunSpan(
+          "RunFooter.flush",
+          {
+            "opencode.batch.commits": batch.length,
+            "opencode.footer.phase": phase,
+            "session.id": this.options.sessionID() || undefined,
+          },
+          async () => {
+            for (const item of batch) {
+              await this.scrollback.append(item)
+            }
+          },
+        ),
+      )
+      .catch(() => {})
+  }
+}

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

@@ -0,0 +1,516 @@
+// Top-level footer layout for direct interactive mode.
+//
+// Renders the footer region as a vertical stack:
+//   1. Spacer row (visual separation from scrollback)
+//   2. Composer frame with left-border accent -- swaps between prompt,
+//      permission, and question bodies via Switch/Match
+//   3. Meta row showing agent name and model label
+//   4. Bottom border + status row (spinner, interrupt hint, duration, usage)
+//
+// All state comes from the parent RunFooter through SolidJS signals.
+// The view itself is stateless except for derived memos.
+/** @jsxImportSource @opentui/solid */
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
+import "opentui-spinner/solid"
+import { createColors, createFrames } from "../tui/ui/spinner"
+import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
+import { RunPromptAutocomplete, RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
+import { RunPermissionBody } from "./footer.permission"
+import { RunQuestionBody } from "./footer.question"
+import { printableBinding } from "./prompt.shared"
+import type {
+  FooterKeybinds,
+  FooterPromptRoute,
+  RunAgent,
+  RunPrompt,
+  RunResource,
+  FooterState,
+  FooterSubagentState,
+  FooterView,
+  PermissionReply,
+  QuestionReject,
+  QuestionReply,
+  RunDiffStyle,
+} from "./types"
+import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
+
+const EMPTY_BORDER = {
+  topLeft: "",
+  bottomLeft: "",
+  vertical: "",
+  topRight: "",
+  bottomRight: "",
+  horizontal: " ",
+  bottomT: "",
+  topT: "",
+  cross: "",
+  leftT: "",
+  rightT: "",
+}
+
+type RunFooterViewProps = {
+  directory: string
+  findFiles: (query: string) => Promise<string[]>
+  agents: () => RunAgent[]
+  resources: () => RunResource[]
+  state: () => FooterState
+  view?: () => FooterView
+  subagent?: () => FooterSubagentState
+  theme?: RunTheme
+  diffStyle?: RunDiffStyle
+  keybinds: FooterKeybinds
+  history?: RunPrompt[]
+  agent: string
+  onSubmit: (input: RunPrompt) => boolean
+  onPermissionReply: (input: PermissionReply) => void | Promise<void>
+  onQuestionReply: (input: QuestionReply) => void | Promise<void>
+  onQuestionReject: (input: QuestionReject) => void | Promise<void>
+  onCycle: () => void
+  onInterrupt: () => boolean
+  onExitRequest?: () => boolean
+  onExit: () => void
+  onRows: (rows: number) => void
+  onLayout: (input: { route: FooterPromptRoute; tabs: boolean }) => void
+  onStatus: (text: string) => void
+  onSubagentSelect?: (sessionID: string | undefined) => void
+}
+
+function subagentShortcut(event: {
+  name: string
+  ctrl?: boolean
+  meta?: boolean
+  shift?: boolean
+  super?: boolean
+}): number | undefined {
+  if (!event.ctrl || event.meta || event.super) {
+    return undefined
+  }
+
+  if (!/^[0-9]$/.test(event.name)) {
+    return undefined
+  }
+
+  const slot = Number(event.name)
+  return slot === 0 ? 9 : slot - 1
+}
+
+export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
+
+export function RunFooterView(props: RunFooterViewProps) {
+  const term = useTerminalDimensions()
+  const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
+  const subagent = createMemo<FooterSubagentState>(() => {
+    return (
+      props.subagent?.() ?? {
+        tabs: [],
+        details: {},
+        permissions: [],
+        questions: [],
+      }
+    )
+  })
+  const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
+  const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
+  const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
+  const selected = createMemo(() => {
+    const current = route()
+    return current.type === "subagent" ? current.sessionID : undefined
+  })
+  const tabs = createMemo(() => subagent().tabs)
+  const showTabs = createMemo(() => active().type === "prompt" && tabs().length > 0)
+  const detail = createMemo(() => {
+    const current = route()
+    return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
+  })
+  const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
+  const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
+  const hints = createMemo(() => hintFlags(term().width))
+  const busy = createMemo(() => props.state().phase === "running")
+  const armed = createMemo(() => props.state().interrupt > 0)
+  const exiting = createMemo(() => props.state().exit > 0)
+  const queue = createMemo(() => props.state().queue)
+  const duration = createMemo(() => props.state().duration)
+  const usage = createMemo(() => props.state().usage)
+  const interruptKey = createMemo(() => interrupt() || "/exit")
+  const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
+  const theme = createMemo(() => runTheme().footer)
+  const block = createMemo(() => runTheme().block)
+  const spin = createMemo(() => {
+    return {
+      frames: createFrames({
+        color: theme().highlight,
+        style: "blocks",
+        inactiveFactor: 0.6,
+        minAlpha: 0.3,
+      }),
+      color: createColors({
+        color: theme().highlight,
+        style: "blocks",
+        inactiveFactor: 0.6,
+        minAlpha: 0.3,
+      }),
+    }
+  })
+  const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
+    const view = active()
+    return view.type === "permission" ? view : undefined
+  })
+  const question = createMemo<Extract<FooterView, { type: "question" }> | undefined>(() => {
+    const view = active()
+    return view.type === "question" ? view : undefined
+  })
+  const promptView = createMemo(() => {
+    if (active().type !== "prompt") {
+      return active().type
+    }
+
+    return route().type === "composer" ? "prompt" : "subagent"
+  })
+
+  const openTab = (sessionID: string) => {
+    setRoute({ type: "subagent", sessionID })
+    props.onSubagentSelect?.(sessionID)
+  }
+
+  const closeTab = () => {
+    setRoute({ type: "composer" })
+    props.onSubagentSelect?.(undefined)
+  }
+
+  const toggleTab = (sessionID: string) => {
+    const current = route()
+    if (current.type === "subagent" && current.sessionID === sessionID) {
+      closeTab()
+      return
+    }
+
+    openTab(sessionID)
+  }
+
+  const cycleTab = (dir: -1 | 1) => {
+    if (tabs().length === 0) {
+      return
+    }
+
+    const routeState = route()
+    const current =
+      routeState.type === "subagent" ? tabs().findIndex((item) => item.sessionID === routeState.sessionID) : -1
+    const index = current === -1 ? 0 : (current + dir + tabs().length) % tabs().length
+    const next = tabs()[index]
+    if (!next) {
+      return
+    }
+
+    openTab(next.sessionID)
+  }
+  const composer = createPromptState({
+    directory: props.directory,
+    findFiles: props.findFiles,
+    agents: props.agents,
+    resources: props.resources,
+    keybinds: props.keybinds,
+    state: props.state,
+    view: promptView,
+    prompt,
+    width: () => term().width,
+    theme,
+    history: props.history,
+    onSubmit: props.onSubmit,
+    onCycle: props.onCycle,
+    onInterrupt: props.onInterrupt,
+    onExitRequest: props.onExitRequest,
+    onExit: props.onExit,
+    onRows: props.onRows,
+    onStatus: props.onStatus,
+  })
+  const menu = createMemo(() => prompt() && composer.visible())
+
+  useKeyboard((event) => {
+    if (active().type !== "prompt") {
+      return
+    }
+
+    const slot = subagentShortcut(event)
+    if (slot !== undefined) {
+      const next = tabs()[slot]
+      if (!next) {
+        return
+      }
+
+      event.preventDefault()
+      toggleTab(next.sessionID)
+    }
+  })
+
+  createEffect(() => {
+    const current = route()
+    if (current.type === "composer") {
+      return
+    }
+
+    if (tabs().some((item) => item.sessionID === current.sessionID)) {
+      return
+    }
+
+    closeTab()
+  })
+
+  createEffect(() => {
+    props.onLayout({
+      route: route(),
+      tabs: tabs().length > 0,
+    })
+  })
+
+  return (
+    <box
+      id="run-direct-footer-shell"
+      width="100%"
+      height="100%"
+      border={false}
+      backgroundColor="transparent"
+      flexDirection="column"
+      gap={0}
+      padding={0}
+    >
+      <box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
+
+      <Show when={showTabs()}>
+        <RunFooterSubagentTabs tabs={tabs()} selected={selected()} theme={theme()} width={term().width} />
+      </Show>
+
+      <Show
+        when={inspecting()}
+        fallback={
+          <box width="100%" flexDirection="column" gap={0}>
+            <box
+              id="run-direct-footer-composer-frame"
+              width="100%"
+              flexShrink={0}
+              border={["left"]}
+              borderColor={theme().highlight}
+              customBorderChars={{
+                ...EMPTY_BORDER,
+                vertical: "┃",
+                bottomLeft: "╹",
+              }}
+            >
+              <box
+                id="run-direct-footer-composer-area"
+                width="100%"
+                flexGrow={1}
+                paddingLeft={0}
+                paddingRight={0}
+                paddingTop={0}
+                flexDirection="column"
+                backgroundColor={theme().surface}
+                gap={0}
+              >
+                <box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
+                  <Switch>
+                    <Match when={active().type === "prompt" && route().type === "composer"}>
+                      <RunPromptBody
+                        theme={theme}
+                        placeholder={composer.placeholder}
+                        bindings={composer.bindings}
+                        onSubmit={composer.onSubmit}
+                        onKeyDown={composer.onKeyDown}
+                        onContentChange={composer.onContentChange}
+                        bind={composer.bind}
+                      />
+                    </Match>
+                    <Match when={active().type === "permission"}>
+                      <RunPermissionBody
+                        request={permission()!.request}
+                        theme={theme()}
+                        block={block()}
+                        diffStyle={props.diffStyle}
+                        onReply={props.onPermissionReply}
+                      />
+                    </Match>
+                    <Match when={active().type === "question"}>
+                      <RunQuestionBody
+                        request={question()!.request}
+                        theme={theme()}
+                        onReply={props.onQuestionReply}
+                        onReject={props.onQuestionReject}
+                      />
+                    </Match>
+                  </Switch>
+                </box>
+
+                <box
+                  id="run-direct-footer-meta-row"
+                  width="100%"
+                  flexDirection="row"
+                  gap={1}
+                  paddingLeft={2}
+                  flexShrink={0}
+                  paddingTop={1}
+                >
+                  <text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
+                    {props.agent}
+                  </text>
+                  <text
+                    id="run-direct-footer-model"
+                    fg={theme().text}
+                    wrapMode="none"
+                    truncate
+                    flexGrow={1}
+                    flexShrink={1}
+                  >
+                    {props.state().model}
+                  </text>
+                </box>
+              </box>
+            </box>
+
+            <box
+              id="run-direct-footer-line-6"
+              width="100%"
+              height={1}
+              border={["left"]}
+              borderColor={theme().highlight}
+              backgroundColor="transparent"
+              customBorderChars={{
+                ...EMPTY_BORDER,
+                vertical: "╹",
+              }}
+              flexShrink={0}
+            >
+              <box
+                id="run-direct-footer-line-6-fill"
+                width="100%"
+                height={1}
+                border={["bottom"]}
+                borderColor={theme().surface}
+                backgroundColor={menu() ? theme().shade : "transparent"}
+                customBorderChars={{
+                  ...EMPTY_BORDER,
+                  horizontal: "▀",
+                }}
+              />
+            </box>
+
+            <Show
+              when={menu()}
+              fallback={
+                <box
+                  id="run-direct-footer-row"
+                  width="100%"
+                  height={1}
+                  flexDirection="row"
+                  justifyContent="space-between"
+                  gap={1}
+                  flexShrink={0}
+                >
+                  <Show when={busy() || exiting()}>
+                    <box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
+                      <Show when={exiting()}>
+                        <text
+                          id="run-direct-footer-hint-exit"
+                          fg={theme().highlight}
+                          wrapMode="none"
+                          truncate
+                          marginLeft={1}
+                        >
+                          Press Ctrl-c again to exit
+                        </text>
+                      </Show>
+
+                      <Show when={busy() && !exiting()}>
+                        <box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
+                          <spinner color={spin().color} frames={spin().frames} interval={40} />
+                        </box>
+
+                        <text
+                          id="run-direct-footer-hint-interrupt"
+                          fg={armed() ? theme().highlight : theme().text}
+                          wrapMode="none"
+                          truncate
+                        >
+                          {interruptKey()}{" "}
+                          <span style={{ fg: armed() ? theme().highlight : theme().muted }}>
+                            {armed() ? "again to interrupt" : "interrupt"}
+                          </span>
+                        </text>
+                      </Show>
+                    </box>
+                  </Show>
+
+                  <Show when={!busy() && !exiting() && duration().length > 0}>
+                    <box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
+                      <text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
+                        ▣
+                      </text>
+                      <box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
+                        <text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
+                          ·
+                        </text>
+                        <text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
+                          {duration()}
+                        </text>
+                      </box>
+                    </box>
+                  </Show>
+
+                  <box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
+
+                  <box
+                    id="run-direct-footer-hint-group"
+                    flexDirection="row"
+                    gap={2}
+                    flexShrink={0}
+                    justifyContent="flex-end"
+                  >
+                    <Show when={queue() > 0}>
+                      <text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
+                        {queue()} queued
+                      </text>
+                    </Show>
+                    <Show when={usage().length > 0}>
+                      <text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
+                        {usage()}
+                      </text>
+                    </Show>
+                    <Show when={variant().length > 0 && hints().variant}>
+                      <text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
+                        {variant()} variant
+                      </text>
+                    </Show>
+                  </box>
+                </box>
+              }
+            >
+              <RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
+            </Show>
+          </box>
+        }
+      >
+        <box
+          id="run-direct-footer-subagent-frame"
+          width="100%"
+          flexGrow={1}
+          flexShrink={1}
+          border={["left"]}
+          borderColor={theme().highlight}
+          customBorderChars={{
+            ...EMPTY_BORDER,
+            vertical: "┃",
+          }}
+        >
+          <RunFooterSubagentBody
+            active={inspecting}
+            theme={runTheme}
+            detail={detail}
+            width={() => term().width}
+            diffStyle={props.diffStyle}
+            onCycle={cycleTab}
+            onClose={closeTab}
+          />
+        </box>
+      </Show>
+    </box>
+  )
+}

+ 119 - 0
packages/opencode/src/cli/cmd/run/otel.ts

@@ -0,0 +1,119 @@
+import { INVALID_SPAN_CONTEXT, context, trace, SpanStatusCode, type Span } from "@opentelemetry/api"
+import { Effect, ManagedRuntime } from "effect"
+import { memoMap } from "@/effect/memo-map"
+import { Observability } from "@/effect/observability"
+
+type AttributeValue = string | number | boolean | undefined
+
+export type RunSpanAttributes = Record<string, AttributeValue>
+
+const noop = trace.wrapSpanContext(INVALID_SPAN_CONTEXT)
+const tracer = trace.getTracer("opencode.run")
+const runtime = ManagedRuntime.make(Observability.layer, { memoMap })
+let ready: Promise<void> | undefined
+
+function attributes(input?: RunSpanAttributes): Record<string, string | number | boolean> | undefined {
+  if (!input) {
+    return undefined
+  }
+
+  const out = Object.entries(input).flatMap(([key, value]) => (value === undefined ? [] : [[key, value] as const]))
+  if (out.length === 0) {
+    return undefined
+  }
+
+  return Object.fromEntries(out)
+}
+
+function message(error: unknown) {
+  if (typeof error === "string") {
+    return error
+  }
+
+  if (error instanceof Error) {
+    return error.message || error.name
+  }
+
+  return String(error)
+}
+
+function ensure() {
+  if (!Observability.enabled) {
+    return Promise.resolve()
+  }
+
+  if (ready) {
+    return ready
+  }
+
+  ready = runtime.runPromise(Effect.void).then(
+    () => undefined,
+    (error) => {
+      ready = undefined
+      throw error
+    },
+  )
+  return ready
+}
+
+function finish<A>(span: Span, out: Promise<A>) {
+  return out.then(
+    (value) => {
+      span.end()
+      return value
+    },
+    (error) => {
+      recordRunSpanError(span, error)
+      span.end()
+      throw error
+    },
+  )
+}
+
+export function setRunSpanAttributes(span: Span, input?: RunSpanAttributes): void {
+  const next = attributes(input)
+  if (!next) {
+    return
+  }
+
+  span.setAttributes(next)
+}
+
+export function recordRunSpanError(span: Span, error: unknown): void {
+  const next = message(error)
+  span.recordException(error instanceof Error ? error : next)
+  span.setStatus({
+    code: SpanStatusCode.ERROR,
+    message: next,
+  })
+}
+
+export function withRunSpan<A>(
+  name: string,
+  input: RunSpanAttributes | undefined,
+  fn: (span: Span) => Promise<A> | A,
+): A | Promise<A> {
+  if (!Observability.enabled) {
+    return fn(noop)
+  }
+
+  return ensure().then(
+    () => {
+      const span = tracer.startSpan(name, {
+        attributes: attributes(input),
+      })
+
+      return context.with(
+        trace.setSpan(context.active(), span),
+        () =>
+          finish(
+            span,
+            new Promise<A>((resolve) => {
+              resolve(fn(span))
+            }),
+          ),
+      )
+    },
+    () => fn(noop),
+  )
+}

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

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

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

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

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

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

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

@@ -0,0 +1,202 @@
+// Boot-time resolution for direct interactive mode.
+//
+// These functions run concurrently at startup to gather everything the runtime
+// needs before the first frame: keybinds from TUI config, diff display style,
+// model variant list with context limits, and session history for the prompt
+// history ring. All are async because they read config or hit the SDK, but
+// none block each other.
+import { Context, Effect, Layer } from "effect"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
+import { makeRuntime } from "@/effect/run-service"
+import { reusePendingTask } from "./runtime.shared"
+import { resolveSession, sessionHistory } from "./session.shared"
+import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt } from "./types"
+import { pickVariant } from "./variant.shared"
+
+const DEFAULT_KEYBINDS: FooterKeybinds = {
+  leader: "ctrl+x",
+  variantCycle: "ctrl+t,<leader>t",
+  interrupt: "escape",
+  historyPrevious: "up",
+  historyNext: "down",
+  inputSubmit: "return",
+  inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
+}
+
+export type ModelInfo = {
+  variants: string[]
+  limits: Record<string, number>
+}
+
+export type SessionInfo = {
+  first: boolean
+  history: RunPrompt[]
+  variant: string | undefined
+}
+
+type Config = Awaited<ReturnType<typeof TuiConfig.get>>
+type BootService = {
+  readonly resolveModelInfo: (sdk: RunInput["sdk"], model: RunInput["model"]) => Effect.Effect<ModelInfo>
+  readonly resolveSessionInfo: (
+    sdk: RunInput["sdk"],
+    sessionID: string,
+    model: RunInput["model"],
+  ) => Effect.Effect<SessionInfo>
+  readonly resolveFooterKeybinds: () => Effect.Effect<FooterKeybinds>
+  readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
+}
+
+const configTask: { current?: Promise<Config> } = {}
+
+class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
+
+function loadConfig() {
+  return reusePendingTask(configTask, () => TuiConfig.get())
+}
+
+function emptyModelInfo(): ModelInfo {
+  return {
+    variants: [],
+    limits: {},
+  }
+}
+
+function emptySessionInfo(): SessionInfo {
+  return {
+    first: true,
+    history: [],
+    variant: undefined,
+  }
+}
+
+function footerKeybinds(config: Config | undefined): FooterKeybinds {
+  const leader = config?.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader
+  const cycle = config?.keybinds?.variant_cycle?.trim() || "ctrl+t"
+  const interrupt = config?.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt
+  const previous = config?.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious
+  const next = config?.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext
+  const submit = config?.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit
+  const newline = config?.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline
+
+  const bindings = cycle
+    .split(",")
+    .map((item) => item.trim())
+    .filter((item) => item.length > 0)
+
+  if (!bindings.some((binding) => binding.toLowerCase() === "<leader>t")) {
+    bindings.push("<leader>t")
+  }
+
+  return {
+    leader,
+    variantCycle: bindings.join(","),
+    interrupt,
+    historyPrevious: previous,
+    historyNext: next,
+    inputSubmit: submit,
+    inputNewline: newline,
+  }
+}
+
+const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const config = Effect.fn("RunBoot.config")(() =>
+      Effect.promise(loadConfig).pipe(
+        Effect.orElseSucceed(() => undefined),
+      ),
+    )
+
+    const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (sdk: RunInput["sdk"], model: RunInput["model"]) {
+      const providers = yield* Effect.promise(() => sdk.provider.list()).pipe(
+        Effect.map((item) => item.data?.all ?? []),
+        Effect.orElseSucceed(() => []),
+      )
+      const limits = Object.fromEntries(
+        providers.flatMap((provider) =>
+          Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
+            const limit = info?.limit?.context
+            if (typeof limit !== "number" || limit <= 0) {
+              return []
+            }
+
+            return [[`${provider.id}/${modelID}`, limit] as const]
+          }),
+        ),
+      )
+
+      if (!model) {
+        return {
+          variants: [],
+          limits,
+        }
+      }
+
+      const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]
+      return {
+        variants: Object.keys(info?.variants ?? {}),
+        limits,
+      }
+    })
+
+    const resolveSessionInfo = Effect.fn("RunBoot.resolveSessionInfo")(function* (
+      sdk: RunInput["sdk"],
+      sessionID: string,
+      model: RunInput["model"],
+    ) {
+      const session = yield* Effect.promise(() => resolveSession(sdk, sessionID)).pipe(
+        Effect.orElseSucceed(() => undefined),
+      )
+      if (!session) {
+        return emptySessionInfo()
+      }
+
+      return {
+        first: session.first,
+        history: sessionHistory(session),
+        variant: pickVariant(model, session),
+      }
+    })
+
+    const resolveFooterKeybinds = Effect.fn("RunBoot.resolveFooterKeybinds")(function* () {
+      return footerKeybinds(yield* config())
+    })
+
+    const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
+      return (yield* config())?.diff_style ?? "auto"
+    })
+
+    return Service.of({
+      resolveModelInfo,
+      resolveSessionInfo,
+      resolveFooterKeybinds,
+      resolveDiffStyle,
+    })
+  }),
+)
+
+const runtime = makeRuntime(Service, layer)
+
+// Fetches available variants and context limits for every provider/model pair.
+export async function resolveModelInfo(sdk: RunInput["sdk"], model: RunInput["model"]): Promise<ModelInfo> {
+  return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, model)).catch(() => emptyModelInfo())
+}
+
+// Fetches session messages to determine if this is the first turn and build prompt history.
+export async function resolveSessionInfo(
+  sdk: RunInput["sdk"],
+  sessionID: string,
+  model: RunInput["model"],
+): Promise<SessionInfo> {
+  return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
+}
+
+// Reads keybind overrides from TUI config and merges them with defaults.
+// Always ensures <leader>t is present in the variant cycle binding.
+export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
+  return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS)
+}
+
+export async function resolveDiffStyle(): Promise<RunDiffStyle> {
+  return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
+}

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

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

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

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

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

@@ -0,0 +1,17 @@
+type PendingTask<T> = {
+  current?: Promise<T>
+}
+
+export function reusePendingTask<T>(slot: PendingTask<T>, run: () => Promise<T>) {
+  if (slot.current) {
+    return slot.current
+  }
+
+  const task = run().finally(() => {
+    if (slot.current === task) {
+      slot.current = undefined
+    }
+  })
+  slot.current = task
+  return task
+}

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

@@ -0,0 +1,586 @@
+// Top-level orchestrator for `run --interactive`.
+//
+// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
+// and prompt queue together into a single session loop. Two entry points:
+//
+//   runInteractiveMode     -- used when an SDK client already exists (attach mode)
+//   runInteractiveLocalMode -- used for local in-process mode (no server)
+//
+// Both delegate to runInteractiveRuntime, which:
+//   1. resolves keybinds, diff style, model info, and session history,
+//   2. creates the split-footer lifecycle (renderer + RunFooter),
+//   3. starts the stream transport (SDK event subscription), lazily for fresh
+//      local sessions,
+//   4. runs the prompt queue until the footer closes.
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { Flag } from "@/flag/flag"
+import { createRunDemo } from "./demo"
+import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
+import { createRuntimeLifecycle } from "./runtime.lifecycle"
+import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
+import { trace } from "./trace"
+import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
+import type { RunInput } from "./types"
+
+/** @internal Exported for testing */
+export { pickVariant, resolveVariant } from "./variant.shared"
+
+/** @internal Exported for testing */
+export { runPromptQueue } from "./runtime.queue"
+
+type BootContext = Pick<
+  RunInput,
+  "sdk" | "directory" | "sessionID" | "sessionTitle" | "resume" | "agent" | "model" | "variant"
+>
+
+type RunRuntimeInput = {
+  boot: () => Promise<BootContext>
+  afterPaint?: (ctx: BootContext) => Promise<void> | void
+  resolveSession?: (
+    ctx: BootContext,
+  ) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
+  files: RunInput["files"]
+  initialInput?: string
+  thinking: boolean
+  demo?: RunInput["demo"]
+  demoText?: RunInput["demoText"]
+}
+
+type RunLocalInput = {
+  directory: string
+  fetch: typeof globalThis.fetch
+  resolveAgent: () => Promise<string | undefined>
+  session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
+  share: (sdk: RunInput["sdk"], sessionID: string) => Promise<void>
+  agent: RunInput["agent"]
+  model: RunInput["model"]
+  variant: RunInput["variant"]
+  files: RunInput["files"]
+  initialInput?: string
+  thinking: boolean
+  demo?: RunInput["demo"]
+  demoText?: RunInput["demoText"]
+}
+
+type StreamState = {
+  mod: Awaited<typeof import("./stream.transport")>
+  handle: Awaited<ReturnType<Awaited<typeof import("./stream.transport")>["createSessionTransport"]>>
+}
+
+type ResolvedSession = {
+  sessionID: string
+  sessionTitle?: string
+  agent?: string | undefined
+}
+
+type RuntimeState = {
+  shown: boolean
+  aborting: boolean
+  variants: string[]
+  limits: Record<string, number>
+  activeVariant: string | undefined
+  sessionID: string
+  sessionTitle?: string
+  agent: string | undefined
+  demo?: ReturnType<typeof createRunDemo>
+  selectSubagent?: (sessionID: string | undefined) => void
+  session?: Promise<void>
+  stream?: Promise<StreamState>
+}
+
+function hasSession(input: RunRuntimeInput, state: RuntimeState) {
+  return !input.resolveSession || !!state.sessionID
+}
+
+function eagerStream(input: RunRuntimeInput, ctx: BootContext) {
+  return ctx.resume === true || !input.resolveSession || !!input.demo
+}
+
+async function resolveExitTitle(
+  ctx: BootContext,
+  input: RunRuntimeInput,
+  state: RuntimeState,
+): Promise<string | undefined> {
+  if (!state.shown || !hasSession(input, state)) {
+    return undefined
+  }
+
+  return ctx.sdk.session
+    .get({
+      sessionID: state.sessionID,
+    })
+    .then((x) => x.data?.title)
+    .catch(() => undefined)
+}
+
+// Core runtime loop. Boot resolves the SDK context, then we set up the
+// lifecycle (renderer + footer), wire the stream transport for SDK events,
+// and feed prompts through the queue until the user exits.
+//
+// Files only attach on the first prompt turn -- after that, includeFiles
+// flips to false so subsequent turns don't re-send attachments.
+async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
+  return withRunSpan(
+    "RunInteractive.session",
+    {
+      "opencode.mode": input.resolveSession ? "local" : "attach",
+      "opencode.initial_input": !!input.initialInput,
+      "opencode.demo": input.demo,
+    },
+    async (span) => {
+      const start = performance.now()
+      const log = trace()
+      const keybindTask = resolveFooterKeybinds()
+      const diffTask = resolveDiffStyle()
+      const ctx = await input.boot()
+      const modelTask = resolveModelInfo(ctx.sdk, ctx.model)
+      const sessionTask =
+        ctx.resume === true
+          ? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
+          : Promise.resolve({
+              first: true,
+              history: [],
+              variant: undefined,
+            })
+      const savedTask = resolveSavedVariant(ctx.model)
+      const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
+        keybindTask,
+        diffTask,
+        sessionTask,
+        savedTask,
+      ])
+      const state: RuntimeState = {
+        shown: !session.first,
+        aborting: false,
+        variants: [],
+        limits: {},
+        activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
+        sessionID: ctx.sessionID,
+        sessionTitle: ctx.sessionTitle,
+        agent: ctx.agent,
+      }
+      setRunSpanAttributes(span, {
+        "opencode.directory": ctx.directory,
+        "opencode.resume": ctx.resume === true,
+        "opencode.agent.name": state.agent,
+        "opencode.model.provider": ctx.model?.providerID,
+        "opencode.model.id": ctx.model?.modelID,
+        "opencode.model.variant": state.activeVariant,
+        "session.id": state.sessionID || undefined,
+      })
+      const ensureSession = () => {
+        if (!input.resolveSession || state.sessionID) {
+          return Promise.resolve()
+        }
+
+        if (state.session) {
+          return state.session
+        }
+
+        state.session = input.resolveSession(ctx).then((next) => {
+          state.sessionID = next.sessionID
+          state.sessionTitle = next.sessionTitle
+          state.agent = next.agent
+          setRunSpanAttributes(span, {
+            "opencode.agent.name": state.agent,
+            "session.id": state.sessionID,
+          })
+        })
+        return state.session
+      }
+
+      const shell = await createRuntimeLifecycle({
+        directory: ctx.directory,
+        findFiles: (query) =>
+          ctx.sdk.find
+            .files({ query, directory: ctx.directory })
+            .then((x) => x.data ?? [])
+            .catch(() => []),
+        agents: [],
+        resources: [],
+        sessionID: state.sessionID,
+        sessionTitle: state.sessionTitle,
+        getSessionID: () => state.sessionID,
+        first: session.first,
+        history: session.history,
+        agent: state.agent,
+        model: ctx.model,
+        variant: state.activeVariant,
+        keybinds,
+        diffStyle,
+        onPermissionReply: async (next) => {
+          if (state.demo?.permission(next)) {
+            return
+          }
+
+          log?.write("send.permission.reply", next)
+          await ctx.sdk.permission.reply(next)
+        },
+        onQuestionReply: async (next) => {
+          if (state.demo?.questionReply(next)) {
+            return
+          }
+
+          await ctx.sdk.question.reply(next)
+        },
+        onQuestionReject: async (next) => {
+          if (state.demo?.questionReject(next)) {
+            return
+          }
+
+          await ctx.sdk.question.reject(next)
+        },
+        onCycleVariant: () => {
+          if (!ctx.model || state.variants.length === 0) {
+            return {
+              status: "no variants available",
+            }
+          }
+
+          state.activeVariant = cycleVariant(state.activeVariant, state.variants)
+          saveVariant(ctx.model, state.activeVariant)
+          setRunSpanAttributes(span, {
+            "opencode.model.variant": state.activeVariant,
+          })
+          return {
+            status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
+            modelLabel: formatModelLabel(ctx.model, state.activeVariant),
+          }
+        },
+        onInterrupt: () => {
+          if (!hasSession(input, state) || state.aborting) {
+            return
+          }
+
+          state.aborting = true
+          void ctx.sdk.session
+            .abort({
+              sessionID: state.sessionID,
+            })
+            .catch(() => {})
+            .finally(() => {
+              state.aborting = false
+            })
+        },
+        onSubagentSelect: (sessionID) => {
+          state.selectSubagent?.(sessionID)
+          log?.write("subagent.select", {
+            sessionID,
+          })
+        },
+      })
+      const footer = shell.footer
+
+      const loadCatalog = async (): Promise<void> => {
+        if (footer.isClosed) {
+          return
+        }
+
+        const [agents, resources] = await Promise.all([
+          ctx.sdk.app
+            .agents({ directory: ctx.directory })
+            .then((x) => x.data ?? [])
+            .catch(() => []),
+          ctx.sdk.experimental.resource
+            .list({ directory: ctx.directory })
+            .then((x) => Object.values(x.data ?? {}))
+            .catch(() => []),
+        ])
+        if (footer.isClosed) {
+          return
+        }
+
+        footer.event({
+          type: "catalog",
+          agents,
+          resources,
+        })
+      }
+
+      void footer
+        .idle()
+        .then(loadCatalog)
+        .catch(() => {})
+
+      if (Flag.OPENCODE_SHOW_TTFD) {
+        footer.append({
+          kind: "system",
+          text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
+          phase: "final",
+          source: "system",
+        })
+      }
+
+      if (input.demo) {
+        await ensureSession()
+        state.demo = createRunDemo({
+          mode: input.demo,
+          text: input.demoText,
+          footer,
+          sessionID: state.sessionID,
+          thinking: input.thinking,
+          limits: () => state.limits,
+        })
+      }
+
+      if (input.afterPaint) {
+        void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
+      }
+
+      void modelTask.then((info) => {
+        state.variants = info.variants
+        state.limits = info.limits
+
+        const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
+        if (next === state.activeVariant) {
+          return
+        }
+
+        state.activeVariant = next
+        setRunSpanAttributes(span, {
+          "opencode.model.variant": state.activeVariant,
+        })
+        if (!ctx.model || footer.isClosed) {
+          return
+        }
+
+        footer.event({
+          type: "model",
+          model: formatModelLabel(ctx.model, state.activeVariant),
+        })
+      })
+
+      const streamTask = import("./stream.transport")
+      const ensureStream = () => {
+        if (state.stream) {
+          return state.stream
+        }
+
+        // Share eager prewarm and first-turn boot through one in-flight promise,
+        // but clear it if transport creation fails so a later prompt can retry.
+        const next = (async () => {
+          await ensureSession()
+          if (footer.isClosed) {
+            throw new Error("runtime closed")
+          }
+
+          const mod = await streamTask
+          if (footer.isClosed) {
+            throw new Error("runtime closed")
+          }
+
+          const handle = await mod.createSessionTransport({
+            sdk: ctx.sdk,
+            sessionID: state.sessionID,
+            thinking: input.thinking,
+            limits: () => state.limits,
+            footer,
+            trace: log,
+          })
+          if (footer.isClosed) {
+            await handle.close()
+            throw new Error("runtime closed")
+          }
+
+          state.selectSubagent = (sessionID) => handle.selectSubagent(sessionID)
+          return { mod, handle }
+        })()
+        state.stream = next
+        void next.catch(() => {
+          if (state.stream === next) {
+            state.stream = undefined
+          }
+        })
+        return next
+      }
+
+      const runQueue = async () => {
+        let includeFiles = true
+        if (state.demo) {
+          await state.demo.start()
+        }
+
+        const mod = await import("./runtime.queue")
+        await mod.runPromptQueue({
+          footer,
+          initialInput: input.initialInput,
+          trace: log,
+          onPrompt: () => {
+            state.shown = true
+          },
+          run: async (prompt, signal) => {
+            if (state.demo && (await state.demo.prompt(prompt, signal))) {
+              return
+            }
+
+            return withRunSpan(
+              "RunInteractive.turn",
+              {
+                "opencode.agent.name": state.agent,
+                "opencode.model.provider": ctx.model?.providerID,
+                "opencode.model.id": ctx.model?.modelID,
+                "opencode.model.variant": state.activeVariant,
+                "opencode.prompt.chars": prompt.text.length,
+                "opencode.prompt.parts": prompt.parts.length,
+                "opencode.prompt.include_files": includeFiles,
+                "opencode.prompt.file_parts": includeFiles ? input.files.length : 0,
+                "session.id": state.sessionID || undefined,
+              },
+              async (span) => {
+                try {
+                  const next = await ensureStream()
+                  setRunSpanAttributes(span, {
+                    "opencode.agent.name": state.agent,
+                    "opencode.model.variant": state.activeVariant,
+                    "session.id": state.sessionID || undefined,
+                  })
+                  await next.handle.runPromptTurn({
+                    agent: state.agent,
+                    model: ctx.model,
+                    variant: state.activeVariant,
+                    prompt,
+                    files: input.files,
+                    includeFiles,
+                    signal,
+                  })
+                  includeFiles = false
+                } catch (error) {
+                  if (signal.aborted || footer.isClosed) {
+                    return
+                  }
+
+                  recordRunSpanError(span, error)
+                  const text =
+                    (await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
+                    (error instanceof Error ? error.message : String(error))
+                  footer.append({ kind: "error", text, phase: "start", source: "system" })
+                }
+              },
+            )
+          },
+        })
+      }
+
+      try {
+        const eager = eagerStream(input, ctx)
+        if (eager) {
+          await ensureStream()
+        }
+
+        if (!eager && input.resolveSession) {
+          queueMicrotask(() => {
+            if (footer.isClosed) {
+              return
+            }
+
+            void ensureStream().catch(() => {})
+          })
+        }
+
+        try {
+          await runQueue()
+        } finally {
+          await state.stream?.then((item) => item.handle.close()).catch(() => {})
+        }
+      } finally {
+        const title = await resolveExitTitle(ctx, input, state)
+
+        await shell.close({
+          showExit: state.shown && hasSession(input, state),
+          sessionTitle: title,
+          sessionID: state.sessionID,
+        })
+      }
+    },
+  )
+}
+
+// Local in-process mode. Creates an SDK client backed by a direct fetch to
+// the in-process server, so no external HTTP server is needed.
+export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
+  return withRunSpan(
+    "RunInteractive.localMode",
+    {
+      "opencode.directory": input.directory,
+      "opencode.initial_input": !!input.initialInput,
+      "opencode.demo": input.demo,
+    },
+    async () => {
+      const sdk = createOpencodeClient({
+        baseUrl: "http://opencode.internal",
+        fetch: input.fetch,
+        directory: input.directory,
+      })
+      let session: Promise<ResolvedSession> | undefined
+
+      return runInteractiveRuntime({
+        files: input.files,
+        initialInput: input.initialInput,
+        thinking: input.thinking,
+        demo: input.demo,
+        demoText: input.demoText,
+        resolveSession: () => {
+          if (session) {
+            return session
+          }
+
+          session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
+            if (!next?.id) {
+              throw new Error("Session not found")
+            }
+
+            void input.share(sdk, next.id).catch(() => {})
+            return {
+              sessionID: next.id,
+              sessionTitle: next.title,
+              agent,
+            }
+          })
+          return session
+        },
+        boot: async () => {
+          return {
+            sdk,
+            directory: input.directory,
+            sessionID: "",
+            sessionTitle: undefined,
+            resume: false,
+            agent: input.agent,
+            model: input.model,
+            variant: input.variant,
+          }
+        },
+      })
+    },
+  )
+}
+
+// Attach mode. Uses the caller-provided SDK client directly.
+export async function runInteractiveMode(input: RunInput): Promise<void> {
+  return withRunSpan(
+    "RunInteractive.attachMode",
+    {
+      "opencode.directory": input.directory,
+      "opencode.initial_input": !!input.initialInput,
+      "session.id": input.sessionID,
+    },
+    async () =>
+      runInteractiveRuntime({
+        files: input.files,
+        initialInput: input.initialInput,
+        thinking: input.thinking,
+        demo: input.demo,
+        demoText: input.demoText,
+        boot: async () => ({
+          sdk: input.sdk,
+          directory: input.directory,
+          sessionID: input.sessionID,
+          sessionTitle: input.sessionTitle,
+          resume: input.resume,
+          agent: input.agent,
+          model: input.model,
+          variant: input.variant,
+        }),
+      }),
+  )
+}

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

@@ -0,0 +1,92 @@
+import { SyntaxStyle, TextAttributes, type ColorInput } from "@opentui/core"
+import { type RunEntryTheme, type RunTheme } from "./theme"
+import type { StreamCommit } from "./types"
+
+function syntax(style?: SyntaxStyle): SyntaxStyle {
+  return style ?? SyntaxStyle.fromTheme([])
+}
+
+export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
+  if (commit.kind === "reasoning") {
+    return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
+  }
+
+  return syntax(theme.block.syntax)
+}
+
+export function entryFailed(commit: StreamCommit): boolean {
+  return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
+}
+
+export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } {
+  if (commit.kind === "user") {
+    return {
+      fg: theme.user.body,
+      attrs: TextAttributes.BOLD,
+    }
+  }
+
+  if (entryFailed(commit)) {
+    return {
+      fg: theme.error.body,
+      attrs: TextAttributes.BOLD,
+    }
+  }
+
+  if (commit.phase === "final") {
+    return {
+      fg: theme.system.body,
+      attrs: TextAttributes.DIM,
+    }
+  }
+
+  if (commit.kind === "tool" && commit.phase === "start") {
+    return {
+      fg: theme.tool.start ?? theme.tool.body,
+    }
+  }
+
+  if (commit.kind === "assistant") {
+    return { fg: theme.assistant.body }
+  }
+
+  if (commit.kind === "reasoning") {
+    return {
+      fg: theme.reasoning.body,
+      attrs: TextAttributes.DIM,
+    }
+  }
+
+  if (commit.kind === "error") {
+    return {
+      fg: theme.error.body,
+      attrs: TextAttributes.BOLD,
+    }
+  }
+
+  if (commit.kind === "tool") {
+    return { fg: theme.tool.body }
+  }
+
+  return { fg: theme.system.body }
+}
+
+export function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput {
+  if (commit.kind === "assistant") {
+    return theme.entry.assistant.body
+  }
+
+  if (commit.kind === "reasoning") {
+    return theme.entry.reasoning.body
+  }
+
+  if (entryFailed(commit)) {
+    return theme.entry.error.body
+  }
+
+  if (commit.kind === "tool") {
+    return theme.block.text
+  }
+
+  return entryLook(commit, theme.entry).fg
+}

+ 370 - 0
packages/opencode/src/cli/cmd/run/scrollback.surface.ts

@@ -0,0 +1,370 @@
+// Retained streaming append logic for direct-mode scrollback.
+//
+// Static entries are rendered through `scrollback.writer.tsx`. This file only
+// keeps the retained-surface machinery needed for streaming assistant,
+// reasoning, and tool progress entries that need stable markdown/code layout
+// while content is still arriving.
+import {
+  CodeRenderable,
+  MarkdownRenderable,
+  TextRenderable,
+  getTreeSitterClient,
+  type TreeSitterClient,
+  type CliRenderer,
+  type ScrollbackSurface,
+} from "@opentui/core"
+import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
+import { withRunSpan } from "./otel"
+import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
+import { entryWriter, sameEntryGroup, separatorRows, spacerWriter } from "./scrollback.writer"
+import { type RunTheme } from "./theme"
+import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
+
+type ActiveBody = Exclude<RunEntryBody, { type: "none" | "structured" }>
+
+type ActiveEntry = {
+  body: ActiveBody
+  commit: StreamCommit
+  surface: ScrollbackSurface
+  renderable: TextRenderable | CodeRenderable | MarkdownRenderable
+  content: string
+  committedRows: number
+  committedBlocks: number
+  pendingSpacerRows: number
+  rendered: boolean
+}
+
+let nextId = 0
+
+function commitMarkdownBlocks(input: {
+  surface: ScrollbackSurface
+  renderable: MarkdownRenderable
+  startBlock: number
+  endBlockExclusive: number
+  trailingNewline: boolean
+  beforeCommit?: () => void
+}) {
+  if (input.endBlockExclusive <= input.startBlock) {
+    return false
+  }
+
+  const first = input.renderable._blockStates[input.startBlock]
+  const last = input.renderable._blockStates[input.endBlockExclusive - 1]
+  if (!first || !last) {
+    return false
+  }
+
+  const next = input.renderable._blockStates[input.endBlockExclusive]
+  const start = first.renderable.y
+  const end = next ? next.renderable.y : last.renderable.y + last.renderable.height
+
+  input.beforeCommit?.()
+  input.surface.commitRows(start, end, {
+    trailingNewline: input.trailingNewline,
+  })
+  return true
+}
+
+export class RunScrollbackStream {
+  private tail: StreamCommit | undefined
+  private rendered: StreamCommit | undefined
+  private active: ActiveEntry | undefined
+  private diffStyle: RunDiffStyle | undefined
+  private sessionID?: () => string | undefined
+  private treeSitterClient: TreeSitterClient | undefined
+  private wrote: boolean
+
+  constructor(
+    private renderer: CliRenderer,
+    private theme: RunTheme,
+    options: {
+      wrote?: boolean
+      diffStyle?: RunDiffStyle
+      sessionID?: () => string | undefined
+      treeSitterClient?: TreeSitterClient
+    } = {},
+  ) {
+    this.diffStyle = options.diffStyle
+    this.sessionID = options.sessionID
+    this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
+    this.wrote = options.wrote ?? false
+  }
+
+  private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
+    const surface = this.renderer.createScrollbackSurface({
+      startOnNewLine: entryFlags(commit).startOnNewLine,
+    })
+    const id = `run-scrollback-entry-${nextId++}`
+    const style = entryLook(commit, this.theme.entry)
+    const renderable =
+      body.type === "text"
+        ? new TextRenderable(surface.renderContext, {
+          id,
+          content: "",
+          width: "100%",
+          wrapMode: "word",
+          fg: style.fg,
+          attributes: style.attrs,
+        })
+        : body.type === "code"
+          ? new CodeRenderable(surface.renderContext, {
+            id,
+            content: "",
+            filetype: body.filetype,
+            syntaxStyle: entrySyntax(commit, this.theme),
+            width: "100%",
+            wrapMode: "word",
+            drawUnstyledText: false,
+            streaming: true,
+            fg: entryColor(commit, this.theme),
+            treeSitterClient: this.treeSitterClient,
+          })
+          : new MarkdownRenderable(surface.renderContext, {
+            id,
+            content: "",
+            syntaxStyle: entrySyntax(commit, this.theme),
+            width: "100%",
+            streaming: true,
+            internalBlockMode: "top-level",
+            tableOptions: { widthMode: "content" },
+            fg: entryColor(commit, this.theme),
+            treeSitterClient: this.treeSitterClient,
+          })
+
+    surface.root.add(renderable)
+
+    const rows = separatorRows(this.rendered, commit, body)
+
+    return {
+      body,
+      commit,
+      surface,
+      renderable,
+      content: "",
+      committedRows: 0,
+      committedBlocks: 0,
+      pendingSpacerRows: rows || (!this.rendered && this.wrote ? 1 : 0),
+      rendered: false,
+    }
+  }
+
+  private markRendered(commit: StreamCommit | undefined): void {
+    if (!commit) {
+      return
+    }
+
+    this.rendered = commit
+  }
+
+  private writeSpacer(rows: number): void {
+    if (rows === 0) {
+      return
+    }
+
+    this.renderer.writeToScrollback(spacerWriter())
+    this.wrote = false
+  }
+
+  private flushPendingSpacer(active: ActiveEntry): void {
+    this.writeSpacer(active.pendingSpacerRows)
+    active.pendingSpacerRows = 0
+  }
+
+  private async flushActive(done: boolean, trailingNewline: boolean): Promise<boolean> {
+    const active = this.active
+    if (!active) {
+      return false
+    }
+
+    if (active.body.type === "text") {
+      if (!(active.renderable instanceof TextRenderable)) {
+        return false
+      }
+
+      const renderable = active.renderable
+      renderable.content = active.content
+      active.surface.render()
+      const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
+      if (targetRows <= active.committedRows) {
+        return false
+      }
+
+      this.flushPendingSpacer(active)
+      active.surface.commitRows(active.committedRows, targetRows, {
+        trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
+      })
+      active.committedRows = targetRows
+      active.rendered = true
+      return true
+    }
+
+    if (active.body.type === "code") {
+      if (!(active.renderable instanceof CodeRenderable)) {
+        return false
+      }
+
+      const renderable = active.renderable
+      renderable.content = active.content
+      renderable.streaming = !done
+      await active.surface.settle()
+      const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
+      if (targetRows <= active.committedRows) {
+        return false
+      }
+
+      this.flushPendingSpacer(active)
+      active.surface.commitRows(active.committedRows, targetRows, {
+        trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
+      })
+      active.committedRows = targetRows
+      active.rendered = true
+      return true
+    }
+
+    if (!(active.renderable instanceof MarkdownRenderable)) {
+      return false
+    }
+
+    const renderable = active.renderable
+    renderable.content = active.content
+    renderable.streaming = !done
+    await active.surface.settle()
+    const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
+    if (targetBlockCount <= active.committedBlocks) {
+      return false
+    }
+
+    if (
+      commitMarkdownBlocks({
+        surface: active.surface,
+        renderable,
+        startBlock: active.committedBlocks,
+        endBlockExclusive: targetBlockCount,
+        trailingNewline: done && targetBlockCount === renderable._blockStates.length ? trailingNewline : false,
+        beforeCommit: () => this.flushPendingSpacer(active),
+      })
+    ) {
+      active.committedBlocks = targetBlockCount
+      active.rendered = true
+      return true
+    }
+
+    return false
+  }
+
+  private async finishActive(trailingNewline: boolean): Promise<StreamCommit | undefined> {
+    if (!this.active) {
+      return undefined
+    }
+
+    const active = this.active
+
+    try {
+      await this.flushActive(true, trailingNewline)
+    } finally {
+      if (this.active === active) {
+        this.active = undefined
+      }
+
+      if (!active.surface.isDestroyed) {
+        active.surface.destroy()
+      }
+    }
+
+    return active.rendered ? active.commit : undefined
+  }
+
+  private async writeStreaming(commit: StreamCommit, body: ActiveBody): Promise<void> {
+    if (!this.active || !sameEntryGroup(this.active.commit, commit) || this.active.body.type !== body.type) {
+      this.markRendered(await this.finishActive(false))
+      this.active = this.createEntry(commit, body)
+    }
+
+    this.active.body = body
+    this.active.commit = commit
+    this.active.content += body.content
+    await this.flushActive(false, false)
+    if (this.active.rendered) {
+      this.markRendered(this.active.commit)
+    }
+  }
+
+  public async append(commit: StreamCommit): Promise<void> {
+    const same = sameEntryGroup(this.tail, commit)
+    if (!same) {
+      this.markRendered(await this.finishActive(false))
+    }
+
+    const body = entryBody(commit)
+    if (body.type === "none") {
+      if (entryDone(commit)) {
+        this.markRendered(await this.finishActive(false))
+      }
+
+      this.tail = commit
+      return
+    }
+
+    if (
+      body.type !== "structured" &&
+      (entryCanStream(commit, body) ||
+        (commit.kind === "tool" && commit.phase === "final" && body.type === "markdown"))
+    ) {
+      await this.writeStreaming(commit, body)
+      if (entryDone(commit)) {
+        this.markRendered(await this.finishActive(false))
+      }
+      this.tail = commit
+      return
+    }
+
+    if (same) {
+      this.markRendered(await this.finishActive(false))
+    }
+
+    const rows = separatorRows(this.rendered, commit, body)
+    this.writeSpacer(rows || (!this.rendered && this.wrote ? 1 : 0))
+
+    this.renderer.writeToScrollback(
+      entryWriter({
+        commit,
+        theme: this.theme,
+        opts: {
+          diffStyle: this.diffStyle,
+        },
+      }),
+    )
+    this.markRendered(commit)
+    this.tail = commit
+  }
+
+  private resetActive(): void {
+    if (!this.active) {
+      return
+    }
+
+    if (!this.active.surface.isDestroyed) {
+      this.active.surface.destroy()
+    }
+
+    this.active = undefined
+  }
+
+  public async complete(trailingNewline = false): Promise<void> {
+    return withRunSpan(
+      "RunScrollbackStream.complete",
+      {
+        "opencode.entry.active": !!this.active,
+        "opencode.trailing_newline": trailingNewline,
+        "session.id": this.sessionID?.() || undefined,
+      },
+      async () => {
+        this.markRendered(await this.finishActive(trailingNewline))
+      },
+    )
+  }
+
+  public destroy(): void {
+    this.resetActive()
+  }
+}

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

@@ -0,0 +1,322 @@
+/** @jsxImportSource @opentui/solid */
+
+import { createScrollbackWriter } from "@opentui/solid"
+import { TextRenderable, type ScrollbackWriter } from "@opentui/core"
+import { createMemo } from "solid-js"
+import { entryBody, entryFlags } from "./entry.body"
+import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
+import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool"
+import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
+import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
+
+function todoText(item: { status: string; content: string }): string {
+  if (item.status === "completed") {
+    return `[✓] ${item.content}`
+  }
+
+  if (item.status === "cancelled") {
+    return `~[ ] ${item.content}~`
+  }
+
+  if (item.status === "in_progress") {
+    return `[•] ${item.content}`
+  }
+
+  return `[ ] ${item.content}`
+}
+
+function todoColor(theme: RunTheme, status: string) {
+  return status === "in_progress" ? theme.footer.warning : theme.block.muted
+}
+
+export function entryGroupKey(commit: StreamCommit): string | undefined {
+  if (!commit.partID) {
+    return undefined
+  }
+
+  if (toolStructuredFinal(commit)) {
+    return `tool:${commit.partID}:final`
+  }
+
+  return `${commit.kind}:${commit.partID}`
+}
+
+export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean {
+  if (!left) {
+    return false
+  }
+
+  const current = entryGroupKey(left)
+  const next = entryGroupKey(right)
+  return Boolean(current && next && current === next)
+}
+
+export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
+  if (commit.kind === "tool") {
+    if (body.type === "structured" || body.type === "markdown") {
+      return "block"
+    }
+
+    return "inline"
+  }
+
+  if (commit.kind === "reasoning") {
+    return "block"
+  }
+
+  return "block"
+}
+
+export function separatorRows(
+  prev: StreamCommit | undefined,
+  next: StreamCommit,
+  body: RunEntryBody = entryBody(next),
+): number {
+  if (!prev || sameEntryGroup(prev, next)) {
+    return 0
+  }
+
+  if (entryLayout(prev) === "inline" && entryLayout(next, body) === "inline") {
+    return 0
+  }
+
+  return 1
+}
+
+export function RunEntryContent(props: {
+  commit: StreamCommit
+  theme?: RunTheme
+  opts?: ScrollbackOptions
+  width?: number
+}) {
+  const theme = props.theme ?? RUN_THEME_FALLBACK
+  const body = createMemo(() => entryBody(props.commit))
+  const text = () => {
+    const value = body()
+    return value.type === "text" ? value : undefined
+  }
+  const code = () => {
+    const value = body()
+    return value.type === "code" ? value : undefined
+  }
+  const snapshot = () => {
+    const value = body()
+    return value.type === "structured" ? value.snapshot : undefined
+  }
+  const markdown = () => {
+    const value = body()
+    return value.type === "markdown" ? value : undefined
+  }
+
+  if (body().type === "none") {
+    return null
+  }
+
+  if (body().type === "text") {
+    const style = entryLook(props.commit, theme.entry)
+    return (
+      <text width="100%" wrapMode="word" fg={style.fg} attributes={style.attrs}>
+        {text()?.content}
+      </text>
+    )
+  }
+
+  if (body().type === "code") {
+    return (
+      <code
+        width="100%"
+        wrapMode="word"
+        filetype={code()?.filetype}
+        drawUnstyledText={false}
+        streaming={props.commit.phase === "progress"}
+        syntaxStyle={entrySyntax(props.commit, theme)}
+        content={code()?.content}
+        fg={entryColor(props.commit, theme)}
+      />
+    )
+  }
+
+  if (body().type === "structured") {
+    const snap = snapshot()
+    if (!snap) {
+      return null
+    }
+
+    const width = Math.max(1, Math.trunc(props.width ?? 80))
+
+    if (snap.kind === "code") {
+      return (
+        <box width="100%" flexDirection="column" gap={1}>
+          <text width="100%" wrapMode="word" fg={theme.block.muted}>
+            {snap.title}
+          </text>
+          <box width="100%" paddingLeft={1}>
+            <line_number width="100%" fg={theme.block.muted} minWidth={3} paddingRight={1}>
+              <code
+                width="100%"
+                wrapMode="char"
+                filetype={toolFiletype(snap.file)}
+                streaming={false}
+                syntaxStyle={entrySyntax(props.commit, theme)}
+                content={snap.content}
+                fg={theme.block.text}
+              />
+            </line_number>
+          </box>
+        </box>
+      )
+    }
+
+    if (snap.kind === "diff") {
+      const view = toolDiffView(width, props.opts?.diffStyle)
+      return (
+        <box width="100%" flexDirection="column" gap={1}>
+          {snap.items.map((item) => (
+            <box width="100%" flexDirection="column" gap={1}>
+              <text width="100%" wrapMode="word" fg={theme.block.muted}>
+                {item.title}
+              </text>
+              {item.diff.trim() ? (
+                <box width="100%" paddingLeft={1}>
+                  <diff
+                    diff={item.diff}
+                    view={view}
+                    filetype={toolFiletype(item.file)}
+                    syntaxStyle={entrySyntax(props.commit, theme)}
+                    showLineNumbers={true}
+                    width="100%"
+                    wrapMode="word"
+                    fg={theme.block.text}
+                    addedBg={theme.block.diffAddedBg}
+                    removedBg={theme.block.diffRemovedBg}
+                    contextBg={theme.block.diffContextBg}
+                    addedSignColor={theme.block.diffHighlightAdded}
+                    removedSignColor={theme.block.diffHighlightRemoved}
+                    lineNumberFg={theme.block.diffLineNumber}
+                    lineNumberBg={theme.block.diffContextBg}
+                    addedLineNumberBg={theme.block.diffAddedLineNumberBg}
+                    removedLineNumberBg={theme.block.diffRemovedLineNumberBg}
+                  />
+                </box>
+              ) : (
+                <text width="100%" wrapMode="word" fg={theme.block.diffRemoved}>
+                  -{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
+                </text>
+              )}
+            </box>
+          ))}
+        </box>
+      )
+    }
+
+    if (snap.kind === "task") {
+      return (
+        <box width="100%" flexDirection="column" gap={1}>
+          <text width="100%" wrapMode="word" fg={theme.block.muted}>
+            {snap.title}
+          </text>
+          <box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
+            {snap.rows.map((row) => (
+              <text width="100%" wrapMode="word" fg={theme.block.text}>
+                {row}
+              </text>
+            ))}
+            {snap.tail ? (
+              <text width="100%" wrapMode="word" fg={theme.block.muted}>
+                {snap.tail}
+              </text>
+            ) : null}
+          </box>
+        </box>
+      )
+    }
+
+    if (snap.kind === "todo") {
+      return (
+        <box width="100%" flexDirection="column" gap={1}>
+          <text width="100%" wrapMode="word" fg={theme.block.muted}>
+            # Todos
+          </text>
+          <box width="100%" flexDirection="column" gap={0}>
+            {snap.items.map((item) => (
+              <text width="100%" wrapMode="word" fg={todoColor(theme, item.status)}>
+                {todoText(item)}
+              </text>
+            ))}
+            {snap.tail ? (
+              <text width="100%" wrapMode="word" fg={theme.block.muted}>
+                {snap.tail}
+              </text>
+            ) : null}
+          </box>
+        </box>
+      )
+    }
+
+    if (snap.kind !== "question") {
+      return null
+    }
+
+    return (
+      <box width="100%" flexDirection="column" gap={1}>
+        <text width="100%" wrapMode="word" fg={theme.block.muted}>
+          # Questions
+        </text>
+          <box width="100%" flexDirection="column" gap={1}>
+            {snap.items.map((item) => (
+              <box width="100%" flexDirection="column" gap={0}>
+              <text width="100%" wrapMode="word" fg={theme.block.muted}>
+                {item.question}
+              </text>
+              <text width="100%" wrapMode="word" fg={theme.block.text}>
+                {item.answer}
+              </text>
+            </box>
+          ))}
+          {snap.tail ? (
+            <text width="100%" wrapMode="word" fg={theme.block.muted}>
+              {snap.tail}
+            </text>
+          ) : null}
+        </box>
+      </box>
+    )
+  }
+
+  return (
+    <markdown
+      width="100%"
+      syntaxStyle={entrySyntax(props.commit, theme)}
+      streaming={props.commit.phase === "progress"}
+      content={markdown()?.content}
+      fg={entryColor(props.commit, theme)}
+      tableOptions={{ widthMode: "content" }}
+    />
+  )
+}
+
+export function entryWriter(input: {
+  commit: StreamCommit
+  theme?: RunTheme
+  opts?: ScrollbackOptions
+}): ScrollbackWriter {
+  return createScrollbackWriter(
+    (ctx) => <RunEntryContent commit={input.commit} theme={input.theme} opts={input.opts} width={ctx.width} />,
+    entryFlags(input.commit),
+  )
+}
+
+export function spacerWriter(): ScrollbackWriter {
+  return (ctx) => ({
+    root: new TextRenderable(ctx.renderContext, {
+      id: "run-scrollback-spacer",
+      width: Math.max(1, Math.trunc(ctx.width)),
+      height: 1,
+      content: "",
+    }),
+    width: Math.max(1, Math.trunc(ctx.width)),
+    height: 1,
+    startOnNewLine: true,
+    trailingNewline: true,
+  })
+}

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

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

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

@@ -0,0 +1,196 @@
+// Session message extraction and prompt history.
+//
+// Fetches session messages from the SDK and extracts user turn text for
+// the prompt history ring. Also finds the most recently used variant for
+// the current model so the footer can pre-select it.
+import { promptCopy, promptSame } from "./prompt.shared"
+import type { RunInput, RunPrompt } from "./types"
+
+const LIMIT = 200
+
+export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
+
+type Turn = {
+  prompt: RunPrompt
+  provider: string | undefined
+  model: string | undefined
+  variant: string | undefined
+}
+
+export type RunSession = {
+  first: boolean
+  turns: Turn[]
+}
+
+function fileName(url: string, filename?: string) {
+  if (filename) {
+    return filename
+  }
+
+  try {
+    const next = new URL(url)
+    if (next.protocol !== "file:") {
+      return url
+    }
+
+    const name = next.pathname.split("/").at(-1)
+    if (name) {
+      return decodeURIComponent(name)
+    }
+  } catch {}
+
+  return url
+}
+
+function fileSource(
+  part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
+  text: { start: number; end: number; value: string },
+) {
+  if (part.source) {
+    return {
+      ...structuredClone(part.source),
+      text,
+    }
+  }
+
+  return {
+    type: "file" as const,
+    path: part.filename ?? part.url,
+    text,
+  }
+}
+
+function prompt(msg: SessionMessages[number]): RunPrompt {
+  const parts: RunPrompt["parts"] = []
+  let text = msg.parts
+    .filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
+      return part.type === "text" && !part.synthetic
+    })
+    .map((part) => part.text)
+    .join("")
+  let cursor = Bun.stringWidth(text)
+  const used: Array<{ start: number; end: number }> = []
+
+  const take = (value: string): { start: number; end: number; value: string } | undefined => {
+    let from = 0
+    while (true) {
+      const idx = text.indexOf(value, from)
+      if (idx === -1) {
+        return undefined
+      }
+
+      const start = Bun.stringWidth(text.slice(0, idx))
+      const end = start + Bun.stringWidth(value)
+      if (!used.some((item) => item.start < end && start < item.end)) {
+        return { start, end, value }
+      }
+
+      from = idx + value.length
+    }
+  }
+
+  const add = (value: string) => {
+    const gap = text ? " " : ""
+    const start = cursor + Bun.stringWidth(gap)
+    text += gap + value
+    const end = start + Bun.stringWidth(value)
+    cursor = end
+    return { start, end, value }
+  }
+
+  for (const part of msg.parts) {
+    if (part.type === "file") {
+      const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename))
+      const span = next ?? add("@" + fileName(part.url, part.filename))
+      used.push({ start: span.start, end: span.end })
+      parts.push({
+        type: "file",
+        mime: part.mime,
+        filename: part.filename,
+        url: part.url,
+        source: fileSource(part, span),
+      })
+      continue
+    }
+
+    if (part.type !== "agent") {
+      continue
+    }
+
+    const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name))
+    used.push({ start: span.start, end: span.end })
+    parts.push({
+      type: "agent",
+      name: part.name,
+      source: span,
+    })
+  }
+
+  return { text, parts }
+}
+
+function turn(msg: SessionMessages[number]): Turn | undefined {
+  if (msg.info.role !== "user") {
+    return undefined
+  }
+
+  return {
+    prompt: prompt(msg),
+    provider: msg.info.model.providerID,
+    model: msg.info.model.modelID,
+    variant: msg.info.model.variant,
+  }
+}
+
+export function createSession(messages: SessionMessages): RunSession {
+  return {
+    first: messages.length === 0,
+    turns: messages.flatMap((msg) => {
+      const item = turn(msg)
+      return item ? [item] : []
+    }),
+  }
+}
+
+export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise<RunSession> {
+  const response = await sdk.session.messages({
+    sessionID,
+    limit,
+  })
+  return createSession(response.data ?? [])
+}
+
+export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
+  const out: RunPrompt[] = []
+
+  for (const turn of session.turns) {
+    if (!turn.prompt.text.trim()) {
+      continue
+    }
+
+    if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
+      continue
+    }
+
+    out.push(promptCopy(turn.prompt))
+  }
+
+  return out.slice(-limit)
+}
+
+export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
+  if (!model) {
+    return undefined
+  }
+
+  for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
+    const turn = session.turns[idx]
+    if (turn.provider !== model.providerID || turn.model !== model.modelID) {
+      continue
+    }
+
+    return turn.variant
+  }
+
+  return undefined
+}

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

@@ -0,0 +1,291 @@
+// Entry and exit splash banners for direct interactive mode scrollback.
+//
+// Renders the opencode ASCII logo with half-block shadow characters, the
+// session title, and contextual hints (entry: "/exit to finish", exit:
+// "opencode -s <id>" to resume). These are scrollback snapshots, so they
+// become immutable terminal history once committed.
+//
+// The logo uses a cell-based renderer. cells() classifies each character
+// in the logo template as text, full-block, half-block-mix, or
+// half-block-top, and draw() renders it with foreground/background shadow
+// colors from the theme.
+import {
+  BoxRenderable,
+  type ColorInput,
+  RGBA,
+  TextAttributes,
+  TextRenderable,
+  type ScrollbackRenderContext,
+  type ScrollbackSnapshot,
+  type ScrollbackWriter,
+} from "@opentui/core"
+import * as Locale from "@/util/locale"
+import { logo } from "@/cli/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 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)
+}

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

@@ -0,0 +1,876 @@
+// SDK event subscription and prompt turn coordination.
+//
+// Creates a long-lived event stream subscription and feeds every event
+// through the session-data reducer. The reducer produces scrollback commits
+// and footer patches, which get forwarded to the footer through stream.ts.
+//
+// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the
+// SDK, arms a deferred Wait, and resolves when a session.status idle event
+// arrives for this session. If the turn is aborted (user interrupt), it
+// flushes any in-progress parts as interrupted entries.
+//
+// The tick counter prevents stale idle events from resolving the wrong turn.
+// We also re-check live session status before resolving an idle event so a
+// delayed idle from an older turn cannot complete a newer busy turn.
+import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
+import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
+import { makeRuntime } from "@/effect/run-service"
+import {
+  blockerStatus,
+  bootstrapSessionData,
+  createSessionData,
+  flushInterrupted,
+  pickBlockerView,
+  reduceSessionData,
+  type SessionData,
+} from "./session-data"
+import {
+  bootstrapSubagentCalls,
+  bootstrapSubagentData,
+  clearFinishedSubagents,
+  createSubagentData,
+  listSubagentPermissions,
+  listSubagentQuestions,
+  listSubagentTabs,
+  reduceSubagentData,
+  sameSubagentTab,
+  snapshotSelectedSubagentData,
+  SUBAGENT_BOOTSTRAP_LIMIT,
+  SUBAGENT_CALL_BOOTSTRAP_LIMIT,
+  type SubagentData,
+} from "./subagent-data"
+import { traceFooterOutput, writeSessionOutput } from "./stream"
+import type {
+  FooterApi,
+  FooterOutput,
+  FooterPatch,
+  FooterSubagentState,
+  FooterSubagentTab,
+  FooterView,
+  RunFilePart,
+  RunInput,
+  RunPrompt,
+  StreamCommit,
+} from "./types"
+
+type Trace = {
+  write(type: string, data?: unknown): void
+}
+
+type StreamInput = {
+  sdk: OpencodeClient
+  sessionID: string
+  thinking: boolean
+  limits: () => Record<string, number>
+  footer: FooterApi
+  trace?: Trace
+  signal?: AbortSignal
+}
+
+type Wait = {
+  tick: number
+  armed: boolean
+  live: boolean
+  done: Deferred.Deferred<void, unknown>
+}
+
+export type SessionTurnInput = {
+  agent: string | undefined
+  model: RunInput["model"]
+  variant: string | undefined
+  prompt: RunPrompt
+  files: RunFilePart[]
+  includeFiles: boolean
+  signal?: AbortSignal
+}
+
+export type SessionTransport = {
+  runPromptTurn(input: SessionTurnInput): Promise<void>
+  selectSubagent(sessionID: string | undefined): void
+  close(): Promise<void>
+}
+
+type State = {
+  data: SessionData
+  subagent: SubagentData
+  wait?: Wait
+  tick: number
+  fault?: unknown
+  footerView: FooterView
+  blockerTick: number
+  selectedSubagent?: string
+  blockers: Map<string, number>
+}
+
+type TransportService = {
+  readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect<void, unknown>
+  readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect<void>
+  readonly close: () => Effect.Effect<void>
+}
+
+class Service extends Context.Service<Service, TransportService>()("@opencode/RunStreamTransport") {}
+
+function sid(event: Event): string | undefined {
+  if (event.type === "message.updated") {
+    return event.properties.sessionID
+  }
+
+  if (event.type === "message.part.delta") {
+    return event.properties.sessionID
+  }
+
+  if (event.type === "message.part.updated") {
+    return event.properties.part.sessionID
+  }
+
+  if (
+    event.type === "permission.asked" ||
+    event.type === "permission.replied" ||
+    event.type === "question.asked" ||
+    event.type === "question.replied" ||
+    event.type === "question.rejected" ||
+    event.type === "session.error" ||
+    event.type === "session.status"
+  ) {
+    return event.properties.sessionID
+  }
+
+  return undefined
+}
+
+function isEvent(value: unknown): value is Event {
+  if (!value || typeof value !== "object" || Array.isArray(value)) {
+    return false
+  }
+
+  const type = Reflect.get(value, "type")
+  const properties = Reflect.get(value, "properties")
+  return typeof type === "string" && !!properties && typeof properties === "object"
+}
+
+function active(event: Event, sessionID: string): boolean {
+  if (sid(event) !== sessionID) {
+    return false
+  }
+
+  if (event.type !== "session.status") {
+    return true
+  }
+
+  return event.properties.status.type !== "idle"
+}
+
+// Races the turn's deferred completion against an abort signal.
+function waitTurn(done: Wait["done"], signal: AbortSignal) {
+  return Effect.raceAll([
+    Deferred.await(done).pipe(Effect.as("idle" as const), Effect.exit),
+    Effect.callback<"abort">((resume) => {
+      if (signal.aborted) {
+        resume(Effect.succeed("abort"))
+        return Effect.void
+      }
+
+      const onAbort = () => {
+        signal.removeEventListener("abort", onAbort)
+        resume(Effect.succeed("abort"))
+      }
+
+      signal.addEventListener("abort", onAbort, { once: true })
+      return Effect.sync(() => signal.removeEventListener("abort", onAbort))
+    }).pipe(Effect.exit),
+  ]).pipe(
+    Effect.flatMap((exit) => (Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.succeed(exit.value))),
+  )
+}
+
+export function formatUnknownError(error: unknown): string {
+  if (typeof error === "string") {
+    return error
+  }
+
+  if (error instanceof Error) {
+    return error.message || error.name
+  }
+
+  if (error && typeof error === "object") {
+    const value = error as { message?: unknown; name?: unknown }
+    if (typeof value.message === "string" && value.message.trim()) {
+      return value.message
+    }
+
+    if (typeof value.name === "string" && value.name.trim()) {
+      return value.name
+    }
+  }
+
+  return "unknown error"
+}
+
+function sameView(a: FooterView, b: FooterView) {
+  if (a.type !== b.type) {
+    return false
+  }
+
+  if (a.type === "prompt" && b.type === "prompt") {
+    return true
+  }
+
+  if (a.type === "prompt" || b.type === "prompt") {
+    return false
+  }
+
+  return a.request === b.request
+}
+
+function blockerOrder(order: Map<string, number>, id: string) {
+  return order.get(id) ?? Number.MAX_SAFE_INTEGER
+}
+
+function firstByOrder<T extends { id: string }>(left: T[], right: T[], order: Map<string, number>) {
+  return [...left, ...right].sort((a, b) => {
+    const next = blockerOrder(order, a.id) - blockerOrder(order, b.id)
+    if (next !== 0) {
+      return next
+    }
+
+    return a.id.localeCompare(b.id)
+  })[0]
+}
+
+function pickView(data: SessionData, subagent: SubagentData, order: Map<string, number>): FooterView {
+  return pickBlockerView({
+    permission: firstByOrder(data.permissions, listSubagentPermissions(subagent), order),
+    question: firstByOrder(data.questions, listSubagentQuestions(subagent), order),
+  })
+}
+
+function composeFooter(input: {
+  patch?: FooterPatch
+  subagent?: FooterSubagentState
+  current: FooterView
+  previous: FooterView
+}) {
+  let footer: FooterOutput | undefined
+
+  if (input.subagent) {
+    footer = {
+      ...footer,
+      subagent: input.subagent,
+    }
+  }
+
+  if (!sameView(input.previous, input.current)) {
+    footer = {
+      ...footer,
+      view: input.current,
+    }
+  }
+
+  if (input.current.type !== "prompt") {
+    footer = {
+      ...footer,
+      patch: {
+        ...input.patch,
+        status: blockerStatus(input.current),
+      },
+    }
+    return footer
+  }
+
+  if (input.patch) {
+    footer = {
+      ...footer,
+      patch: input.patch,
+    }
+    return footer
+  }
+
+  if (input.previous.type !== "prompt") {
+    footer = {
+      ...footer,
+      patch: {
+        status: "",
+      },
+    }
+  }
+
+  return footer
+}
+
+function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: FooterSubagentTab[]) {
+  const before = new Map(prev.map((item) => [item.sessionID, item]))
+  const after = new Map(next.map((item) => [item.sessionID, item]))
+
+  for (const [sessionID, tab] of after) {
+    if (sameSubagentTab(before.get(sessionID), tab)) {
+      continue
+    }
+
+    trace?.write("subagent.tab", {
+      sessionID,
+      tab,
+    })
+  }
+
+  for (const sessionID of before.keys()) {
+    if (after.has(sessionID)) {
+      continue
+    }
+
+    trace?.write("subagent.tab", {
+      sessionID,
+      cleared: true,
+    })
+  }
+}
+
+function createLayer(input: StreamInput) {
+  return Layer.fresh(
+    Layer.effect(
+      Service,
+      Effect.gen(function* () {
+        const scope = yield* Scope.make()
+        const abort = yield* Scope.provide(scope)(
+          Effect.acquireRelease(
+            Effect.sync(() => new AbortController()),
+            (abort) => Effect.sync(() => abort.abort()),
+          ),
+        )
+        let closed = false
+        let closeStream = () => {}
+        const halt = () => {
+          abort.abort()
+        }
+        const stop = () => {
+          input.signal?.removeEventListener("abort", halt)
+          abort.abort()
+          closeStream()
+        }
+        const closeScope = () => {
+          if (closed) {
+            return Effect.void
+          }
+
+          closed = true
+          stop()
+          return Scope.close(scope, Exit.void)
+        }
+
+        input.signal?.addEventListener("abort", halt, { once: true })
+        yield* Effect.addFinalizer(() => closeScope())
+
+        const events = yield* Scope.provide(scope)(
+          Effect.acquireRelease(
+            Effect.promise(() =>
+              input.sdk.event.subscribe(undefined, {
+                signal: abort.signal,
+              }),
+            ),
+            (events) =>
+              Effect.sync(() => {
+                void events.stream.return(undefined).catch(() => {})
+                }),
+          ),
+        )
+        closeStream = () => {
+          void events.stream.return(undefined).catch(() => {})
+        }
+        input.trace?.write("recv.subscribe", {
+          sessionID: input.sessionID,
+        })
+
+        const state: State = {
+          data: createSessionData(),
+          subagent: createSubagentData(),
+          tick: 0,
+          footerView: { type: "prompt" },
+          blockerTick: 0,
+          blockers: new Map(),
+        }
+
+        const currentSubagentState = () => {
+          if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
+            state.selectedSubagent = undefined
+          }
+
+          return snapshotSelectedSubagentData(state.subagent, state.selectedSubagent)
+        }
+
+        const seedBlocker = (id: string) => {
+          if (state.blockers.has(id)) {
+            return
+          }
+
+          state.blockerTick += 1
+          state.blockers.set(id, state.blockerTick)
+        }
+
+        const trackBlocker = (event: Event) => {
+          if (event.type !== "permission.asked" && event.type !== "question.asked") {
+            return
+          }
+
+          if (
+            event.properties.sessionID !== input.sessionID &&
+            !state.subagent.tabs.has(event.properties.sessionID)
+          ) {
+            return
+          }
+
+          seedBlocker(event.properties.id)
+        }
+
+        const releaseBlocker = (event: Event) => {
+          if (
+            event.type !== "permission.replied" &&
+            event.type !== "question.replied" &&
+            event.type !== "question.rejected"
+          ) {
+            return
+          }
+
+          state.blockers.delete(event.properties.requestID)
+        }
+
+        const syncFooter = (commits: StreamCommit[], patch?: FooterPatch, nextSubagent?: FooterSubagentState) => {
+          const current = pickView(state.data, state.subagent, state.blockers)
+          const footer = composeFooter({
+            patch,
+            subagent: nextSubagent,
+            current,
+            previous: state.footerView,
+          })
+
+          if (commits.length === 0 && !footer) {
+            state.footerView = current
+            return
+          }
+
+          input.trace?.write("reduce.output", {
+            commits,
+            footer: traceFooterOutput(footer),
+          })
+          writeSessionOutput(
+            {
+              footer: input.footer,
+              trace: input.trace,
+            },
+            {
+              commits,
+              footer,
+            },
+          )
+          state.footerView = current
+        }
+
+        const messages = (sessionID: string, limit: number) =>
+          Effect.promise(() =>
+            input.sdk.session.messages({
+              sessionID,
+              limit,
+            }),
+          ).pipe(
+            Effect.map((item) => item.data ?? []),
+            Effect.orElseSucceed(() => []),
+          )
+
+        const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
+          const [messagesList, children, permissions, questions] = yield* Effect.all(
+            [
+              messages(input.sessionID, SUBAGENT_BOOTSTRAP_LIMIT),
+              Effect.promise(() =>
+                input.sdk.session.children({
+                  sessionID: input.sessionID,
+                }),
+              ).pipe(
+                Effect.map((item) => item.data ?? []),
+                Effect.orElseSucceed(() => []),
+              ),
+              Effect.promise(() => input.sdk.permission.list()).pipe(
+                Effect.map((item) => item.data ?? []),
+                Effect.orElseSucceed(() => []),
+              ),
+              Effect.promise(() => input.sdk.question.list()).pipe(
+                Effect.map((item) => item.data ?? []),
+                Effect.orElseSucceed(() => []),
+              ),
+            ],
+            {
+              concurrency: "unbounded",
+            },
+          )
+
+          bootstrapSessionData({
+            data: state.data,
+            messages: messagesList,
+            permissions: permissions.filter((item) => item.sessionID === input.sessionID),
+            questions: questions.filter((item) => item.sessionID === input.sessionID),
+          })
+          bootstrapSubagentData({
+            data: state.subagent,
+            messages: messagesList,
+            children,
+            permissions,
+            questions,
+          })
+
+          const sessions = [
+            ...new Set(
+              listSubagentPermissions(state.subagent)
+                .filter((item) => item.tool && item.metadata?.input === undefined)
+                .map((item) => item.sessionID),
+            ),
+          ]
+          yield* Effect.forEach(
+            sessions,
+            (sessionID) =>
+              messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe(
+                Effect.tap((messagesList) =>
+                  Effect.sync(() => {
+                    bootstrapSubagentCalls({
+                      data: state.subagent,
+                      sessionID,
+                      messages: messagesList,
+                    })
+                  }),
+                ),
+              ),
+            {
+              concurrency: "unbounded",
+              discard: true,
+            },
+          )
+
+          for (const request of [
+            ...state.data.permissions,
+            ...listSubagentPermissions(state.subagent),
+            ...state.data.questions,
+            ...listSubagentQuestions(state.subagent),
+          ].sort((a, b) => a.id.localeCompare(b.id))) {
+            seedBlocker(request.id)
+          }
+
+          const snapshot = currentSubagentState()
+          traceTabs(input.trace, [], snapshot.tabs)
+          syncFooter([], undefined, snapshot)
+        })
+
+        const idle = Effect.fn("RunStreamTransport.idle")(() =>
+          Effect.promise(() => input.sdk.session.status()).pipe(
+            Effect.map((out) => {
+              const item = out.data?.[input.sessionID]
+              return !item || item.type === "idle"
+            }),
+            Effect.orElseSucceed(() => true),
+          ),
+        )
+
+        const fail = Effect.fn("RunStreamTransport.fail")(function* (error: unknown) {
+          if (state.fault) {
+            return
+          }
+
+          state.fault = error
+          const next = state.wait
+          state.wait = undefined
+          if (!next) {
+            return
+          }
+
+          yield* Deferred.fail(next.done, error).pipe(Effect.ignore)
+        })
+
+        const touch = (event: Event) => {
+          const next = state.wait
+          if (!next || !active(event, input.sessionID)) {
+            return
+          }
+
+          next.live = true
+        }
+
+        const mark = Effect.fn("RunStreamTransport.mark")(function* (event: Event) {
+          if (
+            event.type !== "session.status" ||
+            event.properties.sessionID !== input.sessionID ||
+            event.properties.status.type !== "idle"
+          ) {
+            return
+          }
+
+          const next = state.wait
+          if (!next || !next.armed || !next.live) {
+            return
+          }
+
+          if (!(yield* idle()) || state.wait !== next) {
+            return
+          }
+
+          state.tick = next.tick + 1
+          state.wait = undefined
+          yield* Deferred.succeed(next.done, undefined).pipe(Effect.ignore)
+        })
+
+        const flush = (type: "turn.abort" | "turn.cancel") => {
+          const commits: StreamCommit[] = []
+          flushInterrupted(state.data, commits)
+          syncFooter(commits)
+          input.trace?.write(type, {
+            sessionID: input.sessionID,
+          })
+        }
+
+        const watch = Effect.fn("RunStreamTransport.watch")(() =>
+          Stream.fromAsyncIterable(events.stream as AsyncIterable<unknown>, (error) =>
+            error instanceof Error ? error : new Error(String(error)),
+          ).pipe(
+            Stream.takeUntil(() => input.footer.isClosed || abort.signal.aborted),
+            Stream.runForEach(
+              Effect.fn("RunStreamTransport.event")(function* (item: unknown) {
+                if (input.footer.isClosed) {
+                  abort.abort()
+                  return
+                }
+
+                if (!isEvent(item)) {
+                  return
+                }
+
+                const event = item
+                input.trace?.write("recv.event", event)
+                trackBlocker(event)
+
+                const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
+                const next = reduceSessionData({
+                  data: state.data,
+                  event,
+                  sessionID: input.sessionID,
+                  thinking: input.thinking,
+                  limits: input.limits(),
+                })
+                state.data = next.data
+
+                const changed = reduceSubagentData({
+                  data: state.subagent,
+                  event,
+                  sessionID: input.sessionID,
+                  thinking: input.thinking,
+                  limits: input.limits(),
+                })
+                if (changed && prev) {
+                  traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
+                }
+                releaseBlocker(event)
+
+                syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
+
+                touch(event)
+                yield* mark(event)
+              }),
+            ),
+            Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
+            Effect.ensuring(
+              Effect.gen(function* () {
+                if (!abort.signal.aborted && !state.fault) {
+                  yield* fail(new Error("session event stream closed"))
+                }
+                closeStream()
+              }),
+            ),
+          ),
+        )
+
+        yield* bootstrap()
+        yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
+
+        const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
+          if (closed || next.signal?.aborted || input.footer.isClosed) {
+            return
+          }
+
+          if (state.fault) {
+            yield* Effect.fail(state.fault)
+            return
+          }
+
+          if (state.wait) {
+            yield* Effect.fail(new Error("prompt already running"))
+            return
+          }
+
+          const prev = listSubagentTabs(state.subagent)
+          if (clearFinishedSubagents(state.subagent)) {
+            const snapshot = currentSubagentState()
+            traceTabs(input.trace, prev, snapshot.tabs)
+            syncFooter([], undefined, snapshot)
+          }
+
+          const item: Wait = {
+            tick: state.tick,
+            armed: false,
+            live: false,
+            done: yield* Deferred.make<void, unknown>(),
+          }
+          state.wait = item
+          state.data.announced = false
+
+          const turn = new AbortController()
+          const stop = () => {
+            turn.abort()
+          }
+          next.signal?.addEventListener("abort", stop, { once: true })
+          abort.signal.addEventListener("abort", stop, { once: true })
+
+          const req = {
+            sessionID: input.sessionID,
+            agent: next.agent,
+            model: next.model,
+            variant: next.variant,
+            parts: [
+              ...(next.includeFiles ? next.files : []),
+              { type: "text" as const, text: next.prompt.text },
+              ...next.prompt.parts,
+            ],
+          }
+          input.trace?.write("send.prompt", req)
+
+          const send = Effect.promise(() =>
+            input.sdk.session.promptAsync(req, {
+              signal: turn.signal,
+            }),
+          ).pipe(
+            Effect.tap(() =>
+              Effect.sync(() => {
+                input.trace?.write("send.prompt.ok", {
+                  sessionID: input.sessionID,
+                })
+                item.armed = true
+              }),
+            ),
+          )
+
+          yield* send.pipe(
+            Effect.flatMap(() => {
+              if (turn.signal.aborted || next.signal?.aborted || input.footer.isClosed || closed) {
+                if (state.wait === item) {
+                  state.wait = undefined
+                }
+                flush("turn.abort")
+                return Effect.void
+              }
+
+              if (!input.footer.isClosed && !state.data.announced) {
+                input.trace?.write("ui.patch", {
+                  phase: "running",
+                  status: "waiting for assistant",
+                })
+                input.footer.event({
+                  type: "turn.wait",
+                })
+              }
+
+              if (state.tick > item.tick) {
+                if (state.wait === item) {
+                  state.wait = undefined
+                }
+                return Effect.void
+              }
+
+              return waitTurn(item.done, turn.signal).pipe(
+                Effect.flatMap((status) =>
+                  Effect.sync(() => {
+                    if (state.wait === item) {
+                      state.wait = undefined
+                    }
+
+                    if (status === "abort") {
+                      flush("turn.abort")
+                    }
+                  }),
+                ),
+              )
+            }),
+            Effect.catch((error) => {
+              if (state.wait === item) {
+                state.wait = undefined
+              }
+
+              const canceled = turn.signal.aborted || next.signal?.aborted === true || input.footer.isClosed || closed
+              if (canceled) {
+                flush("turn.cancel")
+                return Effect.void
+              }
+
+              if (error === state.fault) {
+                return Effect.fail(error)
+              }
+
+              input.trace?.write("send.prompt.error", {
+                sessionID: input.sessionID,
+                error: formatUnknownError(error),
+              })
+              return Effect.fail(error)
+            }),
+            Effect.ensuring(
+              Effect.sync(() => {
+                input.trace?.write("turn.end", {
+                  sessionID: input.sessionID,
+                })
+                next.signal?.removeEventListener("abort", stop)
+                abort.signal.removeEventListener("abort", stop)
+              }),
+            ),
+          )
+          return
+        })
+
+        const selectSubagent = Effect.fn("RunStreamTransport.selectSubagent")((sessionID: string | undefined) =>
+          Effect.sync(() => {
+            if (closed) {
+              return
+            }
+
+            const next = sessionID && state.subagent.tabs.has(sessionID) ? sessionID : undefined
+            if (state.selectedSubagent === next) {
+              return
+            }
+
+            state.selectedSubagent = next
+            syncFooter([], undefined, currentSubagentState())
+          }),
+        )
+
+        const close = Effect.fn("RunStreamTransport.close")(function* () {
+          yield* closeScope()
+        })
+
+        return Service.of({
+          runPromptTurn,
+          selectSubagent,
+          close,
+        })
+      }),
+    ),
+  )
+}
+
+// Opens an SDK event subscription and returns a SessionTransport.
+//
+// The background `watch` loop consumes every SDK event, runs it through the
+// reducer, and writes output to the footer. When a session.status idle
+// event arrives, it resolves the current turn's Wait so runPromptTurn()
+// can return.
+//
+// The transport is single-turn: only one runPromptTurn() call can be active
+// at a time. The prompt queue enforces this from above.
+export async function createSessionTransport(input: StreamInput): Promise<SessionTransport> {
+  const runtime = makeRuntime(Service, createLayer(input))
+  await runtime.runPromise(() => Effect.void)
+
+  return {
+    runPromptTurn: (next) => runtime.runPromise((svc) => svc.runPromptTurn(next)),
+    selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)),
+    close: () => runtime.runPromise((svc) => svc.close()),
+  }
+}

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

@@ -0,0 +1,175 @@
+// Thin bridge between reducer output and the footer API.
+//
+// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
+// view + subagent state). This module forwards them to footer.append() and
+// footer.event() respectively, adding trace writes along the way. It also
+// defaults status updates to phase "running" if the caller didn't set a
+// phase -- a convenience so reducer code doesn't have to repeat that.
+import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
+
+type Trace = {
+  write(type: string, data?: unknown): void
+}
+
+type OutputInput = {
+  footer: FooterApi
+  trace?: Trace
+}
+
+type StreamOutput = {
+  commits: StreamCommit[]
+  footer?: FooterOutput
+}
+
+// Default to "running" phase when a status string arrives without an explicit phase.
+function patch(next: FooterPatch): FooterPatch {
+  if (typeof next.status === "string" && next.phase === undefined) {
+    return {
+      phase: "running",
+      ...next,
+    }
+  }
+
+  return next
+}
+
+function summarize(value: unknown): unknown {
+  if (typeof value === "string") {
+    if (value.length <= 160) {
+      return value
+    }
+
+    return {
+      type: "string",
+      length: value.length,
+      preview: `${value.slice(0, 160)}...`,
+    }
+  }
+
+  if (Array.isArray(value)) {
+    return {
+      type: "array",
+      length: value.length,
+    }
+  }
+
+  if (!value || typeof value !== "object") {
+    return value
+  }
+
+  return {
+    type: "object",
+    keys: Object.keys(value),
+  }
+}
+
+function traceCommit(commit: StreamCommit) {
+  return {
+    ...commit,
+    text: summarize(commit.text),
+    textLength: commit.text.length,
+    part: commit.part
+      ? {
+          id: commit.part.id,
+          sessionID: commit.part.sessionID,
+          messageID: commit.part.messageID,
+          callID: commit.part.callID,
+          tool: commit.part.tool,
+          state: {
+            status: commit.part.state.status,
+            title: "title" in commit.part.state ? summarize(commit.part.state.title) : undefined,
+            error: "error" in commit.part.state ? summarize(commit.part.state.error) : undefined,
+            time: "time" in commit.part.state ? summarize(commit.part.state.time) : undefined,
+            input: summarize(commit.part.state.input),
+            metadata: "metadata" in commit.part.state ? summarize(commit.part.state.metadata) : undefined,
+          },
+        }
+      : undefined,
+  }
+}
+
+export function traceSubagentState(state: FooterSubagentState) {
+  return {
+    tabs: state.tabs,
+    details: Object.fromEntries(
+      Object.entries(state.details).map(([sessionID, detail]) => [
+        sessionID,
+        {
+          sessionID,
+          commits: detail.commits.map(traceCommit),
+        },
+      ]),
+    ),
+    permissions: state.permissions.map((item) => ({
+      id: item.id,
+      sessionID: item.sessionID,
+      permission: item.permission,
+      patterns: item.patterns,
+      tool: item.tool,
+      metadata: item.metadata
+        ? {
+            keys: Object.keys(item.metadata),
+            input: summarize(item.metadata.input),
+          }
+        : undefined,
+    })),
+    questions: state.questions.map((item) => ({
+      id: item.id,
+      sessionID: item.sessionID,
+      questions: item.questions.map((question) => ({
+        header: question.header,
+        question: question.question,
+        options: question.options.length,
+        multiple: question.multiple,
+      })),
+    })),
+  }
+}
+
+export function traceFooterOutput(footer?: FooterOutput) {
+  if (!footer?.subagent) {
+    return footer
+  }
+
+  return {
+    ...footer,
+    subagent: traceSubagentState(footer.subagent),
+  }
+}
+
+// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
+export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
+  for (const commit of out.commits) {
+    input.trace?.write("ui.commit", commit)
+    input.footer.append(commit)
+  }
+
+  if (out.footer?.patch) {
+    const next = patch(out.footer.patch)
+    input.trace?.write("ui.patch", next)
+    input.footer.event({
+      type: "stream.patch",
+      patch: next,
+    })
+  }
+
+  if (out.footer?.subagent) {
+    input.trace?.write("ui.subagent", traceSubagentState(out.footer.subagent))
+    input.footer.event({
+      type: "stream.subagent",
+      state: out.footer.subagent,
+    })
+  }
+
+  if (!out.footer?.view) {
+    return
+  }
+
+  input.trace?.write("ui.patch", {
+    view: out.footer.view,
+  })
+  input.footer.event({
+    type: "stream.view",
+    view: out.footer.view,
+  })
+}

+ 746 - 0
packages/opencode/src/cli/cmd/run/subagent-data.ts

@@ -0,0 +1,746 @@
+import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
+import * as Locale from "@/util/locale"
+import {
+  bootstrapSessionData,
+  createSessionData,
+  formatError,
+  reduceSessionData,
+  type SessionData,
+} from "./session-data"
+import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
+
+export const SUBAGENT_BOOTSTRAP_LIMIT = 200
+export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80
+
+const SUBAGENT_COMMIT_LIMIT = 80
+const SUBAGENT_CALL_LIMIT = 32
+const SUBAGENT_ROLE_LIMIT = 32
+const SUBAGENT_ERROR_LIMIT = 16
+const SUBAGENT_ECHO_LIMIT = 8
+
+type SessionMessage = {
+  parts: Part[]
+}
+
+type Frame = {
+  key: string
+  commit: StreamCommit
+}
+
+type DetailState = {
+  sessionID: string
+  data: SessionData
+  frames: Frame[]
+}
+
+export type SubagentData = {
+  tabs: Map<string, FooterSubagentTab>
+  details: Map<string, DetailState>
+}
+
+export type BootstrapSubagentInput = {
+  data: SubagentData
+  messages: SessionMessage[]
+  children: Array<{ id: string; title?: string }>
+  permissions: PermissionRequest[]
+  questions: QuestionRequest[]
+}
+
+function createDetail(sessionID: string): DetailState {
+  return {
+    sessionID,
+    data: createSessionData({
+      includeUserText: true,
+    }),
+    frames: [],
+  }
+}
+
+function ensureDetail(data: SubagentData, sessionID: string) {
+  const current = data.details.get(sessionID)
+  if (current) {
+    return current
+  }
+
+  const next = createDetail(sessionID)
+  data.details.set(sessionID, next)
+  return next
+}
+
+export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) {
+  if (!a || !b) {
+    return false
+  }
+
+  return (
+    a.sessionID === b.sessionID &&
+    a.partID === b.partID &&
+    a.callID === b.callID &&
+    a.label === b.label &&
+    a.description === b.description &&
+    a.status === b.status &&
+    a.title === b.title &&
+    a.toolCalls === b.toolCalls &&
+    a.lastUpdatedAt === b.lastUpdatedAt
+  )
+}
+
+function sameQueue<T extends { id: string }>(left: T[], right: T[]) {
+  return (
+    left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index])
+  )
+}
+
+function queueSnapshot(data: SessionData) {
+  return {
+    permissions: data.permissions.slice(),
+    questions: data.questions.slice(),
+  }
+}
+
+function queueChanged(data: SessionData, before: ReturnType<typeof queueSnapshot>) {
+  return !sameQueue(before.permissions, data.permissions) || !sameQueue(before.questions, data.questions)
+}
+
+function sameCommit(left: StreamCommit, right: StreamCommit) {
+  return (
+    left.kind === right.kind &&
+    left.text === right.text &&
+    left.phase === right.phase &&
+    left.source === right.source &&
+    left.messageID === right.messageID &&
+    left.partID === right.partID &&
+    left.tool === right.tool &&
+    left.interrupted === right.interrupted &&
+    left.toolState === right.toolState &&
+    left.toolError === right.toolError
+  )
+}
+
+function text(value: unknown): string | undefined {
+  if (typeof value !== "string") {
+    return undefined
+  }
+
+  const next = value.trim()
+  return next || undefined
+}
+
+function num(value: unknown): number | undefined {
+  if (typeof value === "number" && Number.isFinite(value)) {
+    return value
+  }
+
+  return undefined
+}
+
+function inputLabel(input: Record<string, unknown>): string | undefined {
+  const description = text(input.description)
+  if (description) {
+    return description
+  }
+
+  const command = text(input.command)
+  if (command) {
+    return command
+  }
+
+  const filePath = text(input.filePath) ?? text(input.filepath)
+  if (filePath) {
+    return filePath
+  }
+
+  const pattern = text(input.pattern)
+  if (pattern) {
+    return pattern
+  }
+
+  const query = text(input.query)
+  if (query) {
+    return query
+  }
+
+  const url = text(input.url)
+  if (url) {
+    return url
+  }
+
+  const path = text(input.path)
+  if (path) {
+    return path
+  }
+
+  const prompt = text(input.prompt)
+  if (prompt) {
+    return prompt
+  }
+
+  return undefined
+}
+
+function stateTitle(part: ToolPart) {
+  return text("title" in part.state ? part.state.title : undefined)
+}
+
+function callKey(messageID: string | undefined, callID: string | undefined): string | undefined {
+  if (!messageID || !callID) {
+    return undefined
+  }
+
+  return `${messageID}:${callID}`
+}
+
+function compactToolState(part: ToolPart): ToolPart["state"] {
+  if (part.state.status === "pending") {
+    return {
+      status: "pending",
+      input: part.state.input,
+      raw: part.state.raw,
+    }
+  }
+
+  if (part.state.status === "running") {
+    return {
+      status: "running",
+      input: part.state.input,
+      time: part.state.time,
+      ...(part.state.metadata ? { metadata: part.state.metadata } : {}),
+      ...(part.state.title ? { title: part.state.title } : {}),
+    }
+  }
+
+  if (part.state.status === "completed") {
+    return {
+      status: "completed",
+      input: part.state.input,
+      output: part.state.output,
+      title: part.state.title,
+      metadata: part.state.metadata,
+      time: part.state.time,
+    }
+  }
+
+  return {
+    status: "error",
+    input: part.state.input,
+    error: part.state.error,
+    time: part.state.time,
+    ...(part.state.metadata ? { metadata: part.state.metadata } : {}),
+  }
+}
+
+function recent<T>(input: Iterable<T>, limit: number) {
+  const list = [...input]
+  return list.slice(Math.max(0, list.length - limit))
+}
+
+function copyMap<K, V>(source: Map<K, V>, keep: Set<K>) {
+  const out = new Map<K, V>()
+  for (const [key, value] of source) {
+    if (!keep.has(key)) {
+      continue
+    }
+
+    out.set(key, value)
+  }
+  return out
+}
+
+function compactToolPart(part: ToolPart): ToolPart {
+  return {
+    id: part.id,
+    type: "tool",
+    sessionID: part.sessionID,
+    messageID: part.messageID,
+    callID: part.callID,
+    tool: part.tool,
+    state: compactToolState(part),
+    ...(part.metadata ? { metadata: part.metadata } : {}),
+  }
+}
+
+function compactCommit(commit: StreamCommit): StreamCommit {
+  if (!commit.part) {
+    return commit
+  }
+
+  return {
+    ...commit,
+    part: compactToolPart(commit.part),
+  }
+}
+
+function stateUpdatedAt(part: ToolPart) {
+  if (!("time" in part.state)) {
+    return Date.now()
+  }
+
+  const time = part.state.time
+  if (!("end" in time)) {
+    return time.start ?? Date.now()
+  }
+
+  return time.end ?? time.start ?? Date.now()
+}
+
+function metadata(part: ToolPart, key: string) {
+  return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
+}
+
+function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
+  const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
+  const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
+  const status = part.state.status === "error" ? "error" : part.state.status === "completed" ? "completed" : "running"
+
+  return {
+    sessionID,
+    partID: part.id,
+    callID: part.callID,
+    label,
+    description,
+    status,
+    title: stateTitle(part),
+    toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
+    lastUpdatedAt: stateUpdatedAt(part),
+  }
+}
+
+function taskSessionID(part: ToolPart) {
+  return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID"))
+}
+
+function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set<string>) {
+  if (part.tool !== "task") {
+    return false
+  }
+
+  const sessionID = taskSessionID(part)
+  if (!sessionID) {
+    return false
+  }
+
+  if (children && children.size > 0 && !children.has(sessionID)) {
+    return false
+  }
+
+  const next = taskTab(part, sessionID)
+  if (sameSubagentTab(data.tabs.get(sessionID), next)) {
+    ensureDetail(data, sessionID)
+    return false
+  }
+
+  data.tabs.set(sessionID, next)
+  ensureDetail(data, sessionID)
+  return true
+}
+
+function frameKey(commit: StreamCommit) {
+  if (commit.partID) {
+    return `${commit.kind}:${commit.partID}:${commit.phase}`
+  }
+
+  if (commit.messageID) {
+    return `${commit.kind}:${commit.messageID}:${commit.phase}`
+  }
+
+  return `${commit.kind}:${commit.phase}:${commit.text}`
+}
+
+function limitFrames(detail: DetailState) {
+  if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) {
+    return
+  }
+
+  detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT)
+}
+
+function mergeLiveCommit(current: StreamCommit, next: StreamCommit) {
+  if (current.phase !== "progress" || next.phase !== "progress") {
+    if (sameCommit(current, next)) {
+      return current
+    }
+
+    return next
+  }
+
+  const merged = {
+    ...current,
+    ...next,
+    text: current.text + next.text,
+  }
+
+  if (sameCommit(current, merged)) {
+    return current
+  }
+
+  return merged
+}
+
+function appendCommits(detail: DetailState, commits: StreamCommit[]) {
+  let changed = false
+
+  for (const commit of commits.map(compactCommit)) {
+    const key = frameKey(commit)
+    const index = detail.frames.findIndex((item) => item.key === key)
+    if (index === -1) {
+      detail.frames.push({
+        key,
+        commit,
+      })
+      changed = true
+      continue
+    }
+
+    const next = mergeLiveCommit(detail.frames[index].commit, commit)
+    if (sameCommit(detail.frames[index].commit, next)) {
+      continue
+    }
+
+    detail.frames[index] = {
+      key,
+      commit: next,
+    }
+    changed = true
+  }
+
+  if (changed) {
+    limitFrames(detail)
+  }
+
+  return changed
+}
+
+function ensureBlockerTab(
+  data: SubagentData,
+  sessionID: string,
+  title: string | undefined,
+  kind: "permission" | "question",
+) {
+  if (data.tabs.has(sessionID)) {
+    ensureDetail(data, sessionID)
+    return false
+  }
+
+  data.tabs.set(sessionID, {
+    sessionID,
+    partID: `bootstrap:${sessionID}`,
+    callID: `bootstrap:${sessionID}`,
+    label: text(title) ?? Locale.titlecase(kind),
+    description: kind === "permission" ? "Pending permission" : "Pending question",
+    status: "running",
+    lastUpdatedAt: Date.now(),
+  })
+  ensureDetail(data, sessionID)
+  return true
+}
+
+function compactCallMap(detail: DetailState) {
+  const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
+
+  for (const request of detail.data.permissions) {
+    const key = callKey(request.tool?.messageID, request.tool?.callID)
+    if (key) {
+      keep.add(key)
+    }
+  }
+
+  for (const item of detail.frames) {
+    const key = callKey(item.commit.part?.messageID, item.commit.part?.callID)
+    if (key) {
+      keep.add(key)
+    }
+  }
+
+  return copyMap(detail.data.call, keep)
+}
+
+function compactEchoMap(data: SessionData, messageIDs: Set<string>) {
+  const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)])
+  return copyMap(data.echo, keys)
+}
+
+function compactIDs(detail: DetailState) {
+  return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT))
+}
+
+function compactDetail(detail: DetailState) {
+  const next = createSessionData({
+    includeUserText: true,
+  })
+  const activePartIDs = new Set(detail.data.part.keys())
+  const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : [])))
+  const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools])
+  const messageIDs = new Set([
+    ...[...activePartIDs]
+      .map((partID) => detail.data.msg.get(partID))
+      .filter((item): item is string => typeof item === "string"),
+    ...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT),
+  ])
+
+  next.announced = detail.data.announced
+  next.permissions = detail.data.permissions
+  next.questions = detail.data.questions
+  next.ids = compactIDs(detail)
+  next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item)))
+  next.call = compactCallMap(detail)
+  next.role = copyMap(detail.data.role, messageIDs)
+  next.msg = copyMap(detail.data.msg, activePartIDs)
+  next.part = copyMap(detail.data.part, activePartIDs)
+  next.text = copyMap(detail.data.text, activePartIDs)
+  next.sent = copyMap(detail.data.sent, activePartIDs)
+  next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item)))
+  next.echo = compactEchoMap(detail.data, messageIDs)
+  detail.data = next
+}
+
+function applyChildEvent(input: {
+  detail: DetailState
+  event: Event
+  thinking: boolean
+  limits: Record<string, number>
+}) {
+  const before = queueSnapshot(input.detail.data)
+  const out = reduceSessionData({
+    data: input.detail.data,
+    event: input.event,
+    sessionID: input.detail.sessionID,
+    thinking: input.thinking,
+    limits: input.limits,
+  })
+  const changed = appendCommits(input.detail, out.commits)
+  compactDetail(input.detail)
+
+  return changed || queueChanged(input.detail.data, before)
+}
+
+function knownSession(data: SubagentData, sessionID: string) {
+  return data.tabs.has(sessionID)
+}
+
+export function listSubagentPermissions(data: SubagentData) {
+  return [...data.details.values()].flatMap((detail) => detail.data.permissions)
+}
+
+export function listSubagentQuestions(data: SubagentData) {
+  return [...data.details.values()].flatMap((detail) => detail.data.questions)
+}
+
+export function createSubagentData(): SubagentData {
+  return {
+    tabs: new Map(),
+    details: new Map(),
+  }
+}
+
+function snapshotDetail(detail: DetailState) {
+  return {
+    sessionID: detail.sessionID,
+    commits: detail.frames.map((item) => item.commit),
+  }
+}
+
+export function listSubagentTabs(data: SubagentData) {
+  return [...data.tabs.values()].sort((a, b) => {
+    const active = Number(b.status === "running") - Number(a.status === "running")
+    if (active !== 0) {
+      return active
+    }
+
+    return b.lastUpdatedAt - a.lastUpdatedAt
+  })
+}
+
+function snapshotQueues(data: SubagentData) {
+  return {
+    permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)),
+    questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)),
+  }
+}
+
+function snapshotState(data: SubagentData, details: FooterSubagentState["details"]): FooterSubagentState {
+  return {
+    tabs: listSubagentTabs(data),
+    details,
+    ...snapshotQueues(data),
+  }
+}
+
+export function snapshotSubagentData(data: SubagentData): FooterSubagentState {
+  return snapshotState(
+    data,
+    Object.fromEntries([...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)])),
+  )
+}
+
+export function snapshotSelectedSubagentData(
+  data: SubagentData,
+  selectedSessionID: string | undefined,
+): FooterSubagentState {
+  const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined
+
+  return snapshotState(data, detail ? { [detail.sessionID]: snapshotDetail(detail) } : {})
+}
+
+export function bootstrapSubagentData(input: BootstrapSubagentInput) {
+  const child = new Map(input.children.map((item) => [item.id, item]))
+  const children = new Set(child.keys())
+  let changed = false
+
+  for (const message of input.messages) {
+    for (const part of message.parts) {
+      if (part.type !== "tool") {
+        continue
+      }
+
+      changed = syncTaskTab(input.data, part, children) || changed
+    }
+  }
+
+  for (const item of input.permissions) {
+    if (!children.has(item.sessionID)) {
+      continue
+    }
+
+    changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed
+  }
+
+  for (const item of input.questions) {
+    if (!children.has(item.sessionID)) {
+      continue
+    }
+
+    changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed
+  }
+
+  for (const sessionID of input.data.tabs.keys()) {
+    const detail = ensureDetail(input.data, sessionID)
+    const before = queueSnapshot(detail.data)
+
+    bootstrapSessionData({
+      data: detail.data,
+      messages: [],
+      permissions: input.permissions
+        .filter((item) => item.sessionID === sessionID)
+        .sort((a, b) => a.id.localeCompare(b.id)),
+      questions: input.questions
+        .filter((item) => item.sessionID === sessionID)
+        .sort((a, b) => a.id.localeCompare(b.id)),
+    })
+    compactDetail(detail)
+
+    changed = queueChanged(detail.data, before) || changed
+  }
+
+  return changed
+}
+
+export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) {
+  if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
+    return false
+  }
+
+  const detail = ensureDetail(input.data, input.sessionID)
+  const before = queueSnapshot(detail.data)
+  const beforeCallCount = detail.data.call.size
+  bootstrapSessionData({
+    data: detail.data,
+    messages: input.messages,
+    permissions: detail.data.permissions,
+    questions: detail.data.questions,
+  })
+  compactDetail(detail)
+
+  return beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
+}
+
+export function clearFinishedSubagents(data: SubagentData) {
+  let changed = false
+
+  for (const [sessionID, tab] of data.tabs.entries()) {
+    if (tab.status === "running") {
+      continue
+    }
+
+    data.tabs.delete(sessionID)
+    data.details.delete(sessionID)
+    changed = true
+  }
+
+  return changed
+}
+
+export function reduceSubagentData(input: {
+  data: SubagentData
+  event: Event
+  sessionID: string
+  thinking: boolean
+  limits: Record<string, number>
+}) {
+  const event = input.event
+
+  if (event.type === "message.part.updated") {
+    const part = event.properties.part
+    if (part.sessionID === input.sessionID) {
+      if (part.type !== "tool") {
+        return false
+      }
+
+      return syncTaskTab(input.data, part)
+    }
+  }
+
+  const sessionID =
+    event.type === "message.updated" ||
+    event.type === "message.part.delta" ||
+    event.type === "permission.asked" ||
+    event.type === "permission.replied" ||
+    event.type === "question.asked" ||
+    event.type === "question.replied" ||
+    event.type === "question.rejected" ||
+    event.type === "session.error" ||
+    event.type === "session.status"
+      ? event.properties.sessionID
+      : event.type === "message.part.updated"
+        ? event.properties.part.sessionID
+        : undefined
+
+  if (!sessionID || !knownSession(input.data, sessionID)) {
+    return false
+  }
+
+  const detail = ensureDetail(input.data, sessionID)
+  if (event.type === "session.status") {
+    if (event.properties.status.type !== "retry") {
+      return false
+    }
+
+    return appendCommits(detail, [
+      {
+        kind: "error",
+        text: event.properties.status.message,
+        phase: "start",
+        source: "system",
+        messageID: `retry:${event.properties.status.attempt}`,
+      },
+    ])
+  }
+
+  if (event.type === "session.error" && event.properties.error) {
+    return appendCommits(detail, [
+      {
+        kind: "error",
+        text: formatError(event.properties.error),
+        phase: "start",
+        source: "system",
+        messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
+      },
+    ])
+  }
+
+  return applyChildEvent({
+    detail,
+    event,
+    thinking: input.thinking,
+    limits: input.limits,
+  })
+}

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

@@ -0,0 +1,275 @@
+// 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
+  shade: ColorInput
+  surface: ColorInput
+  pane: ColorInput
+  border: ColorInput
+  line: ColorInput
+}
+
+export type RunBlockTheme = {
+  text: ColorInput
+  muted: ColorInput
+  syntax?: SyntaxStyle
+  subtleSyntax?: SyntaxStyle
+  diffAdded: ColorInput
+  diffRemoved: ColorInput
+  diffAddedBg: ColorInput
+  diffRemovedBg: ColorInput
+  diffContextBg: ColorInput
+  diffHighlightAdded: ColorInput
+  diffHighlightRemoved: ColorInput
+  diffLineNumber: ColorInput
+  diffAddedLineNumberBg: ColorInput
+  diffRemovedLineNumberBg: ColorInput
+}
+
+export type RunTheme = {
+  background: ColorInput
+  footer: RunFooterTheme
+  entry: RunEntryTheme
+  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 blend(color: RGBA, bg: RGBA): RGBA {
+  if (color.a >= 1) {
+    return color
+  }
+
+  return RGBA.fromValues(
+    bg.r + (color.r - bg.r) * color.a,
+    bg.g + (color.g - bg.g) * color.a,
+    bg.b + (color.b - bg.b) * color.a,
+    1,
+  )
+}
+
+export function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
+  if (!style) {
+    return undefined
+  }
+
+  return SyntaxStyle.fromStyles(
+    Object.fromEntries(
+      [...style.getAllStyles()].map(([name, value]) => [
+        name,
+        {
+          ...value,
+          fg: value.fg ? blend(value.fg, bg) : value.fg,
+          bg: value.bg ? blend(value.bg, bg) : value.bg,
+        },
+      ]),
+    ),
+  )
+}
+
+function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: SyntaxStyle): RunTheme {
+  const bg = theme.background
+  const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, bg)
+  subtleSyntax?.destroy()
+  const pane = theme.backgroundElement
+  const shade = fade(pane, bg, 0.12, 0.56, 0.72)
+  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,
+      shade,
+      surface,
+      pane,
+      border: theme.border,
+      line,
+    },
+    entry: {
+      system: {
+        body: theme.textMuted,
+      },
+      user: {
+        body: theme.primary,
+      },
+      assistant: {
+        body: theme.text,
+      },
+      reasoning: {
+        body: theme.textMuted,
+      },
+      tool: {
+        body: theme.text,
+        start: theme.textMuted,
+      },
+      error: {
+        body: theme.error,
+      },
+    },
+    block: {
+      text: theme.text,
+      muted: theme.textMuted,
+      syntax,
+      subtleSyntax: opaqueSubtleSyntax,
+      diffAdded: theme.diffAdded,
+      diffRemoved: theme.diffRemoved,
+      diffAddedBg: theme.diffAddedBg,
+      diffRemovedBg: theme.diffRemovedBg,
+      diffContextBg: theme.diffContextBg,
+      diffHighlightAdded: theme.diffHighlightAdded,
+      diffHighlightRemoved: theme.diffHighlightRemoved,
+      diffLineNumber: theme.diffLineNumber,
+      diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
+      diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
+    },
+  }
+}
+
+const seed = {
+  highlight: rgba("#38bdf8"),
+  muted: rgba("#64748b"),
+  text: rgba("#f8fafc"),
+  panel: rgba("#0f172a"),
+  success: rgba("#22c55e"),
+  warning: rgba("#f59e0b"),
+  error: rgba("#ef4444"),
+}
+
+function tone(body: ColorInput, start?: ColorInput): Tone {
+  return {
+    body,
+    start,
+  }
+}
+
+export const RUN_THEME_FALLBACK: RunTheme = {
+  background: RGBA.fromValues(0, 0, 0, 0),
+  footer: {
+    highlight: seed.highlight,
+    warning: seed.warning,
+    success: seed.success,
+    error: seed.error,
+    muted: seed.muted,
+    text: seed.text,
+    shade: alpha(seed.panel, 0.68),
+    surface: alpha(seed.panel, 0.86),
+    pane: seed.panel,
+    border: seed.muted,
+    line: alpha(seed.panel, 0.96),
+  },
+  entry: {
+    system: tone(seed.muted),
+    user: tone(seed.highlight),
+    assistant: tone(seed.text),
+    reasoning: tone(seed.muted),
+    tool: tone(seed.text, seed.muted),
+    error: tone(seed.error),
+  },
+  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), mod.generateSubtleSyntax(theme))
+    } catch {
+      return map(theme)
+    }
+  } catch {
+    return RUN_THEME_FALLBACK
+  }
+}

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

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

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

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

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

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

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

@@ -0,0 +1,200 @@
+// Model variant resolution and persistence.
+//
+// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
+// Resolution priority: CLI --variant flag > saved preference > session history.
+//
+// The saved variant persists across sessions in ~/.local/state/opencode/model.json
+// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
+// variant and the persisted file.
+import path from "path"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Context, Effect, Layer } from "effect"
+import { makeRuntime } from "@/effect/run-service"
+import { Global } from "@/global"
+import { isRecord } from "@/util/record"
+import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
+import type { RunInput } from "./types"
+
+const MODEL_FILE = path.join(Global.Path.state, "model.json")
+
+type ModelState = Record<string, unknown> & {
+  variant?: Record<string, string | undefined>
+}
+type VariantService = {
+  readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined>
+  readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void>
+}
+type VariantRuntime = {
+  resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined>
+  saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
+}
+
+class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
+
+function modelKey(provider: string, model: string): string {
+  return `${provider}/${model}`
+}
+
+function variantKey(model: NonNullable<RunInput["model"]>): string {
+  return modelKey(model.providerID, model.modelID)
+}
+
+export function formatModelLabel(model: NonNullable<RunInput["model"]>, variant: string | undefined): string {
+  const label = variant ? ` · ${variant}` : ""
+  return `${model.modelID} · ${model.providerID}${label}`
+}
+
+export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
+  if (variants.length === 0) {
+    return undefined
+  }
+
+  if (!current) {
+    return variants[0]
+  }
+
+  const idx = variants.indexOf(current)
+  if (idx === -1 || idx === variants.length - 1) {
+    return undefined
+  }
+
+  return variants[idx + 1]
+}
+
+export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
+  return sessionVariant(Array.isArray(input) ? createSession(input) : input, model)
+}
+
+function fitVariant(value: string | undefined, variants: string[]): string | undefined {
+  if (!value) {
+    return undefined
+  }
+
+  if (variants.length === 0 || variants.includes(value)) {
+    return value
+  }
+
+  return undefined
+}
+
+// Picks the active variant. CLI flag wins, then saved preference, then session
+// history. fitVariant() checks saved and session values against the available
+// variants list -- if the provider doesn't offer a variant, it drops.
+export function resolveVariant(
+  input: string | undefined,
+  session: string | undefined,
+  saved: string | undefined,
+  variants: string[],
+): string | undefined {
+  if (input !== undefined) {
+    return input
+  }
+
+  const fallback = fitVariant(saved, variants)
+  const current = fitVariant(session, variants)
+  if (current !== undefined) {
+    return current
+  }
+
+  return fallback
+}
+
+function state(value: unknown): ModelState {
+  if (!isRecord(value)) {
+    return {}
+  }
+
+  const variant = isRecord(value.variant)
+    ? Object.fromEntries(
+        Object.entries(value.variant).flatMap(([key, item]) => {
+          if (typeof item !== "string") {
+            return []
+          }
+
+          return [[key, item] as const]
+        }),
+      )
+    : undefined
+
+  return {
+    ...value,
+    variant,
+  }
+}
+
+function createLayer(fs = AppFileSystem.defaultLayer) {
+  return Layer.fresh(
+    Layer.effect(
+      Service,
+      Effect.gen(function* () {
+        const file = yield* AppFileSystem.Service
+
+        const read = Effect.fn("RunVariant.read")(function* () {
+          return yield* file.readJson(MODEL_FILE).pipe(
+            Effect.map(state),
+            Effect.catchCause(() => Effect.succeed(state(undefined))),
+          )
+        })
+
+        const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
+          if (!model) {
+            return undefined
+          }
+
+          return (yield* read()).variant?.[variantKey(model)]
+        })
+
+        const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
+          model: RunInput["model"],
+          variant: string | undefined,
+        ) {
+          if (!model) {
+            return
+          }
+
+          const current = yield* read()
+          const next = {
+            ...current.variant,
+          }
+          const key = variantKey(model)
+          if (variant) {
+            next[key] = variant
+          }
+
+          if (!variant) {
+            delete next[key]
+          }
+
+          yield* file.writeJson(MODEL_FILE, {
+            ...current,
+            variant: next,
+          }).pipe(Effect.orElseSucceed(() => undefined))
+        })
+
+        return Service.of({
+          resolveSavedVariant,
+          saveVariant,
+        })
+      }),
+    ).pipe(Layer.provide(fs)),
+  )
+}
+
+/** @internal Exported for testing. */
+export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime {
+  const runtime = makeRuntime(Service, createLayer(fs))
+  return {
+    resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
+    saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
+  }
+}
+
+const runtime = createVariantRuntime()
+
+export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
+  return runtime.resolveSavedVariant(model)
+}
+
+export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
+  void runtime.saveVariant(model, variant)
+}

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

@@ -202,6 +202,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()

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

@@ -1,8 +1,8 @@
 import { cmd } from "../cmd"
 import { UI } from "@/cli/ui"
-import { tui } from "./app"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 import { TuiConfig } from "@/cli/cmd/tui/config/tui"
+import { Instance } from "@/project/instance"
 
 export const AttachCommand = cmd({
   command: "attach <url>",
@@ -64,8 +64,12 @@ export const AttachCommand = cmd({
         const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
         return { Authorization: auth }
       })()
-      const config = await TuiConfig.get()
-      await tui({
+      const config = await Instance.provide({
+        directory: process.cwd(),
+        fn: () => TuiConfig.get(),
+      })
+      const app = await import("./app")
+      await app.tui({
         url: args.url,
         config,
         args: {

+ 2 - 2
packages/opencode/src/cli/cmd/tui/component/spinner.tsx

@@ -5,7 +5,7 @@ import type { JSX } from "@opentui/solid"
 import type { RGBA } from "@opentui/core"
 import "opentui-spinner/solid"
 
-const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
+export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
 
 export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
   const { theme } = useTheme()
@@ -14,7 +14,7 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
   return (
     <Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
       <box flexDirection="row" gap={1}>
-        <spinner frames={frames} interval={80} color={color()} />
+        <spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
         <Show when={props.children}>
           <text fg={color()}>{props.children}</text>
         </Show>

+ 60 - 8
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -513,7 +513,35 @@ 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 {
+function luminance(color: RGBA) {
+  return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
+}
+
+function chroma(color: RGBA) {
+  return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
+}
+
+function pickPrimaryColor(
+  bg: RGBA,
+  candidates: Array<{
+    key: string
+    color: RGBA | undefined
+  }>,
+) {
+  return candidates
+    .flatMap((item) => {
+      if (!item.color) return []
+      const contrast = Math.abs(luminance(item.color) - luminance(bg))
+      const vivid = chroma(item.color)
+      if (contrast < 0.16 || vivid < 0.12) return []
+      return [{ key: item.key, color: item.color, score: vivid * 1.5 + contrast }]
+    })
+    .sort((a, b) => b.score - a.score)[0]
+}
+
+// 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)
@@ -550,13 +578,37 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
   const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
   const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha)
   const diffLineNumber = textMuted
+  // The generated system theme also feeds the run footer highlight, so prefer
+  // the terminal's own cursor/selection accent when it stays legible.
+  const primary =
+    pickPrimaryColor(bg, [
+      {
+        key: "cursor",
+        color: colors.cursorColor ? RGBA.fromHex(colors.cursorColor) : undefined,
+      },
+      {
+        key: "selection",
+        color: colors.highlightBackground ? RGBA.fromHex(colors.highlightBackground) : undefined,
+      },
+      {
+        key: "blue",
+        color: ansiColors.blue,
+      },
+      {
+        key: "magenta",
+        color: ansiColors.magenta,
+      },
+    ]) ?? {
+      key: "blue",
+      color: ansiColors.blue,
+    }
 
   return {
     theme: {
-      // Primary colors using ANSI
-      primary: ansiColors.cyan,
-      secondary: ansiColors.magenta,
-      accent: ansiColors.cyan,
+      // Fall back to blue/magenta when the terminal UI colors are too muted.
+      primary: primary.color,
+      secondary: primary.key === "magenta" ? ansiColors.blue : ansiColors.magenta,
+      accent: primary.color,
 
       // Status colors using ANSI
       error: ansiColors.red,
@@ -709,11 +761,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) {
+export function generateSubtleSyntax(theme: TuiThemeCurrent) {
   const rules = getSyntaxRules(theme)
   return SyntaxStyle.fromTheme(
     rules.map((rule) => {
@@ -737,7 +789,7 @@ function generateSubtleSyntax(theme: Theme) {
   )
 }
 
-function getSyntaxRules(theme: Theme) {
+function getSyntaxRules(theme: TuiThemeCurrent) {
   return [
     {
       scope: ["default"],

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

@@ -1,5 +1,4 @@
 import { cmd } from "@/cli/cmd/cmd"
-import { tui } from "./app"
 import { Rpc } from "@/util"
 import { type rpc } from "./worker"
 import path from "path"
@@ -180,13 +179,15 @@ export const TuiThreadCommand = cmd({
       }
 
       const prompt = await input(args.prompt)
-      const config = await TuiConfig.get()
+      const config = await Instance.provide({
+        directory: cwd,
+        fn: () => TuiConfig.get(),
+      })
 
       const network = await Instance.provide({
         directory: cwd,
         fn: () => resolveNetworkOptionsNoConfig(args),
       })
-
       const external =
         process.argv.includes("--port") ||
         process.argv.includes("--hostname") ||
@@ -212,7 +213,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")
@@ -241,4 +243,3 @@ export const TuiThreadCommand = cmd({
     process.exit(0)
   },
 })
-// scratch

+ 12 - 3
packages/opencode/src/tool/lsp.ts

@@ -26,7 +26,6 @@ export const LspTool = Tool.define(
   Effect.gen(function* () {
     const lsp = yield* LSP.Service
     const fs = yield* AppFileSystem.Service
-
     return {
       description: DESCRIPTION,
       parameters: z.object({
@@ -42,7 +41,17 @@ export const LspTool = Tool.define(
         Effect.gen(function* () {
           const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
           yield* assertExternalDirectoryEffect(ctx, file)
-          yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
+          yield* ctx.ask({
+            permission: "lsp",
+            patterns: ["*"],
+            always: ["*"],
+            metadata: {
+              operation: args.operation,
+              filePath: file,
+              line: args.line,
+              character: args.character,
+            },
+          })
 
           const uri = pathToFileURL(file).href
           const position = { file, line: args.line - 1, character: args.character - 1 }
@@ -85,7 +94,7 @@ export const LspTool = Tool.define(
             metadata: { result },
             output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
           }
-        }),
+        }).pipe(Effect.orDie),
     }
   }),
 )

+ 392 - 0
packages/opencode/test/cli/run/entry.body.test.ts

@@ -0,0 +1,392 @@
+import { describe, expect, test } from "bun:test"
+import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body"
+import type { StreamCommit } from "@/cli/cmd/run/types"
+
+function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
+  return input
+}
+
+describe("run entry body", () => {
+  test("renders assistant progress as markdown", () => {
+    expect(
+      entryBody(
+        commit({
+          kind: "assistant",
+          text: "# Title\n\nHello **world**",
+          phase: "progress",
+          source: "assistant",
+          partID: "part-1",
+        }),
+      ),
+    ).toEqual({
+      type: "markdown",
+      content: "# Title\n\nHello **world**",
+    })
+  })
+
+  test("renders reasoning as markdown-highlighted code like the tui", () => {
+    const body = entryBody(
+      commit({
+        kind: "reasoning",
+        text: "Thinking: plan next steps",
+        phase: "progress",
+        source: "reasoning",
+        partID: "reason-1",
+      }),
+    )
+
+    expect(body).toEqual({
+      type: "code",
+      filetype: "markdown",
+      content: "_Thinking:_ plan next steps",
+    })
+    expect(entryCanStream(commit({ kind: "reasoning", text: "Thinking: plan next steps", phase: "progress", source: "reasoning" }), body)).toBe(true)
+  })
+
+  test("prefixes user entries in text mode", () => {
+    expect(
+      entryBody(
+        commit({
+          kind: "user",
+          text: "Inspect footer tabs",
+          phase: "start",
+          source: "system",
+        }),
+      ),
+    ).toEqual({
+      type: "text",
+      content: "› Inspect footer tabs",
+    })
+  })
+
+  test("keeps completed write tool finals structured", () => {
+    const body = entryBody(
+      commit({
+        kind: "tool",
+        text: "",
+        phase: "final",
+        source: "tool",
+        tool: "write",
+        toolState: "completed",
+        part: {
+          id: "tool-1",
+          sessionID: "session-1",
+          messageID: "msg-1",
+          type: "tool",
+          callID: "call-1",
+          tool: "write",
+          state: {
+            status: "completed",
+            input: {
+              filePath: "src/a.ts",
+              content: "const x = 1\n",
+            },
+            metadata: {},
+            time: {
+              start: 1,
+              end: 2,
+            },
+          },
+        } as never,
+      }),
+    )
+
+    expect(body.type).toBe("structured")
+    if (body.type !== "structured") {
+      throw new Error("expected structured body")
+    }
+
+    expect(body.snapshot).toEqual({
+      kind: "code",
+      title: "# Wrote src/a.ts",
+      content: "const x = 1\n",
+      file: "src/a.ts",
+    })
+    expect(entryDone(
+      commit({
+        kind: "tool",
+        text: "output",
+        phase: "progress",
+        source: "tool",
+        tool: "bash",
+        toolState: "completed",
+      }),
+    )).toBe(true)
+  })
+
+  test("keeps completed edit tool finals structured", () => {
+    const body = entryBody(
+      commit({
+        kind: "tool",
+        text: "",
+        phase: "final",
+        source: "tool",
+        tool: "edit",
+        toolState: "completed",
+        part: {
+          id: "tool-2",
+          sessionID: "session-1",
+          messageID: "msg-2",
+          type: "tool",
+          callID: "call-2",
+          tool: "edit",
+          state: {
+            status: "completed",
+            input: {
+              filePath: "src/a.ts",
+            },
+            metadata: {
+              diff: "@@ -1 +1 @@\n-old\n+new\n",
+            },
+            time: {
+              start: 1,
+              end: 2,
+            },
+          },
+        } as never,
+      }),
+    )
+
+    expect(body.type).toBe("structured")
+    if (body.type !== "structured") {
+      throw new Error("expected structured body")
+    }
+
+    expect(body.snapshot).toEqual({
+      kind: "diff",
+      items: [
+        {
+          title: "# Edited src/a.ts",
+          diff: "@@ -1 +1 @@\n-old\n+new\n",
+          file: "src/a.ts",
+        },
+      ],
+    })
+  })
+
+  test("keeps completed apply_patch tool finals structured", () => {
+    const body = entryBody(
+      commit({
+        kind: "tool",
+        text: "",
+        phase: "final",
+        source: "tool",
+        tool: "apply_patch",
+        toolState: "completed",
+        part: {
+          id: "tool-3",
+          sessionID: "session-1",
+          messageID: "msg-3",
+          type: "tool",
+          callID: "call-3",
+          tool: "apply_patch",
+          state: {
+            status: "completed",
+            input: {},
+            metadata: {
+              files: [
+                {
+                  type: "update",
+                  filePath: "src/a.ts",
+                  relativePath: "src/a.ts",
+                  patch: "@@ -1 +1 @@\n-old\n+new\n",
+                },
+              ],
+            },
+            time: {
+              start: 1,
+              end: 2,
+            },
+          },
+        } as never,
+      }),
+    )
+
+    expect(body.type).toBe("structured")
+    if (body.type !== "structured") {
+      throw new Error("expected structured body")
+    }
+
+    expect(body.snapshot).toEqual({
+      kind: "diff",
+      items: [
+        {
+          title: "# Patched src/a.ts",
+          diff: "@@ -1 +1 @@\n-old\n+new\n",
+          file: "src/a.ts",
+          deletions: 0,
+        },
+      ],
+    })
+  })
+
+  test("keeps running task tool state out of scrollback", () => {
+    expect(
+      entryBody(
+        commit({
+          kind: "tool",
+          text: "running inspect reducer",
+          phase: "start",
+          source: "tool",
+          tool: "task",
+          toolState: "running",
+          part: {
+            id: "task-1",
+            sessionID: "session-1",
+            messageID: "msg-1",
+            type: "tool",
+            callID: "call-1",
+            tool: "task",
+            state: {
+              status: "running",
+              input: {
+                description: "Inspect reducer",
+                subagent_type: "explore",
+              },
+            },
+          } as never,
+        }),
+      ),
+    ).toEqual({
+      type: "none",
+    })
+  })
+
+  test("renders completed task tool finals from promoted task results", () => {
+    expect(
+      entryBody(
+        commit({
+          kind: "tool",
+          text: "",
+          phase: "final",
+          source: "tool",
+          tool: "task",
+          toolState: "completed",
+          part: {
+            id: "task-1",
+            sessionID: "session-1",
+            messageID: "msg-1",
+            type: "tool",
+            callID: "call-1",
+            tool: "task",
+            state: {
+              status: "completed",
+              input: {
+                description: "Inspect reducer",
+                subagent_type: "explore",
+              },
+              output: [
+                "task_id: child-1 (for resuming to continue this task if needed)",
+                "",
+                "<task_result>",
+                "# Findings\n\n- Footer stays live",
+                "</task_result>",
+              ].join("\n"),
+              metadata: {
+                sessionId: "child-1",
+              },
+              time: {
+                start: 1,
+                end: 2,
+              },
+            },
+          } as never,
+        }),
+      ),
+    ).toEqual({
+      type: "markdown",
+      content: "# Findings\n\n- Footer stays live",
+    })
+  })
+
+  test("falls back to structured task final when task result is empty", () => {
+    const body = entryBody(
+      commit({
+        kind: "tool",
+        text: "",
+        phase: "final",
+        source: "tool",
+        tool: "task",
+        toolState: "completed",
+        part: {
+          id: "task-1",
+          sessionID: "session-1",
+          messageID: "msg-1",
+          type: "tool",
+          callID: "call-1",
+          tool: "task",
+          state: {
+            status: "completed",
+            input: {
+              description: "Inspect reducer",
+              subagent_type: "explore",
+            },
+            output: [
+              "task_id: child-1 (for resuming to continue this task if needed)",
+              "",
+              "<task_result>",
+              "",
+              "</task_result>",
+            ].join("\n"),
+            metadata: {
+              sessionId: "child-1",
+            },
+            time: {
+              start: 1,
+              end: 2,
+            },
+          },
+        } as never,
+      }),
+    )
+
+    expect(body.type).toBe("structured")
+    if (body.type !== "structured") {
+      throw new Error("expected structured body")
+    }
+
+    expect(body.snapshot).toEqual({
+      kind: "task",
+      title: "# Explore Task",
+      rows: ["Inspect reducer"],
+      tail: "",
+    })
+  })
+
+  test("streams tool progress text", () => {
+    const body = entryBody(
+      commit({
+        kind: "tool",
+        text: "partial output",
+        phase: "progress",
+        source: "tool",
+        tool: "bash",
+        partID: "tool-2",
+      }),
+    )
+
+    expect(body).toEqual({
+      type: "text",
+      content: "partial output",
+    })
+    expect(entryCanStream(commit({ kind: "tool", text: "partial output", phase: "progress", source: "tool", tool: "bash" }), body)).toBe(true)
+  })
+
+  test("renders interrupted assistant finals as text", () => {
+    expect(
+      entryBody(
+        commit({
+          kind: "assistant",
+          text: "",
+          phase: "final",
+          source: "assistant",
+          interrupted: true,
+          partID: "part-1",
+        }),
+      ),
+    ).toEqual({
+      type: "text",
+      content: "assistant interrupted",
+    })
+  })
+})

+ 224 - 0
packages/opencode/test/cli/run/footer.test.ts

@@ -0,0 +1,224 @@
+import { afterEach, expect, test } from "bun:test"
+import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
+import { RunFooter } from "@/cli/cmd/run/footer"
+import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
+
+const decoder = new TextDecoder()
+const active: Array<{ footer?: RunFooter; renderer: TestRenderer }> = []
+
+afterEach(() => {
+  for (const item of active.splice(0)) {
+    item.footer?.destroy()
+    item.renderer.destroy()
+  }
+})
+
+function createFooter(renderer: TestRenderer) {
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  return new RunFooter(renderer, {
+    directory: "/tmp",
+    findFiles: async () => [],
+    agents: [],
+    resources: [],
+    sessionID: () => "session-1",
+    agentLabel: "Build",
+    modelLabel: "Model default",
+    first: false,
+    history: [],
+    theme: RUN_THEME_FALLBACK,
+    keybinds: {
+      leader: "",
+      variantCycle: "tab",
+      interrupt: "esc",
+      historyPrevious: "up",
+      historyNext: "down",
+      inputSubmit: "enter",
+      inputNewline: "shift+enter",
+    },
+    diffStyle: "auto",
+    onPermissionReply: () => { },
+    onQuestionReply: () => { },
+    onQuestionReject: () => { },
+    treeSitterClient,
+  })
+}
+
+test("run footer class loads", () => {
+  expect(typeof RunFooter).toBe("function")
+})
+
+test("run footer finalizes streamed markdown tables when the turn goes idle", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    height: 24,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  const footer = createFooter(out.renderer)
+  active.push({ footer, renderer: out.renderer })
+  const lib = Reflect.get(out.renderer, "lib") as {
+    commitSplitFooterSnapshot: (...args: unknown[]) => unknown
+  }
+  const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib)
+  let payload = ""
+
+  lib.commitSplitFooterSnapshot = (...args: unknown[]) => {
+    const snapshot = args[1] as {
+      getRealCharBytes(addLineBreaks?: boolean): Uint8Array
+    }
+    payload += decoder.decode(snapshot.getRealCharBytes(true))
+    return originalCommitSplitFooterSnapshot(...args)
+  }
+
+  try {
+    footer.event({ type: "turn.send", queue: 0 })
+
+    const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
+    for (const chunk of text) {
+      footer.append({
+        kind: "assistant",
+        text: chunk,
+        phase: "progress",
+        source: "assistant",
+        messageID: "msg-1",
+        partID: "part-1",
+      })
+    }
+
+    footer.event({ type: "turn.idle", queue: 0 })
+    await footer.idle()
+
+    expect(payload).toContain("Column 1")
+    expect(payload).toContain("Row 2")
+    expect(payload).toContain("Value 4")
+  } finally {
+    lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
+  }
+})
+
+test("run footer keeps active streamed assistant content across width resize", async () => {
+  const out = await createTestRenderer({
+    width: 40,
+    height: 24,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  const footer = createFooter(out.renderer)
+  active.push({ footer, renderer: out.renderer })
+  const lib = Reflect.get(out.renderer, "lib") as {
+    commitSplitFooterSnapshot: (...args: unknown[]) => unknown
+  }
+  const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib)
+  let payload = ""
+
+  lib.commitSplitFooterSnapshot = (...args: unknown[]) => {
+    const snapshot = args[1] as {
+      getRealCharBytes(addLineBreaks?: boolean): Uint8Array
+    }
+    payload += decoder.decode(snapshot.getRealCharBytes(true))
+    return originalCommitSplitFooterSnapshot(...args)
+  }
+
+  try {
+    footer.event({ type: "turn.send", queue: 0 })
+
+    footer.append({
+      kind: "assistant",
+      text: "This paragraph only existed in the active surface until finalization.",
+      phase: "progress",
+      source: "assistant",
+      messageID: "msg-2",
+      partID: "part-2",
+    })
+
+    out.resize(60, 24)
+
+    footer.event({ type: "turn.idle", queue: 0 })
+    await footer.idle()
+
+    expect(payload.replace(/\s+/g, " ").trim()).toContain(
+      "This paragraph only existed in the active surface until finalization.",
+    )
+  } finally {
+    lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
+  }
+})
+
+test("run footer keeps tool start rows tight with following reasoning", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    height: 24,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  const footer = createFooter(out.renderer)
+  active.push({ footer, renderer: out.renderer })
+  const lib = Reflect.get(out.renderer, "lib") as {
+    commitSplitFooterSnapshot: (...args: unknown[]) => unknown
+  }
+  const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib)
+  const payloads: string[] = []
+
+  lib.commitSplitFooterSnapshot = (...args) => {
+    const snapshot = args[1] as {
+      getRealCharBytes(addLineBreaks?: boolean): Uint8Array
+    }
+    payloads.push(decoder.decode(snapshot.getRealCharBytes(true)))
+    return originalCommitSplitFooterSnapshot(...args)
+  }
+
+  try {
+    footer.append({
+      kind: "tool",
+      source: "tool",
+      messageID: "msg-tool",
+      partID: "part-tool",
+      tool: "glob",
+      phase: "start",
+      text: "running glob",
+      toolState: "running",
+      part: {
+        id: "part-tool",
+        type: "tool",
+        tool: "glob",
+        callID: "call-tool",
+        messageID: "msg-tool",
+        sessionID: "session-1",
+        state: {
+          status: "running",
+          input: {
+            pattern: "**/run.ts",
+          },
+          time: {
+            start: Date.now(),
+          },
+        },
+      },
+    })
+    footer.append({
+      kind: "reasoning",
+      source: "reasoning",
+      messageID: "msg-reasoning",
+      partID: "part-reasoning",
+      phase: "progress",
+      text: "Thinking:    Found it.",
+    })
+
+    await footer.idle()
+
+    const rows = payloads.map((item) => item.replace(/ +/g, " ").trim())
+
+    expect(payloads).toHaveLength(3)
+    expect(rows).toEqual(['✱ Glob "**/run.ts"', "", "_Thinking:_ Found it."])
+  } finally {
+    lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
+  }
+})

+ 96 - 0
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -0,0 +1,96 @@
+/** @jsxImportSource @opentui/solid */
+import { expect, test } from "bun:test"
+import { testRender } from "@opentui/solid"
+import { createSignal } from "solid-js"
+import { RunEntryContent, separatorRows } from "@/cli/cmd/run/scrollback.writer"
+import { RunFooterView } from "@/cli/cmd/run/footer.view"
+import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
+import type { StreamCommit } from "@/cli/cmd/run/types"
+
+test("run footer view loads", () => {
+  expect(typeof RunFooterView).toBe("function")
+})
+
+test("run entry content updates when live commit text changes", async () => {
+  const [commit, setCommit] = createSignal<StreamCommit>({
+    kind: "tool",
+    text: "I",
+    phase: "progress",
+    source: "tool",
+    messageID: "msg-1",
+    partID: "part-1",
+    tool: "bash",
+  })
+
+  const app = await testRender(() => (
+    <box width={80} height={4}>
+      <RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} width={80} />
+    </box>
+  ), {
+    width: 80,
+    height: 4,
+  })
+
+  try {
+    await app.renderOnce()
+    expect(app.captureCharFrame()).toContain("I")
+
+    setCommit({
+      kind: "tool",
+      text: "I need to inspect the codebase",
+      phase: "progress",
+      source: "tool",
+      messageID: "msg-1",
+      partID: "part-1",
+      tool: "bash",
+    })
+    await app.renderOnce()
+
+    expect(app.captureCharFrame()).toContain("I need to inspect the codebase")
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("subagent rows use shared separator rules", async () => {
+  const commits: StreamCommit[] = [
+    {
+      kind: "tool",
+      source: "tool",
+      messageID: "msg-tool",
+      partID: "part-tool",
+      tool: "glob",
+      phase: "start",
+      text: "running glob",
+      toolState: "running",
+      part: {
+        id: "part-tool",
+        type: "tool",
+        tool: "glob",
+        callID: "call-tool",
+        messageID: "msg-tool",
+        sessionID: "session-1",
+        state: {
+          status: "running",
+          input: {
+            pattern: "**/run.ts",
+          },
+          time: {
+            start: 1,
+          },
+        },
+      } as never,
+    },
+    {
+      kind: "reasoning",
+      source: "reasoning",
+      messageID: "msg-reasoning",
+      partID: "part-reasoning",
+      phase: "progress",
+      text: "Thinking:  Found it.",
+    },
+  ]
+
+  expect(separatorRows(undefined, commits[0]!)).toBe(0)
+  expect(separatorRows(commits[0], commits[1]!)).toBe(1)
+})

+ 144 - 0
packages/opencode/test/cli/run/permission.shared.test.ts

@@ -0,0 +1,144 @@
+import { describe, expect, test } from "bun:test"
+import type { PermissionRequest } from "@opencode-ai/sdk/v2"
+import {
+  createPermissionBodyState,
+  permissionAlwaysLines,
+  permissionCancel,
+  permissionEscape,
+  permissionInfo,
+  permissionReject,
+  permissionRun,
+} from "@/cli/cmd/run/permission.shared"
+
+function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
+  return {
+    id: "perm-1",
+    sessionID: "session-1",
+    permission: "read",
+    patterns: [],
+    metadata: {},
+    always: [],
+    ...input,
+  }
+}
+
+describe("run permission shared", () => {
+  test("replies immediately for allow once", () => {
+    const out = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "once")
+
+    expect(out.reply).toEqual({
+      requestID: "perm-1",
+      reply: "once",
+    })
+  })
+
+  test("requires confirmation for allow always", () => {
+    const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "always")
+    expect(next.state.stage).toBe("always")
+    expect(next.state.selected).toBe("confirm")
+    expect(next.reply).toBeUndefined()
+
+    expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({
+      requestID: "perm-1",
+      reply: "always",
+    })
+
+    expect(permissionRun(next.state, "perm-1", "cancel").state).toMatchObject({
+      stage: "permission",
+      selected: "always",
+    })
+  })
+
+  test("builds trimmed reject replies and stage transitions", () => {
+    const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "reject")
+    expect(next.state.stage).toBe("reject")
+
+    const out = permissionReject({ ...next.state, message: "  use rg  " }, "perm-1")
+    expect(out).toEqual({
+      requestID: "perm-1",
+      reply: "reject",
+      message: "use rg",
+    })
+
+    expect(permissionCancel(next.state)).toMatchObject({
+      stage: "permission",
+      selected: "reject",
+    })
+
+    expect(permissionEscape(createPermissionBodyState("perm-1"))).toMatchObject({
+      stage: "reject",
+      selected: "reject",
+    })
+
+    expect(permissionEscape({ ...next.state, stage: "always", selected: "confirm" })).toMatchObject({
+      stage: "permission",
+      selected: "always",
+    })
+  })
+
+  test("maps supported permission types into display info", () => {
+    expect(
+      permissionInfo(
+        req({
+          permission: "bash",
+          metadata: {
+            input: {
+              command: "git status --short",
+            },
+          },
+        }),
+      ),
+    ).toMatchObject({
+      title: "Shell command",
+      lines: ["$ git status --short"],
+    })
+
+    expect(
+      permissionInfo(
+        req({
+          permission: "task",
+          metadata: {
+            description: "investigate stream",
+            subagent_type: "general",
+          },
+        }),
+      ),
+    ).toMatchObject({
+      title: "General Task",
+      lines: ["◉ investigate stream"],
+    })
+
+    expect(
+      permissionInfo(
+        req({
+          permission: "external_directory",
+          patterns: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"],
+        }),
+      ),
+    ).toMatchObject({
+      title: "Access external directory /tmp/work",
+      lines: ["- /tmp/work/**/*.ts", "- /tmp/work/**/*.tsx"],
+    })
+
+    expect(permissionInfo(req({ permission: "doom_loop" }))).toMatchObject({
+      title: "Continue after repeated failures",
+    })
+
+    expect(permissionInfo(req({ permission: "custom_tool" }))).toMatchObject({
+      title: "Call tool custom_tool",
+      lines: ["Tool: custom_tool"],
+    })
+  })
+
+  test("formats always-allow copy for wildcard and explicit patterns", () => {
+    expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([
+      "This will allow bash until OpenCode is restarted.",
+    ])
+
+    expect(permissionAlwaysLines(req({ always: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([
+      "This will allow the following patterns until OpenCode is restarted.",
+      "- src/**/*.ts",
+      "- src/**/*.tsx",
+    ])
+  })
+})

+ 115 - 0
packages/opencode/test/cli/run/prompt.shared.test.ts

@@ -0,0 +1,115 @@
+import { describe, expect, test } from "bun:test"
+import {
+  createPromptHistory,
+  isExitCommand,
+  movePromptHistory,
+  printableBinding,
+  promptCycle,
+  promptInfo,
+  promptKeys,
+  pushPromptHistory,
+} from "@/cli/cmd/run/prompt.shared"
+import type { FooterKeybinds, RunPrompt } from "@/cli/cmd/run/types"
+
+const keybinds: FooterKeybinds = {
+  leader: "ctrl+x",
+  variantCycle: "ctrl+t,<leader>t",
+  interrupt: "escape",
+  historyPrevious: "up",
+  historyNext: "down",
+  inputSubmit: "return",
+  inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
+}
+
+function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
+  return { text, parts }
+}
+
+describe("run prompt shared", () => {
+  test("filters blank prompts and dedupes consecutive history", () => {
+    const out = createPromptHistory([prompt("   "), prompt("one"), prompt("one"), prompt("two"), prompt("one")])
+
+    expect(out.items.map((item) => item.text)).toEqual(["one", "two", "one"])
+    expect(out.index).toBeNull()
+    expect(out.draft).toBe("")
+  })
+
+  test("push ignores blanks and dedupes only the latest item", () => {
+    const base = createPromptHistory([prompt("one")])
+
+    expect(pushPromptHistory(base, prompt("   ")).items.map((item) => item.text)).toEqual(["one"])
+    expect(pushPromptHistory(base, prompt("one")).items.map((item) => item.text)).toEqual(["one"])
+    expect(pushPromptHistory(base, prompt("two")).items.map((item) => item.text)).toEqual(["one", "two"])
+  })
+
+  test("moves through history only at input boundaries and restores draft", () => {
+    const base = createPromptHistory([prompt("one"), prompt("two")])
+
+    expect(movePromptHistory(base, -1, "draft", 1)).toEqual({
+      state: base,
+      apply: false,
+    })
+
+    const up = movePromptHistory(base, -1, "draft", 0)
+    expect(up.apply).toBe(true)
+    expect(up.text).toBe("two")
+    expect(up.cursor).toBe(0)
+    expect(up.state.index).toBe(1)
+    expect(up.state.draft).toBe("draft")
+
+    const older = movePromptHistory(up.state, -1, "two", 0)
+    expect(older.apply).toBe(true)
+    expect(older.text).toBe("one")
+    expect(older.cursor).toBe(0)
+    expect(older.state.index).toBe(0)
+
+    const newer = movePromptHistory(older.state, 1, "one", 3)
+    expect(newer.apply).toBe(true)
+    expect(newer.text).toBe("two")
+    expect(newer.cursor).toBe(3)
+    expect(newer.state.index).toBe(1)
+
+    const draft = movePromptHistory(newer.state, 1, "two", 3)
+    expect(draft.apply).toBe(true)
+    expect(draft.text).toBe("draft")
+    expect(draft.cursor).toBe(5)
+    expect(draft.state.index).toBeNull()
+  })
+
+  test("handles direct and leader-based variant cycling", () => {
+    const keys = promptKeys(keybinds)
+
+    expect(promptCycle(false, promptInfo({ name: "x", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
+      arm: true,
+      clear: false,
+      cycle: false,
+      consume: true,
+    })
+
+    expect(promptCycle(true, promptInfo({ name: "t" }), keys.leaders, keys.cycles)).toEqual({
+      arm: false,
+      clear: true,
+      cycle: true,
+      consume: true,
+    })
+
+    expect(promptCycle(false, promptInfo({ name: "t", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
+      arm: false,
+      clear: false,
+      cycle: true,
+      consume: true,
+    })
+  })
+
+  test("prints bindings with leader substitution and esc normalization", () => {
+    expect(printableBinding("<leader>t", "ctrl+x")).toBe("ctrl+x t")
+    expect(printableBinding("escape", "ctrl+x")).toBe("esc")
+    expect(printableBinding("", "ctrl+x")).toBe("")
+  })
+
+  test("recognizes exit commands", () => {
+    expect(isExitCommand("/exit")).toBe(true)
+    expect(isExitCommand(" /Quit ")).toBe(true)
+    expect(isExitCommand("/quit now")).toBe(false)
+  })
+})

+ 115 - 0
packages/opencode/test/cli/run/question.shared.test.ts

@@ -0,0 +1,115 @@
+import { describe, expect, test } from "bun:test"
+import type { QuestionRequest } from "@opencode-ai/sdk/v2"
+import {
+  createQuestionBodyState,
+  questionConfirm,
+  questionReject,
+  questionSave,
+  questionSelect,
+  questionSetSelected,
+  questionStoreCustom,
+  questionSubmit,
+  questionSync,
+} from "@/cli/cmd/run/question.shared"
+
+function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
+  return {
+    id: "question-1",
+    sessionID: "session-1",
+    questions: [
+      {
+        question: "Mode?",
+        header: "Mode",
+        options: [{ label: "chunked", description: "Incremental output" }],
+        multiple: false,
+      },
+    ],
+    ...input,
+  }
+}
+
+describe("run question shared", () => {
+  test("replies immediately for a single-select question", () => {
+    const out = questionSelect(createQuestionBodyState("question-1"), req())
+
+    expect(out.reply).toEqual({
+      requestID: "question-1",
+      answers: [["chunked"]],
+    })
+  })
+
+  test("advances multi-question flows and submits from confirm", () => {
+    const ask = req({
+      questions: [
+        {
+          question: "Mode?",
+          header: "Mode",
+          options: [{ label: "chunked", description: "Incremental output" }],
+          multiple: false,
+        },
+        {
+          question: "Output?",
+          header: "Output",
+          options: [
+            { label: "yes", description: "Show tool output" },
+            { label: "no", description: "Hide tool output" },
+          ],
+          multiple: false,
+        },
+      ],
+    })
+
+    let state = questionSelect(createQuestionBodyState("question-1"), ask).state
+    expect(state.tab).toBe(1)
+
+    state = questionSetSelected(state, 1)
+    state = questionSelect(state, ask).state
+    expect(questionConfirm(ask, state)).toBe(true)
+    expect(questionSubmit(ask, state)).toEqual({
+      requestID: "question-1",
+      answers: [["chunked"], ["no"]],
+    })
+  })
+
+  test("toggles answers for multiple-choice questions", () => {
+    const ask = req({
+      questions: [
+        {
+          question: "Tags?",
+          header: "Tags",
+          options: [{ label: "bug", description: "Bug fix" }],
+          multiple: true,
+        },
+      ],
+    })
+
+    let state = questionSelect(createQuestionBodyState("question-1"), ask).state
+    expect(state.answers).toEqual([["bug"]])
+
+    state = questionSelect(state, ask).state
+    expect(state.answers).toEqual([[]])
+  })
+
+  test("stores and submits custom answers", () => {
+    let state = questionSetSelected(createQuestionBodyState("question-1"), 1)
+    let next = questionSelect(state, req())
+    expect(next.state.editing).toBe(true)
+
+    state = questionStoreCustom(next.state, 0, "  custom mode  ")
+    next = questionSave(state, req())
+    expect(next.reply).toEqual({
+      requestID: "question-1",
+      answers: [["custom mode"]],
+    })
+  })
+
+  test("resets state when the request id changes and builds reject payloads", () => {
+    const state = questionSetSelected(createQuestionBodyState("question-1"), 1)
+
+    expect(questionSync(state, "question-1")).toBe(state)
+    expect(questionSync(state, "question-2")).toEqual(createQuestionBodyState("question-2"))
+    expect(questionReject(req())).toEqual({
+      requestID: "question-1",
+    })
+  })
+})

+ 155 - 0
packages/opencode/test/cli/run/runtime.boot.test.ts

@@ -0,0 +1,155 @@
+import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
+import {
+  resolveDiffStyle,
+  resolveFooterKeybinds,
+  resolveModelInfo,
+  resolveSessionInfo,
+} from "@/cli/cmd/run/runtime.boot"
+import type { RunInput } from "@/cli/cmd/run/types"
+
+describe("run runtime boot", () => {
+  afterEach(() => {
+    mock.restore()
+  })
+
+  test("merges footer keybind config and injects leader cycle once", async () => {
+    spyOn(TuiConfig, "get").mockResolvedValue({
+      keybinds: {
+        leader: " ctrl+g ",
+        variant_cycle: " ctrl+t, <leader>t , alt+t ",
+        session_interrupt: " ctrl+c ",
+        history_previous: " k ",
+        history_next: " j ",
+        input_submit: " ctrl+s ",
+        input_newline: " alt+return ",
+      },
+    })
+
+    await expect(resolveFooterKeybinds()).resolves.toEqual({
+      leader: "ctrl+g",
+      variantCycle: "ctrl+t,<leader>t,alt+t",
+      interrupt: "ctrl+c",
+      historyPrevious: "k",
+      historyNext: "j",
+      inputSubmit: "ctrl+s",
+      inputNewline: "alt+return",
+    })
+  })
+
+  test("falls back to default keybinds when config load fails", async () => {
+    spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
+
+    await expect(resolveFooterKeybinds()).resolves.toEqual({
+      leader: "ctrl+x",
+      variantCycle: "ctrl+t,<leader>t",
+      interrupt: "escape",
+      historyPrevious: "up",
+      historyNext: "down",
+      inputSubmit: "return",
+      inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
+    })
+  })
+
+  test("collects model variants and context limits", async () => {
+    const sdk = {
+      provider: {
+        list: async () => ({
+          data: {
+            all: [
+              {
+                id: "openai",
+                models: {
+                  "gpt-5": {
+                    variants: {
+                      high: {},
+                      minimal: {},
+                    },
+                    limit: {
+                      context: 128000,
+                    },
+                  },
+                },
+              },
+              {
+                id: "anthropic",
+                models: {
+                  sonnet: {
+                    limit: {
+                      context: 200000,
+                    },
+                  },
+                },
+              },
+            ],
+          },
+        }),
+      },
+    } as unknown as RunInput["sdk"]
+
+    await expect(resolveModelInfo(sdk, { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
+      variants: ["high", "minimal"],
+      limits: {
+        "openai/gpt-5": 128000,
+        "anthropic/sonnet": 200000,
+      },
+    })
+  })
+
+  test("resolves session history and latest session variant", async () => {
+    const sdk = {
+      session: {
+        messages: async () => ({
+          data: [
+            {
+              info: { role: "assistant" },
+              parts: [{ type: "text", text: "ignore" }],
+            },
+            {
+              info: {
+                role: "user",
+                model: {
+                  providerID: "openai",
+                  modelID: "gpt-5",
+                  variant: "high",
+                },
+              },
+              parts: [{ type: "text", text: "hello" }],
+            },
+          ],
+        }),
+      },
+    } as unknown as RunInput["sdk"]
+
+    await expect(resolveSessionInfo(sdk, "session-1", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
+      first: false,
+      history: [{ text: "hello", parts: [] }],
+      variant: "high",
+    })
+  })
+
+  test("falls back when session lookup fails", async () => {
+    const sdk = {
+      session: {
+        messages: async () => {
+          throw new Error("boom")
+        },
+      },
+    } as unknown as RunInput["sdk"]
+
+    await expect(resolveSessionInfo(sdk, "session-1", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
+      first: true,
+      history: [],
+      variant: undefined,
+    })
+  })
+
+  test("reads diff style and falls back to auto", async () => {
+    spyOn(TuiConfig, "get").mockResolvedValue({ diff_style: "stacked" })
+    await expect(resolveDiffStyle()).resolves.toBe("stacked")
+
+    mock.restore()
+    spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
+    await expect(resolveDiffStyle()).resolves.toBe("auto")
+  })
+})

+ 248 - 0
packages/opencode/test/cli/run/runtime.queue.test.ts

@@ -0,0 +1,248 @@
+import { describe, expect, test } from "bun:test"
+import { runPromptQueue } from "@/cli/cmd/run/runtime.queue"
+import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types"
+
+function footer() {
+  const prompts = new Set<(input: RunPrompt) => void>()
+  const closes = new Set<() => void>()
+  const events: FooterEvent[] = []
+  const commits: StreamCommit[] = []
+  let closed = false
+
+  const api: FooterApi = {
+    get isClosed() {
+      return closed
+    },
+    onPrompt(fn) {
+      prompts.add(fn)
+      return () => {
+        prompts.delete(fn)
+      }
+    },
+    onClose(fn) {
+      if (closed) {
+        fn()
+        return () => {}
+      }
+
+      closes.add(fn)
+      return () => {
+        closes.delete(fn)
+      }
+    },
+    event(next) {
+      events.push(next)
+    },
+    append(next) {
+      commits.push(next)
+    },
+    idle() {
+      return Promise.resolve()
+    },
+    close() {
+      if (closed) {
+        return
+      }
+
+      closed = true
+      for (const fn of [...closes]) {
+        fn()
+      }
+    },
+    destroy() {
+      api.close()
+      prompts.clear()
+      closes.clear()
+    },
+  }
+
+  return {
+    api,
+    events,
+    commits,
+    submit(text: string) {
+      const next = { text, parts: [] as RunPrompt["parts"] }
+      for (const fn of [...prompts]) {
+        fn(next)
+      }
+    },
+  }
+}
+
+describe("run runtime queue", () => {
+  test("ignores empty prompts", async () => {
+    const ui = footer()
+    let calls = 0
+
+    const task = runPromptQueue({
+      footer: ui.api,
+      run: async () => {
+        calls += 1
+      },
+    })
+
+    ui.submit("   ")
+    ui.api.close()
+    await task
+
+    expect(calls).toBe(0)
+  })
+
+  test("treats /exit as a close command", async () => {
+    const ui = footer()
+    let calls = 0
+
+    const task = runPromptQueue({
+      footer: ui.api,
+      run: async () => {
+        calls += 1
+      },
+    })
+
+    ui.submit("/exit")
+    await task
+
+    expect(calls).toBe(0)
+  })
+
+  test("preserves whitespace for initial input", async () => {
+    const ui = footer()
+    const seen: string[] = []
+
+    await runPromptQueue({
+      footer: ui.api,
+      initialInput: "  hello  ",
+      run: async (input) => {
+        seen.push(input.text)
+        ui.api.close()
+      },
+    })
+
+    expect(seen).toEqual(["  hello  "])
+    expect(ui.commits).toEqual([
+      {
+        kind: "user",
+        text: "  hello  ",
+        phase: "start",
+        source: "system",
+      },
+    ])
+  })
+
+  test("runs queued prompts in order", async () => {
+    const ui = footer()
+    const seen: string[] = []
+    let wake: (() => void) | undefined
+    const gate = new Promise<void>((resolve) => {
+      wake = resolve
+    })
+
+    const task = runPromptQueue({
+      footer: ui.api,
+      run: async (input) => {
+        seen.push(input.text)
+        if (seen.length === 1) {
+          await gate
+          return
+        }
+
+        ui.api.close()
+      },
+    })
+
+    ui.submit("one")
+    ui.submit("two")
+    await Promise.resolve()
+    expect(seen).toEqual(["one"])
+
+    wake?.()
+    await task
+
+    expect(seen).toEqual(["one", "two"])
+  })
+
+  test("drains a prompt queued during an in-flight turn", async () => {
+    const ui = footer()
+    const seen: string[] = []
+    let wake: (() => void) | undefined
+    const gate = new Promise<void>((resolve) => {
+      wake = resolve
+    })
+
+    const task = runPromptQueue({
+      footer: ui.api,
+      run: async (input) => {
+        seen.push(input.text)
+        if (seen.length === 1) {
+          await gate
+          return
+        }
+
+        ui.api.close()
+      },
+    })
+
+    ui.submit("one")
+    await Promise.resolve()
+    expect(seen).toEqual(["one"])
+
+    wake?.()
+    await Promise.resolve()
+    ui.submit("two")
+    await task
+
+    expect(seen).toEqual(["one", "two"])
+  })
+
+  test("close aborts the active run and drops pending queued work", async () => {
+    const ui = footer()
+    const seen: string[] = []
+    let hit = false
+
+    const task = runPromptQueue({
+      footer: ui.api,
+      run: async (input, signal) => {
+        seen.push(input.text)
+        await new Promise<void>((resolve) => {
+          if (signal.aborted) {
+            hit = true
+            resolve()
+            return
+          }
+
+          signal.addEventListener(
+            "abort",
+            () => {
+              hit = true
+              resolve()
+            },
+            { once: true },
+          )
+        })
+      },
+    })
+
+    ui.submit("one")
+    await Promise.resolve()
+    ui.submit("two")
+    ui.api.close()
+    await task
+
+    expect(hit).toBe(true)
+    expect(seen).toEqual(["one"])
+  })
+
+  test("propagates run errors", async () => {
+    const ui = footer()
+
+    const task = runPromptQueue({
+      footer: ui.api,
+      run: async () => {
+        throw new Error("boom")
+      },
+    })
+
+    ui.submit("one")
+    await expect(task).rejects.toThrow("boom")
+  })
+})

+ 1281 - 0
packages/opencode/test/cli/run/scrollback.surface.test.ts

@@ -0,0 +1,1281 @@
+import { afterEach, expect, test } from "bun:test"
+import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
+import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
+import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
+
+type ClaimedCommit = {
+  snapshot: {
+    height: number
+    getRealCharBytes(addLineBreaks?: boolean): Uint8Array
+    destroy(): void
+  }
+  trailingNewline: boolean
+}
+
+const decoder = new TextDecoder()
+const active: TestRenderer[] = []
+
+afterEach(() => {
+  for (const renderer of active.splice(0)) {
+    renderer.destroy()
+  }
+})
+
+function claimCommits(renderer: TestRenderer): ClaimedCommit[] {
+  return (renderer as any).externalOutputQueue.claim() as ClaimedCommit[]
+}
+
+function renderCommit(commit: ClaimedCommit): string {
+  return decoder.decode(commit.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n")
+}
+
+function destroyCommits(commits: ClaimedCommit[]) {
+  for (const commit of commits) {
+    commit.snapshot.destroy()
+  }
+}
+
+test("completes finely streamed markdown tables when the turn goes idle", async () => {
+  const out = await createTestRenderer({
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+  })
+
+  const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
+
+  for (const chunk of text) {
+    await scrollback.append({
+      kind: "assistant",
+      text: chunk,
+      phase: "progress",
+      source: "assistant",
+      messageID: "msg-1",
+      partID: "part-1",
+    })
+  }
+
+  await scrollback.complete()
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits.length).toBeGreaterThan(0)
+    const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("\n")
+    expect(rendered).toContain("Column 1")
+    expect(rendered).toContain("Row 2")
+    expect(rendered).toContain("Value 4")
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("completes coalesced markdown tables after one progress append", async () => {
+  const out = await createTestRenderer({
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+  })
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+
+  await scrollback.complete()
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits.length).toBeGreaterThan(0)
+    const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("\n")
+    expect(rendered).toContain("Column 1")
+    expect(rendered).toContain("Row 2")
+    expect(rendered).toContain("Value 4")
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("completes markdown replies without adding a second blank line above the footer", async () => {
+  const out = await createTestRenderer({
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = \"Hello, markdown\"\nconsole.log(message)\n```",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+
+  const progress = claimCommits(out.renderer)
+  try {
+    expect(progress).toHaveLength(1)
+    expect(progress[0]!.snapshot.height).toBe(5)
+    const rendered = decoder.decode(progress[0]!.snapshot.getRealCharBytes(true))
+    expect(rendered).toContain("Markdown Sample")
+    expect(rendered).toContain("Item 2")
+    expect(rendered).not.toContain("console.log(message)")
+  } finally {
+    destroyCommits(progress)
+  }
+
+  await scrollback.complete()
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(1)
+    expect(final[0]!.trailingNewline).toBe(false)
+    const rendered = decoder.decode(final[0]!.snapshot.getRealCharBytes(true))
+    expect(rendered).toContain('const message = "Hello, markdown"')
+    expect(rendered).toContain("console.log(message)")
+  } finally {
+    destroyCommits(final)
+  }
+})
+
+test("streamed assistant final leaves newline ownership to the next entry", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "hello",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+  destroyCommits(claimCommits(out.renderer))
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "",
+    phase: "final",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(1)
+    expect(final[0]!.trailingNewline).toBe(false)
+  } finally {
+    destroyCommits(final)
+  }
+})
+
+test("preserves blank rows between streamed markdown block commits", async () => {
+  const out = await createTestRenderer({
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "# Title\n\nPara 1\n\n",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+
+  const first = claimCommits(out.renderer)
+  expect(first).toHaveLength(1)
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "> Quote",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+
+  const second = claimCommits(out.renderer)
+  expect(second).toHaveLength(0)
+
+  await scrollback.complete()
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(1)
+
+    const rendered = [...first, ...final]
+      .map((item) => decoder.decode(item.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n"))
+      .join("")
+    expect(rendered).toContain("# Title\n\nPara 1\n\n> Quote")
+  } finally {
+    destroyCommits(first)
+    destroyCommits(final)
+  }
+})
+
+test("renders write finals without a redundant start row", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "start",
+    source: "tool",
+    partID: "tool-1",
+    messageID: "msg-1",
+    tool: "write",
+    toolState: "running",
+    part: {
+      id: "tool-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "write",
+      state: {
+        status: "running",
+        input: {
+          filePath: "src/a.ts",
+          content: "const x = 1\n",
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  const start = claimCommits(out.renderer)
+  try {
+    expect(start).toHaveLength(0)
+  } finally {
+    destroyCommits(start)
+  }
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "tool-1",
+    messageID: "msg-1",
+    tool: "write",
+    toolState: "completed",
+    part: {
+      id: "tool-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "write",
+      state: {
+        status: "completed",
+        input: {
+          filePath: "src/a.ts",
+          content: "const x = 1\n",
+        },
+        metadata: {},
+        time: {
+          start: 1,
+          end: 2,
+        },
+      },
+    } as never,
+  })
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(1)
+    expect(renderCommit(final[0]!)).toContain("# Wrote src/a.ts")
+  } finally {
+    destroyCommits(final)
+  }
+})
+
+test("renders edit finals without a redundant start row", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "start",
+    source: "tool",
+    partID: "tool-edit",
+    messageID: "msg-edit",
+    tool: "edit",
+    toolState: "running",
+    part: {
+      id: "tool-edit",
+      sessionID: "session-1",
+      messageID: "msg-edit",
+      type: "tool",
+      callID: "call-edit",
+      tool: "edit",
+      state: {
+        status: "running",
+        input: {
+          filePath: "src/a.ts",
+          oldString: "old",
+          newString: "new",
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  expect(claimCommits(out.renderer)).toHaveLength(0)
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "tool-edit",
+    messageID: "msg-edit",
+    tool: "edit",
+    toolState: "completed",
+    part: {
+      id: "tool-edit",
+      sessionID: "session-1",
+      messageID: "msg-edit",
+      type: "tool",
+      callID: "call-edit",
+      tool: "edit",
+      state: {
+        status: "completed",
+        input: {
+          filePath: "src/a.ts",
+          oldString: "old",
+          newString: "new",
+        },
+        metadata: {
+          diff: "@@ -1 +1 @@\n-old\n+new\n",
+        },
+        time: {
+          start: 1,
+          end: 2,
+        },
+      },
+    } as never,
+  })
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits).toHaveLength(1)
+    expect(renderCommit(commits[0]!)).toContain("# Edited src/a.ts")
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("renders apply_patch finals without a redundant start row", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "start",
+    source: "tool",
+    partID: "tool-patch",
+    messageID: "msg-patch",
+    tool: "apply_patch",
+    toolState: "running",
+    part: {
+      id: "tool-patch",
+      sessionID: "session-1",
+      messageID: "msg-patch",
+      type: "tool",
+      callID: "call-patch",
+      tool: "apply_patch",
+      state: {
+        status: "running",
+        input: {},
+        metadata: {
+          files: [
+            {
+              type: "update",
+              filePath: "src/a.ts",
+              relativePath: "src/a.ts",
+              patch: "@@ -1 +1 @@\n-old\n+new\n",
+            },
+          ],
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  expect(claimCommits(out.renderer)).toHaveLength(0)
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "tool-patch",
+    messageID: "msg-patch",
+    tool: "apply_patch",
+    toolState: "completed",
+    part: {
+      id: "tool-patch",
+      sessionID: "session-1",
+      messageID: "msg-patch",
+      type: "tool",
+      callID: "call-patch",
+      tool: "apply_patch",
+      state: {
+        status: "completed",
+        input: {},
+        metadata: {
+          files: [
+            {
+              type: "update",
+              filePath: "src/a.ts",
+              relativePath: "src/a.ts",
+              patch: "@@ -1 +1 @@\n-old\n+new\n",
+            },
+          ],
+        },
+        time: {
+          start: 1,
+          end: 2,
+        },
+      },
+    } as never,
+  })
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits).toHaveLength(1)
+    expect(renderCommit(commits[0]!)).toContain("# Patched src/a.ts")
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("inserts a spacer between block assistant entries and following inline tools", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "hello",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+  await scrollback.complete()
+
+  const first = claimCommits(out.renderer)
+  try {
+    expect(first).toHaveLength(1)
+    expect(renderCommit(first[0]!).trim()).toBe("hello")
+  } finally {
+    destroyCommits(first)
+  }
+
+  await scrollback.append({
+    kind: "tool",
+    source: "tool",
+    messageID: "msg-tool",
+    partID: "part-tool",
+    tool: "glob",
+    phase: "start",
+    text: "running glob",
+    toolState: "running",
+    part: {
+      id: "part-tool",
+      type: "tool",
+      tool: "glob",
+      callID: "call-tool",
+      messageID: "msg-tool",
+      sessionID: "session-1",
+      state: {
+        status: "running",
+        input: {
+          pattern: "**/run.ts",
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  const next = claimCommits(out.renderer)
+  try {
+    expect(next).toHaveLength(2)
+    expect(renderCommit(next[0]!).trim()).toBe("")
+    expect(renderCommit(next[1]!).replace(/ +/g, " ").trim()).toBe('✱ Glob "**/run.ts"')
+  } finally {
+    destroyCommits(next)
+  }
+})
+
+test("renders todos without redundant start or footer lines", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "start",
+    source: "tool",
+    partID: "todo-1",
+    messageID: "msg-1",
+    tool: "todowrite",
+    toolState: "running",
+    part: {
+      id: "todo-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "todowrite",
+      state: {
+        status: "running",
+        input: {
+          todos: [
+            { status: "completed", content: "List files under `run/`" },
+            { status: "in_progress", content: "Count functions in each `run/` file" },
+            { status: "pending", content: "Mark each tracking item complete" },
+          ],
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  expect(claimCommits(out.renderer)).toHaveLength(0)
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "todo-1",
+    messageID: "msg-1",
+    tool: "todowrite",
+    toolState: "completed",
+    part: {
+      id: "todo-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "todowrite",
+      state: {
+        status: "completed",
+        input: {
+          todos: [
+            { status: "completed", content: "List files under `run/`" },
+            { status: "in_progress", content: "Count functions in each `run/` file" },
+            { status: "pending", content: "Mark each tracking item complete" },
+          ],
+        },
+        metadata: {},
+        time: {
+          start: 1,
+          end: 4,
+        },
+      },
+    } as never,
+  })
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits).toHaveLength(1)
+    const raw = decoder.decode(commits[0]!.snapshot.getRealCharBytes(true))
+    const rows = Array.from({ length: commits[0]!.snapshot.height }, (_, index) =>
+      raw.slice(index * 80, (index + 1) * 80).trimEnd(),
+    )
+    const rendered = rows.join("\n")
+    expect(rendered).toContain("# Todos")
+    expect(rendered).toContain("[✓] List files under `run/`")
+    expect(rendered).toContain("[•] Count functions in each `run/` file")
+    expect(rendered).toContain("[ ] Mark each tracking item complete")
+    expect(rendered).not.toContain("Updating")
+    expect(rendered).not.toContain("todos completed")
+    expect(rows).toContain("[✓] List files under `run/`")
+    expect(rows).toContain("[•] Count functions in each `run/` file")
+    expect(rows).toContain("[ ] Mark each tracking item complete")
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("renders questions without redundant start or footer lines", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "start",
+    source: "tool",
+    partID: "question-1",
+    messageID: "msg-1",
+    tool: "question",
+    toolState: "running",
+    part: {
+      id: "question-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "question",
+      state: {
+        status: "running",
+        input: {
+          questions: [
+            {
+              question: "What should I work on in the codebase next?",
+              header: "Next work",
+              options: [{ label: "bug", description: "Bug fix" }],
+              multiple: false,
+            },
+          ],
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  expect(claimCommits(out.renderer)).toHaveLength(0)
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "question-1",
+    messageID: "msg-1",
+    tool: "question",
+    toolState: "completed",
+    part: {
+      id: "question-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "question",
+      state: {
+        status: "completed",
+        input: {
+          questions: [
+            {
+              question: "What should I work on in the codebase next?",
+              header: "Next work",
+              options: [{ label: "bug", description: "Bug fix" }],
+              multiple: false,
+            },
+          ],
+        },
+        metadata: {
+          answers: [["Bug fix"]],
+        },
+        time: {
+          start: 1,
+          end: 2100,
+        },
+      },
+    } as never,
+  })
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits).toHaveLength(1)
+    const raw = decoder.decode(commits[0]!.snapshot.getRealCharBytes(true))
+    const rows = Array.from({ length: commits[0]!.snapshot.height }, (_, index) =>
+      raw.slice(index * 80, (index + 1) * 80).trimEnd(),
+    )
+    const rendered = rows.join("\n")
+    expect(rendered).toContain("# Questions")
+    expect(rendered).toContain("What should I work on in the codebase next?")
+    expect(rendered).toContain("Bug fix")
+    expect(rendered).not.toContain("Asked")
+    expect(rendered).not.toContain("questions completed")
+    expect(rows).toContain("What should I work on in the codebase next?")
+    expect(rows).toContain("Bug fix")
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("bodyless starts keep the previous rendered item as separator context", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "hello",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+  await scrollback.complete()
+  destroyCommits(claimCommits(out.renderer))
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "start",
+    source: "tool",
+    partID: "task-1",
+    messageID: "msg-2",
+    tool: "task",
+    toolState: "running",
+    part: {
+      id: "task-1",
+      sessionID: "session-1",
+      messageID: "msg-2",
+      type: "tool",
+      callID: "call-2",
+      tool: "task",
+      state: {
+        status: "running",
+        input: {
+          description: "Explore run.ts",
+          subagent_type: "explore",
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  expect(claimCommits(out.renderer)).toHaveLength(0)
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "task-1",
+    messageID: "msg-2",
+    tool: "task",
+    toolState: "error",
+    part: {
+      id: "task-1",
+      sessionID: "session-1",
+      messageID: "msg-2",
+      type: "tool",
+      callID: "call-2",
+      tool: "task",
+      state: {
+        status: "error",
+        input: {
+          description: "Explore run.ts",
+          subagent_type: "explore",
+        },
+        error: "boom",
+        time: {
+          start: 1,
+          end: 2,
+        },
+      },
+    } as never,
+  })
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(2)
+    expect(renderCommit(final[0]!).trim()).toBe("")
+    expect(renderCommit(final[1]!)).toContain("failed")
+  } finally {
+    destroyCommits(final)
+  }
+})
+
+test("streamed assistant blocks defer their spacer until first render", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "user",
+    text: "use subagent to explore run.ts",
+    phase: "start",
+    source: "system",
+  })
+  destroyCommits(claimCommits(out.renderer))
+
+  for (const chunk of ["Exploring", " run.ts", " via", " a codebase-aware", " subagent next."]) {
+    await scrollback.append({
+      kind: "assistant",
+      text: chunk,
+      phase: "progress",
+      source: "assistant",
+      messageID: "msg-1",
+      partID: "part-1",
+    })
+  }
+
+  const progress = claimCommits(out.renderer)
+  try {
+    expect(progress).toHaveLength(0)
+  } finally {
+    destroyCommits(progress)
+  }
+
+  await scrollback.complete()
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(2)
+    expect(renderCommit(final[0]!).trim()).toBe("")
+    expect(renderCommit(final[1]!).replace(/\n/g, " ")).toContain(
+      "Exploring run.ts via a codebase-aware subagent next.",
+    )
+  } finally {
+    destroyCommits(final)
+  }
+})
+
+test("first entry after prior scrollback gets a spacer", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: true,
+  })
+
+  await scrollback.append({
+    kind: "user",
+    text: "use subagent to explore run.ts",
+    phase: "start",
+    source: "system",
+  })
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits).toHaveLength(2)
+    expect(renderCommit(commits[0]!).trim()).toBe("")
+    expect(renderCommit(commits[1]!).trim()).toBe("› use subagent to explore run.ts")
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("first streamed entry after prior scrollback gets a spacer", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: true,
+  })
+
+  for (const chunk of ["Exploring", " run.ts", " via", " a codebase-aware", " subagent next."]) {
+    await scrollback.append({
+      kind: "assistant",
+      text: chunk,
+      phase: "progress",
+      source: "assistant",
+      messageID: "msg-1",
+      partID: "part-1",
+    })
+  }
+
+  const progress = claimCommits(out.renderer)
+  try {
+    expect(progress).toHaveLength(0)
+  } finally {
+    destroyCommits(progress)
+  }
+
+  await scrollback.complete()
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits).toHaveLength(2)
+    expect(renderCommit(commits[0]!).trim()).toBe("")
+    expect(renderCommit(commits[1]!).replace(/\n/g, " ")).toContain(
+      "Exploring run.ts via a codebase-aware subagent next.",
+    )
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("coalesces same-line tool progress into one snapshot", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "abc",
+    phase: "progress",
+    source: "tool",
+    partID: "tool-1",
+    messageID: "msg-1",
+    tool: "bash",
+  })
+  await scrollback.append({
+    kind: "tool",
+    text: "def",
+    phase: "progress",
+    source: "tool",
+    partID: "tool-1",
+    messageID: "msg-1",
+    tool: "bash",
+  })
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "tool-1",
+    messageID: "msg-1",
+    tool: "bash",
+    toolState: "completed",
+  })
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits).toHaveLength(1)
+    expect(decoder.decode(commits[0]!.snapshot.getRealCharBytes(true))).toContain("abcdef")
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("renders structured write finals as native code blocks", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "tool-2",
+    messageID: "msg-2",
+    tool: "write",
+    toolState: "completed",
+    part: {
+      id: "tool-2",
+      sessionID: "session-1",
+      messageID: "msg-2",
+      type: "tool",
+      callID: "call-2",
+      tool: "write",
+      state: {
+        status: "completed",
+        input: {
+          filePath: "src/a.ts",
+          content: "const x = 1\nconst y = 2\n",
+        },
+        metadata: {},
+        time: {
+          start: 1,
+          end: 2,
+        },
+      },
+    } as never,
+  })
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits).toHaveLength(1)
+    const rendered = decoder.decode(commits[0]!.snapshot.getRealCharBytes(true)).replace(/ +/g, " ")
+    expect(rendered).toContain("# Wrote src/a.ts")
+    expect(rendered).toMatch(/1\s+const x = 1/)
+    expect(rendered).toMatch(/2\s+const y = 2/)
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
+test("renders promoted task-result markdown without leading blank rows", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "task-1",
+    messageID: "msg-1",
+    tool: "task",
+    toolState: "completed",
+    part: {
+      id: "task-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "task",
+      state: {
+        status: "completed",
+        input: {
+          description: "Explore run.ts",
+          subagent_type: "explore",
+        },
+        output: [
+          "task_id: child-1 (for resuming to continue this task if needed)",
+          "",
+          "<task_result>",
+          "Location: `/tmp/run.ts`",
+          "",
+          "Summary:",
+          "- Local interactive mode",
+          "- Attach mode",
+          "</task_result>",
+        ].join("\n"),
+        metadata: {
+          sessionId: "child-1",
+        },
+        time: {
+          start: 1,
+          end: 2,
+        },
+      },
+    } as never,
+  })
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits.length).toBeGreaterThan(0)
+    const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("")
+    expect(rendered.startsWith("\n")).toBe(false)
+    expect(rendered).toContain("Summary:")
+    expect(rendered).toContain("Local interactive mode")
+  } finally {
+    destroyCommits(commits)
+  }
+})

+ 661 - 0
packages/opencode/test/cli/run/session-data.test.ts

@@ -0,0 +1,661 @@
+import { describe, expect, test } from "bun:test"
+import type { Event } from "@opencode-ai/sdk/v2"
+import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
+
+function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
+  return reduceSessionData({
+    data,
+    event: event as Event,
+    sessionID: "session-1",
+    thinking,
+    limits: {},
+  })
+}
+
+function assistant(id: string, extra: Record<string, unknown> = {}) {
+  return {
+    type: "message.updated",
+    properties: {
+      sessionID: "session-1",
+      info: {
+        id,
+        role: "assistant",
+        providerID: "openai",
+        modelID: "gpt-5",
+        tokens: {
+          input: 1,
+          output: 1,
+          reasoning: 0,
+          cache: { read: 0, write: 0 },
+        },
+        ...extra,
+      },
+    },
+  }
+}
+
+describe("run session data", () => {
+  test("buffers deltas until role and part kind are known", () => {
+    let data = createSessionData()
+
+    data = reduce(data, {
+      type: "message.part.delta",
+      properties: {
+        sessionID: "session-1",
+        messageID: "msg-1",
+        partID: "txt-1",
+        field: "text",
+        delta: "hello",
+      },
+    }).data
+
+    data = reduce(data, assistant("msg-1")).data
+
+    const out = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "txt-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          type: "text",
+          text: "",
+          time: { end: Date.now() },
+        },
+      },
+    })
+
+    expect(out.commits).toEqual([
+      {
+        kind: "assistant",
+        text: "hello",
+        phase: "progress",
+        source: "assistant",
+        messageID: "msg-1",
+        partID: "txt-1",
+      },
+    ])
+  })
+
+  test("buffers whitespace-only initial assistant chunks until real content arrives", () => {
+    let data = createSessionData()
+
+    data = reduce(data, assistant("msg-1")).data
+    data = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "txt-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          type: "text",
+          text: "",
+          time: { start: Date.now() },
+        },
+      },
+    }).data
+
+    let out = reduce(data, {
+      type: "message.part.delta",
+      properties: {
+        sessionID: "session-1",
+        messageID: "msg-1",
+        partID: "txt-1",
+        field: "text",
+        delta: " ",
+      },
+    })
+
+    expect(out.commits).toEqual([])
+
+    data = out.data
+    out = reduce(data, {
+      type: "message.part.delta",
+      properties: {
+        sessionID: "session-1",
+        messageID: "msg-1",
+        partID: "txt-1",
+        field: "text",
+        delta: "Found",
+      },
+    })
+
+    expect(out.commits).toEqual([
+      {
+        kind: "assistant",
+        text: " Found",
+        phase: "progress",
+        source: "assistant",
+        messageID: "msg-1",
+        partID: "txt-1",
+      },
+    ])
+  })
+
+  test("drops user text when the delayed role resolves to user", () => {
+    let data = createSessionData()
+
+    data = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "txt-user-1",
+          messageID: "msg-user-1",
+          sessionID: "session-1",
+          type: "text",
+          text: "HELLO",
+          time: { end: Date.now() },
+        },
+      },
+    }).data
+
+    const out = reduce(data, {
+      type: "message.updated",
+      properties: {
+        sessionID: "session-1",
+        info: {
+          id: "msg-user-1",
+          role: "user",
+        },
+      },
+    })
+
+    expect(out.commits).toEqual([])
+    expect(out.data.ids.has("txt-user-1")).toBe(true)
+  })
+
+  test("suppresses reasoning when thinking is disabled", () => {
+    const out = reduce(
+      createSessionData(),
+      {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "reason-1",
+            messageID: "msg-1",
+            sessionID: "session-1",
+            type: "reasoning",
+            text: "hidden",
+            time: { end: Date.now() },
+          },
+        },
+      },
+      false,
+    )
+
+    expect(out.commits).toEqual([])
+    expect(out.data.ids.has("reason-1")).toBe(true)
+  })
+
+  test("dedupes tool lifecycle events and emits output/final commits", () => {
+    let data = createSessionData()
+
+    let out = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "bash-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          type: "tool",
+          tool: "bash",
+          state: {
+            status: "running",
+            input: {
+              command: "git status --short",
+            },
+          },
+        },
+      },
+    })
+
+    expect(out.commits).toHaveLength(1)
+    expect(out.commits[0]).toMatchObject({
+      kind: "tool",
+      text: "running bash",
+      phase: "start",
+      source: "tool",
+      messageID: "msg-1",
+      partID: "bash-1",
+      tool: "bash",
+      toolState: "running",
+    })
+
+    data = out.data
+    expect(
+      reduce(data, {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "bash-1",
+            messageID: "msg-1",
+            sessionID: "session-1",
+            type: "tool",
+            tool: "bash",
+            state: {
+              status: "running",
+              input: {
+                command: "git status --short",
+              },
+            },
+          },
+        },
+      }).commits,
+    ).toEqual([])
+
+    out = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "bash-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          type: "tool",
+          tool: "bash",
+          state: {
+            status: "completed",
+            input: {
+              command: "git status --short",
+            },
+            output: "clean",
+            time: { start: 1, end: 2 },
+          },
+        },
+      },
+    })
+
+    expect(out.commits).toHaveLength(1)
+    expect(out.commits[0]).toMatchObject({
+      kind: "tool",
+      text: "clean",
+      phase: "progress",
+      source: "tool",
+      messageID: "msg-1",
+      partID: "bash-1",
+      tool: "bash",
+      toolState: "completed",
+    })
+
+    data = out.data
+    out = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "write-1",
+          messageID: "msg-2",
+          sessionID: "session-1",
+          type: "tool",
+          tool: "write",
+          state: {
+            status: "running",
+            input: {
+              filePath: "src/a.ts",
+            },
+          },
+        },
+      },
+    })
+
+    expect(out.commits).toHaveLength(1)
+    expect(out.commits[0]).toMatchObject({
+      kind: "tool",
+      text: "running write",
+      phase: "start",
+      source: "tool",
+      messageID: "msg-2",
+      partID: "write-1",
+      tool: "write",
+      toolState: "running",
+    })
+
+    data = out.data
+    out = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "write-1",
+          messageID: "msg-2",
+          sessionID: "session-1",
+          type: "tool",
+          tool: "write",
+          state: {
+            status: "completed",
+            input: {
+              filePath: "src/a.ts",
+            },
+            output: "ok",
+            time: { start: 1, end: 2 },
+          },
+        },
+      },
+    })
+
+    expect(out.commits).toHaveLength(1)
+    expect(out.commits[0]).toMatchObject({
+      kind: "tool",
+      text: "",
+      phase: "final",
+      source: "tool",
+      messageID: "msg-2",
+      partID: "write-1",
+      tool: "write",
+      toolState: "completed",
+    })
+  })
+
+  test("keeps permission precedence over queued questions", () => {
+    let data = createSessionData()
+
+    data = reduce(data, {
+      type: "permission.asked",
+      properties: {
+        id: "perm-1",
+        sessionID: "session-1",
+        permission: "read",
+        patterns: ["/tmp/file.txt"],
+        metadata: {},
+        always: [],
+      },
+    }).data
+
+    const ask = reduce(data, {
+      type: "question.asked",
+      properties: {
+        id: "question-1",
+        sessionID: "session-1",
+        questions: [
+          {
+            question: "Mode?",
+            header: "Mode",
+            options: [{ label: "chunked", description: "Incremental output" }],
+            multiple: false,
+          },
+        ],
+      },
+    })
+
+    expect(ask.footer).toEqual({
+      patch: { status: "awaiting permission" },
+      view: {
+        type: "permission",
+        request: expect.objectContaining({ id: "perm-1" }),
+      },
+    })
+
+    const next = reduce(ask.data, {
+      type: "permission.replied",
+      properties: {
+        sessionID: "session-1",
+        requestID: "perm-1",
+        reply: "reject",
+      },
+    })
+
+    expect(next.footer).toEqual({
+      patch: { status: "awaiting answer" },
+      view: {
+        type: "question",
+        request: expect.objectContaining({ id: "question-1" }),
+      },
+    })
+  })
+
+  test("refreshes the active permission view when tool input arrives later", () => {
+    let data = createSessionData()
+
+    data = reduce(data, {
+      type: "permission.asked",
+      properties: {
+        id: "perm-1",
+        sessionID: "session-1",
+        permission: "bash",
+        patterns: ["src/**/*.ts"],
+        metadata: {},
+        always: [],
+        tool: {
+          messageID: "msg-1",
+          callID: "call-1",
+        },
+      },
+    }).data
+
+    const out = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "tool-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          callID: "call-1",
+          type: "tool",
+          tool: "bash",
+          state: {
+            status: "running",
+            input: {
+              command: "git status --short",
+            },
+          },
+        },
+      },
+    })
+
+    expect(out.footer).toEqual({
+      view: {
+        type: "permission",
+        request: expect.objectContaining({
+          id: "perm-1",
+          metadata: expect.objectContaining({
+            input: {
+              command: "git status --short",
+            },
+          }),
+        }),
+      },
+    })
+  })
+
+  test("strips bash echo only from the first assistant flush", () => {
+    let data = createSessionData()
+    data = reduce(data, assistant("msg-1")).data
+
+    data = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "tool-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          type: "tool",
+          tool: "bash",
+          state: {
+            status: "completed",
+            input: {
+              command: "printf hi",
+            },
+            output: "echoed\n",
+            time: { start: 1, end: 2 },
+          },
+        },
+      },
+    }).data
+
+    const first = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "txt-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          type: "text",
+          text: "echoed\nanswer",
+        },
+      },
+    })
+
+    expect(first.commits).toEqual([
+      {
+        kind: "assistant",
+        text: "answer",
+        phase: "progress",
+        source: "assistant",
+        messageID: "msg-1",
+        partID: "txt-1",
+      },
+    ])
+
+    const next = reduce(first.data, {
+      type: "message.part.delta",
+      properties: {
+        sessionID: "session-1",
+        messageID: "msg-1",
+        partID: "txt-1",
+        field: "text",
+        delta: "\nechoed\nagain",
+      },
+    })
+
+    expect(next.commits).toEqual([
+      {
+        kind: "assistant",
+        text: "\nechoed\nagain",
+        phase: "progress",
+        source: "assistant",
+        messageID: "msg-1",
+        partID: "txt-1",
+      },
+    ])
+  })
+
+  test("emits assistant error rows after replaying pending text", () => {
+    let data = createSessionData()
+
+    data = reduce(data, {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "txt-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          type: "text",
+          text: "hello",
+          time: { end: Date.now() },
+        },
+      },
+    }).data
+
+    const out = reduce(
+      data,
+      assistant("msg-1", {
+        error: {
+          name: "UnknownError",
+          data: {
+            message: "boom",
+          },
+        },
+      }),
+    )
+
+    expect(out.commits).toEqual([
+      {
+        kind: "assistant",
+        text: "hello",
+        phase: "progress",
+        source: "assistant",
+        messageID: "msg-1",
+        partID: "txt-1",
+      },
+      {
+        kind: "error",
+        text: "boom",
+        phase: "start",
+        source: "system",
+        messageID: "msg-1",
+      },
+    ])
+  })
+
+  test("flushInterrupted emits interrupted finals for in-flight parts", () => {
+    const data = reduce(createSessionData(), {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "txt-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          type: "text",
+          text: "unfinished",
+        },
+      },
+    }).data
+
+    const commits: ReturnType<typeof reduce>["commits"] = []
+    flushInterrupted(data, commits)
+
+    expect(commits).toEqual([
+      {
+        kind: "assistant",
+        text: "unfinished",
+        phase: "progress",
+        source: "assistant",
+        messageID: "msg-1",
+        partID: "txt-1",
+      },
+      {
+        kind: "assistant",
+        text: "",
+        phase: "final",
+        source: "assistant",
+        messageID: "msg-1",
+        partID: "txt-1",
+        interrupted: true,
+      },
+    ])
+  })
+
+  test("flushInterrupted does not emit the same interrupted final twice", () => {
+    const data = reduce(createSessionData(), {
+      type: "message.part.updated",
+      properties: {
+        part: {
+          id: "txt-1",
+          messageID: "msg-1",
+          sessionID: "session-1",
+          type: "text",
+          text: "unfinished",
+        },
+      },
+    }).data
+
+    const first: ReturnType<typeof reduce>["commits"] = []
+    flushInterrupted(data, first)
+    expect(first).toHaveLength(2)
+
+    const next: ReturnType<typeof reduce>["commits"] = []
+    flushInterrupted(data, next)
+    expect(next).toEqual([])
+  })
+
+  test("emits session error transcript rows", () => {
+    const out = reduce(createSessionData(), {
+      type: "session.error",
+      properties: {
+        sessionID: "session-1",
+        error: {
+          name: "UnknownError",
+          data: {
+            message: "permission denied",
+          },
+        },
+      },
+    })
+
+    expect(out.commits).toEqual([
+      {
+        kind: "error",
+        text: "permission denied",
+        phase: "start",
+        source: "system",
+      },
+    ])
+  })
+})

+ 187 - 0
packages/opencode/test/cli/run/session.shared.test.ts

@@ -0,0 +1,187 @@
+import { describe, expect, test } from "bun:test"
+import {
+  createSession,
+  sessionHistory,
+  sessionVariant,
+  type RunSession,
+  type SessionMessages,
+} from "@/cli/cmd/run/session.shared"
+
+const model = {
+  providerID: "openai",
+  modelID: "gpt-5",
+}
+
+describe("run session shared", () => {
+  test("builds user prompt text from text, file, and agent parts", () => {
+    const msgs = [
+      {
+        info: { role: "assistant" },
+        parts: [{ type: "text", text: "ignore me" }],
+      },
+      {
+        info: {
+          role: "user",
+          model: {
+            ...model,
+            variant: "high",
+          },
+        },
+        parts: [
+          { type: "text", text: "look @scan" },
+          { type: "text", text: "hidden", synthetic: true },
+          {
+            type: "agent",
+            name: "scan",
+            source: {
+              start: 5,
+              end: 10,
+              value: "@scan",
+            },
+          },
+          {
+            type: "file",
+            mime: "text/plain",
+            url: "file:///tmp/note.ts",
+          },
+        ],
+      },
+    ] as unknown as SessionMessages
+
+    const out = createSession(msgs)
+    expect(out.first).toBe(false)
+    expect(out.turns).toHaveLength(1)
+    expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
+    expect(out.turns[0]?.prompt.parts).toEqual([
+      {
+        type: "agent",
+        name: "scan",
+        source: {
+          start: 5,
+          end: 10,
+          value: "@scan",
+        },
+      },
+      {
+        type: "file",
+        mime: "text/plain",
+        filename: undefined,
+        url: "file:///tmp/note.ts",
+        source: {
+          type: "file",
+          path: "file:///tmp/note.ts",
+          text: {
+            start: 11,
+            end: 19,
+            value: "@note.ts",
+          },
+        },
+      },
+    ])
+  })
+
+  test("reuses existing mentions when file and agent parts have no source", () => {
+    const out = createSession([
+      {
+        info: {
+          role: "user",
+          model: {
+            ...model,
+            variant: "high",
+          },
+        },
+        parts: [
+          { type: "text", text: "look @scan @note.ts" },
+          { type: "agent", name: "scan" },
+          {
+            type: "file",
+            mime: "text/plain",
+            url: "file:///tmp/note.ts",
+          },
+        ],
+      },
+    ] as unknown as SessionMessages)
+
+    expect(out.turns[0]?.prompt).toEqual({
+      text: "look @scan @note.ts",
+      parts: [
+        {
+          type: "agent",
+          name: "scan",
+          source: {
+            start: 5,
+            end: 10,
+            value: "@scan",
+          },
+        },
+        {
+          type: "file",
+          mime: "text/plain",
+          filename: undefined,
+          url: "file:///tmp/note.ts",
+          source: {
+            type: "file",
+            path: "file:///tmp/note.ts",
+            text: {
+              start: 11,
+              end: 19,
+              value: "@note.ts",
+            },
+          },
+        },
+      ],
+    })
+  })
+
+  test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
+    const parts = [
+      {
+        type: "agent" as const,
+        name: "scan",
+        source: {
+          start: 0,
+          end: 5,
+          value: "@scan",
+        },
+      },
+    ]
+    const session: RunSession = {
+      first: false,
+      turns: [
+        { prompt: { text: "one", parts }, provider: "openai", model: "gpt-5", variant: "high" },
+        { prompt: { text: "one", parts: structuredClone(parts) }, provider: "openai", model: "gpt-5", variant: "high" },
+        { prompt: { text: "   ", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
+        { prompt: { text: "two", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
+      ],
+    }
+
+    const out = sessionHistory(session)
+
+    expect(out.map((item) => item.text)).toEqual(["one", "two"])
+    expect(out[0]?.parts).toEqual(parts)
+    expect(out[0]?.parts).not.toBe(parts)
+    expect(out[0]?.parts[0]).not.toBe(parts[0])
+  })
+
+  test("returns the latest matching variant for the active model", () => {
+    const session: RunSession = {
+      first: false,
+      turns: [
+        { prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
+        { prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" },
+        { prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
+      ],
+    }
+
+    expect(sessionVariant(session, model)).toBeUndefined()
+
+    session.turns.push({
+      prompt: { text: "four", parts: [] },
+      provider: "openai",
+      model: "gpt-5",
+      variant: "minimal",
+    })
+
+    expect(sessionVariant(session, model)).toBe("minimal")
+  })
+})

+ 164 - 0
packages/opencode/test/cli/run/stream.test.ts

@@ -0,0 +1,164 @@
+import { describe, expect, test } from "bun:test"
+import { writeSessionOutput } from "@/cli/cmd/run/stream"
+import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
+
+function footer() {
+  const events: FooterEvent[] = []
+  const commits: StreamCommit[] = []
+
+  const api: FooterApi = {
+    isClosed: false,
+    onPrompt: () => () => {},
+    onClose: () => () => {},
+    event: (next) => {
+      events.push(next)
+    },
+    append: (next) => {
+      commits.push(next)
+    },
+    idle: () => Promise.resolve(),
+    close: () => {},
+    destroy: () => {},
+  }
+
+  return { api, events, commits }
+}
+
+describe("run stream bridge", () => {
+  test("forwards commits in order", () => {
+    const out = footer()
+    const commits: StreamCommit[] = [
+      { kind: "assistant", text: "one", phase: "progress", source: "assistant", partID: "a" },
+      { kind: "tool", text: "two", phase: "final", source: "tool", partID: "b", tool: "bash" },
+    ]
+
+    writeSessionOutput(
+      {
+        footer: out.api,
+      },
+      {
+        commits,
+      },
+    )
+
+    expect(out.commits).toEqual(commits)
+  })
+
+  test("defaults status patches to running phase", () => {
+    const out = footer()
+
+    writeSessionOutput(
+      {
+        footer: out.api,
+      },
+      {
+        commits: [],
+        footer: {
+          patch: {
+            status: "assistant responding",
+          },
+        },
+      },
+    )
+
+    expect(out.events).toEqual([
+      {
+        type: "stream.patch",
+        patch: {
+          phase: "running",
+          status: "assistant responding",
+        },
+      },
+    ])
+  })
+
+  test("forwards footer view updates as stream.view events", () => {
+    const out = footer()
+
+    writeSessionOutput(
+      {
+        footer: out.api,
+      },
+      {
+        commits: [],
+        footer: {
+          view: {
+            type: "prompt",
+          },
+        },
+      },
+    )
+
+    expect(out.events).toEqual([
+      {
+        type: "stream.view",
+        view: {
+          type: "prompt",
+        },
+      },
+    ])
+  })
+
+  test("forwards subagent footer snapshots as stream.subagent events", () => {
+    const out = footer()
+
+    writeSessionOutput(
+      {
+        footer: out.api,
+      },
+      {
+        commits: [],
+        footer: {
+          subagent: {
+            tabs: [
+              {
+                sessionID: "child-1",
+                partID: "part-1",
+                callID: "call-1",
+                label: "Explore",
+                description: "Scan reducer paths",
+                status: "running",
+                lastUpdatedAt: 1,
+              },
+            ],
+            details: {
+              "child-1": {
+                sessionID: "child-1",
+                commits: [],
+              },
+            },
+            permissions: [],
+            questions: [],
+          },
+        },
+      },
+    )
+
+    expect(out.events).toEqual([
+      {
+        type: "stream.subagent",
+        state: {
+          tabs: [
+            {
+              sessionID: "child-1",
+              partID: "part-1",
+              callID: "call-1",
+              label: "Explore",
+              description: "Scan reducer paths",
+              status: "running",
+              lastUpdatedAt: 1,
+            },
+          ],
+          details: {
+            "child-1": {
+              sessionID: "child-1",
+              commits: [],
+            },
+          },
+          permissions: [],
+          questions: [],
+        },
+      },
+    ])
+  })
+})

+ 941 - 0
packages/opencode/test/cli/run/stream.transport.test.ts

@@ -0,0 +1,941 @@
+import { describe, expect, test } from "bun:test"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2"
+import { createSessionTransport } from "@/cli/cmd/run/stream.transport"
+import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
+
+function defer<T = void>() {
+  let resolve!: (value: T | PromiseLike<T>) => void
+  let reject!: (error?: unknown) => void
+  const promise = new Promise<T>((next, fail) => {
+    resolve = next
+    reject = fail
+  })
+
+  return { promise, resolve, reject }
+}
+
+function tick() {
+  return new Promise<void>((resolve) => queueMicrotask(resolve))
+}
+
+async function flush(n = 5) {
+  for (let i = 0; i < n; i += 1) {
+    await tick()
+  }
+}
+
+function busy(sessionID = "session-1") {
+  return {
+    type: "session.status",
+    properties: {
+      sessionID,
+      status: {
+        type: "busy",
+      },
+    },
+  }
+}
+
+function idle(sessionID = "session-1") {
+  return {
+    type: "session.status",
+    properties: {
+      sessionID,
+      status: {
+        type: "idle",
+      },
+    },
+  }
+}
+
+function assistant(id: string) {
+  return {
+    type: "message.updated",
+    properties: {
+      sessionID: "session-1",
+      info: {
+        id,
+        role: "assistant",
+        providerID: "openai",
+        modelID: "gpt-5",
+        tokens: {
+          input: 1,
+          output: 1,
+          reasoning: 0,
+          cache: { read: 0, write: 0 },
+        },
+      },
+    },
+  }
+}
+
+function feed() {
+  const list: unknown[] = []
+  let done = false
+  let wake: (() => void) | undefined
+
+  const stream = (async function* () {
+    while (!done || list.length > 0) {
+      if (list.length === 0) {
+        await new Promise<void>((resolve) => {
+          wake = resolve
+        })
+        continue
+      }
+
+      yield list.shift()
+    }
+  })()
+
+  return {
+    stream,
+    push(value: unknown) {
+      list.push(value)
+      wake?.()
+      wake = undefined
+    },
+    close() {
+      done = true
+      wake?.()
+      wake = undefined
+    },
+  }
+}
+
+function blockingFeed() {
+  let done = false
+  let wake: (() => void) | undefined
+  const started = defer()
+
+  const stream: AsyncIterableIterator<unknown> = {
+    [Symbol.asyncIterator]() {
+      return this
+    },
+    next() {
+      started.resolve()
+      if (done) {
+        return Promise.resolve({ done: true, value: undefined })
+      }
+
+      return new Promise((resolve) => {
+        wake = () => {
+          done = true
+          wake = undefined
+          resolve({ done: true, value: undefined })
+        }
+      })
+    },
+    return() {
+      done = true
+      wake?.()
+      wake = undefined
+      return Promise.resolve({ done: true, value: undefined })
+    },
+    throw(error) {
+      done = true
+      wake?.()
+      wake = undefined
+      return Promise.reject(error)
+    },
+  }
+
+  return { stream, started }
+}
+
+function footer(fn?: (commit: StreamCommit) => void) {
+  const commits: StreamCommit[] = []
+  const events: FooterEvent[] = []
+  let closed = false
+
+  const api: FooterApi = {
+    get isClosed() {
+      return closed
+    },
+    onPrompt: () => () => {},
+    onClose: () => () => {},
+    event(next) {
+      events.push(next)
+    },
+    append(next) {
+      commits.push(next)
+      fn?.(next)
+    },
+    idle() {
+      return Promise.resolve()
+    },
+    close() {
+      closed = true
+    },
+    destroy() {
+      closed = true
+    },
+  }
+
+  return { api, commits, events }
+}
+
+function sdk(
+  src: ReturnType<typeof feed>,
+  opt: {
+    promptAsync?: (input: unknown, opt?: { signal?: AbortSignal }) => Promise<void>
+    status?: () => Promise<{ data?: Record<string, { type: string }> }>
+    messages?: (input: {
+      sessionID: string
+      limit?: number
+    }) => Promise<{ data?: Array<{ info: unknown; parts: unknown[] }> }>
+    children?: () => Promise<{ data?: Array<{ id: string }> }>
+    permissions?: () => Promise<{ data?: unknown[] }>
+    questions?: () => Promise<{ data?: unknown[] }>
+  } = {},
+) {
+  return {
+    event: {
+      subscribe: async () => ({
+        stream: src.stream,
+      }),
+    },
+    session: {
+      promptAsync: opt.promptAsync ?? (async () => {}),
+      status: opt.status ?? (async () => ({ data: {} })),
+      messages: opt.messages ?? (async () => ({ data: [] })),
+      children: opt.children ?? (async () => ({ data: [] })),
+    },
+    permission: {
+      list: opt.permissions ?? (async () => ({ data: [] })),
+    },
+    question: {
+      list: opt.questions ?? (async () => ({ data: [] })),
+    },
+  } as unknown as OpencodeClient
+}
+
+describe("run stream transport", () => {
+  test("bootstraps subagent tabs from parent task parts", async () => {
+    const src = feed()
+    const ui = footer()
+    const transport = await createSessionTransport({
+      sdk: sdk(src, {
+        messages: async ({ sessionID }) => {
+          if (sessionID !== "session-1") {
+            throw new Error("unexpected child bootstrap")
+          }
+
+          return {
+            data: [
+              {
+                info: {
+                  id: "msg-1",
+                  role: "assistant",
+                },
+                parts: [
+                  {
+                    id: "task-1",
+                    sessionID: "session-1",
+                    messageID: "msg-1",
+                    type: "tool",
+                    callID: "call-1",
+                    tool: "task",
+                    state: {
+                      status: "running",
+                      input: {
+                        description: "Explore run folder",
+                        subagent_type: "explore",
+                      },
+                      metadata: {
+                        sessionId: "child-1",
+                      },
+                      time: {
+                        start: 1,
+                      },
+                    },
+                  },
+                ],
+              },
+            ],
+          }
+        },
+        children: async () => ({
+          data: [{ id: "child-1" }],
+        }),
+      }),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      expect(ui.events).toContainEqual({
+        type: "stream.subagent",
+        state: {
+          tabs: [
+            expect.objectContaining({
+              sessionID: "child-1",
+              label: "Explore",
+              description: "Explore run folder",
+              status: "running",
+            }),
+          ],
+          details: {},
+          permissions: [],
+          questions: [],
+        },
+      })
+
+      transport.selectSubagent("child-1")
+
+      expect(ui.events).toContainEqual({
+        type: "stream.subagent",
+        state: {
+          tabs: [
+            expect.objectContaining({
+              sessionID: "child-1",
+              label: "Explore",
+              description: "Explore run folder",
+              status: "running",
+            }),
+          ],
+          details: {
+            "child-1": {
+              sessionID: "child-1",
+              commits: [],
+            },
+          },
+          permissions: [],
+          questions: [],
+        },
+      })
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
+  test("bootstraps resumed child permission input without recent parent task parts", async () => {
+    const src = feed()
+    const ui = footer()
+    const transport = await createSessionTransport({
+      sdk: sdk(src, {
+        messages: async ({ sessionID }) => {
+          if (sessionID === "session-1") {
+            return { data: [] }
+          }
+
+          return {
+            data: [
+              {
+                info: {
+                  id: "msg-child-1",
+                  role: "assistant",
+                },
+                parts: [
+                  {
+                    id: "edit-1",
+                    sessionID: "child-1",
+                    messageID: "msg-child-1",
+                    type: "tool",
+                    callID: "call-edit-1",
+                    tool: "edit",
+                    state: {
+                      status: "running",
+                      input: {
+                        filePath: "src/run/subagent-data.ts",
+                        diff: "@@ -1 +1 @@",
+                      },
+                      time: {
+                        start: 1,
+                      },
+                    },
+                  },
+                ],
+              },
+            ],
+          }
+        },
+        children: async () => ({
+          data: [{ id: "child-1" }],
+        }),
+        permissions: async () => ({
+          data: [
+            {
+              id: "perm-1",
+              sessionID: "child-1",
+              permission: "edit",
+              patterns: ["src/run/subagent-data.ts"],
+              metadata: {},
+              always: [],
+              tool: {
+                messageID: "msg-child-1",
+                callID: "call-edit-1",
+              },
+            },
+          ],
+        }),
+      }),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      expect(ui.events).toContainEqual({
+        type: "stream.subagent",
+        state: {
+          tabs: [
+            expect.objectContaining({
+              sessionID: "child-1",
+              status: "running",
+            }),
+          ],
+          details: {},
+          permissions: [
+            expect.objectContaining({
+              id: "perm-1",
+              sessionID: "child-1",
+              metadata: {
+                input: {
+                  filePath: "src/run/subagent-data.ts",
+                  diff: "@@ -1 +1 @@",
+                },
+              },
+            }),
+          ],
+          questions: [],
+        },
+      })
+
+      expect(ui.events).toContainEqual({
+        type: "stream.view",
+        view: {
+          type: "permission",
+          request: expect.objectContaining({
+            id: "perm-1",
+            metadata: {
+              input: {
+                filePath: "src/run/subagent-data.ts",
+                diff: "@@ -1 +1 @@",
+              },
+            },
+          }),
+        },
+      })
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
+  test("respects the includeFiles flag when building prompt payloads", async () => {
+    const src = feed()
+    const ui = footer()
+    const seen: unknown[] = []
+    const file: RunFilePart = {
+      type: "file",
+      url: "file:///tmp/a.ts",
+      filename: "a.ts",
+      mime: "text/plain",
+    }
+
+    const transport = await createSessionTransport({
+      sdk: sdk(src, {
+        promptAsync: async (input) => {
+          seen.push(input)
+          queueMicrotask(() => {
+            src.push(busy())
+            src.push(idle())
+          })
+        },
+      }),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      await transport.runPromptTurn({
+        agent: undefined,
+        model: undefined,
+        variant: undefined,
+        prompt: { text: "hello", parts: [] },
+        files: [file],
+        includeFiles: true,
+      })
+
+      await transport.runPromptTurn({
+        agent: undefined,
+        model: undefined,
+        variant: undefined,
+        prompt: { text: "again", parts: [] },
+        files: [file],
+        includeFiles: false,
+      })
+
+      expect(seen).toEqual([
+        expect.objectContaining({
+          parts: [file, { type: "text", text: "hello" }],
+        }),
+        expect.objectContaining({
+          parts: [{ type: "text", text: "again" }],
+        }),
+      ])
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
+  test("ignores idle events for other sessions", async () => {
+    const src = feed()
+    const ui = footer()
+    const live = defer()
+    const transport = await createSessionTransport({
+      sdk: sdk(src, {
+        promptAsync: async () => {
+          queueMicrotask(() => {
+            src.push(busy())
+            live.resolve()
+          })
+        },
+      }),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      const task = transport.runPromptTurn({
+        agent: undefined,
+        model: undefined,
+        variant: undefined,
+        prompt: { text: "hello", parts: [] },
+        files: [],
+        includeFiles: false,
+      })
+
+      let done = false
+      void task.then(() => {
+        done = true
+      })
+
+      await live.promise
+      await flush()
+      src.push(idle("other-session"))
+      await flush()
+      expect(done).toBe(false)
+      src.push(idle())
+      await task
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
+  test("flushes interrupted output when the active turn aborts", async () => {
+    const src = feed()
+    const seen = defer()
+    const ui = footer((commit) => {
+      if (commit.kind === "assistant" && commit.phase === "progress") {
+        seen.resolve()
+      }
+    })
+    const transport = await createSessionTransport({
+      sdk: sdk(src, {
+        promptAsync: async () => {
+          queueMicrotask(() => {
+            src.push(busy())
+            src.push(assistant("msg-1"))
+            src.push({
+              type: "message.part.updated",
+              properties: {
+                part: {
+                  id: "txt-1",
+                  messageID: "msg-1",
+                  sessionID: "session-1",
+                  type: "text",
+                  text: "",
+                },
+              },
+            })
+            src.push({
+              type: "message.part.delta",
+              properties: {
+                sessionID: "session-1",
+                messageID: "msg-1",
+                partID: "txt-1",
+                field: "text",
+                delta: "unfinished",
+              },
+            })
+          })
+        },
+      }),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    const ctrl = new AbortController()
+
+    try {
+      const task = transport.runPromptTurn({
+        agent: undefined,
+        model: undefined,
+        variant: undefined,
+        prompt: { text: "hello", parts: [] },
+        files: [],
+        includeFiles: false,
+        signal: ctrl.signal,
+      })
+
+      await seen.promise
+      ctrl.abort()
+      await task
+
+      expect(ui.commits).toEqual([
+        {
+          kind: "assistant",
+          text: "unfinished",
+          phase: "progress",
+          source: "assistant",
+          messageID: "msg-1",
+          partID: "txt-1",
+        },
+        {
+          kind: "assistant",
+          text: "",
+          phase: "final",
+          source: "assistant",
+          messageID: "msg-1",
+          partID: "txt-1",
+          interrupted: true,
+        },
+      ])
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
+  test("closes an active turn without rejecting it", async () => {
+    const src = feed()
+    const ui = footer()
+    const ready = defer()
+    let aborted = false
+
+    const transport = await createSessionTransport({
+      sdk: sdk(src, {
+        promptAsync: async (_input, opt) => {
+          ready.resolve()
+          await new Promise<void>((resolve) => {
+            const onAbort = () => {
+              aborted = true
+              opt?.signal?.removeEventListener("abort", onAbort)
+              resolve()
+            }
+
+            opt?.signal?.addEventListener("abort", onAbort, { once: true })
+          })
+        },
+      }),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      const task = transport.runPromptTurn({
+        agent: undefined,
+        model: undefined,
+        variant: undefined,
+        prompt: { text: "hello", parts: [] },
+        files: [],
+        includeFiles: false,
+      })
+
+      await ready.promise
+      await transport.close()
+      await task
+
+      expect(aborted).toBe(true)
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
+  test("rejects the active turn when the event stream faults", async () => {
+    const ui = footer()
+    const ready = defer()
+
+    const transport = await createSessionTransport({
+      sdk: {
+        event: {
+          subscribe: async () => ({
+            stream: (async function* () {
+              await ready.promise
+              yield busy()
+              throw new Error("boom")
+            })(),
+          }),
+        },
+        session: {
+          promptAsync: async () => {
+            ready.resolve()
+          },
+          status: async () => ({ data: { "session-1": { type: "busy" } } }),
+          messages: async () => ({ data: [] }),
+          children: async () => ({ data: [] }),
+        },
+        permission: {
+          list: async () => ({ data: [] }),
+        },
+        question: {
+          list: async () => ({ data: [] }),
+        },
+      } as unknown as OpencodeClient,
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      await expect(
+        transport.runPromptTurn({
+          agent: undefined,
+          model: undefined,
+          variant: undefined,
+          prompt: { text: "hello", parts: [] },
+          files: [],
+          includeFiles: false,
+        }),
+      ).rejects.toThrow("boom")
+    } finally {
+      await transport.close()
+    }
+  })
+
+  test("closes while the event stream is waiting for the next item", async () => {
+    const src = blockingFeed()
+    const ui = footer()
+    const transport = await createSessionTransport({
+      sdk: {
+        event: {
+          subscribe: async () => ({
+            stream: src.stream,
+          }),
+        },
+        session: {
+          promptAsync: async () => {},
+          status: async () => ({ data: {} }),
+          messages: async () => ({ data: [] }),
+          children: async () => ({ data: [] }),
+        },
+        permission: {
+          list: async () => ({ data: [] }),
+        },
+        question: {
+          list: async () => ({ data: [] }),
+        },
+      } as unknown as OpencodeClient,
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      await src.started.promise
+      await Promise.race([
+        transport.close(),
+        new Promise<never>((_, reject) => {
+          setTimeout(() => reject(new Error("close timed out")), 100)
+        }),
+      ])
+    } finally {
+      await transport.close()
+    }
+  })
+
+  test("ignores stale idle events from an earlier turn", async () => {
+    const src = feed()
+    const ui = footer()
+    const live = defer()
+    const done = defer()
+    let call = 0
+    let state: "idle" | "busy" = "idle"
+
+    const transport = await createSessionTransport({
+      sdk: sdk(src, {
+        promptAsync: async () => {
+          call += 1
+          if (call === 1) {
+            queueMicrotask(() => {
+              state = "busy"
+              src.push(busy())
+              state = "idle"
+              src.push(idle())
+            })
+            return
+          }
+
+          queueMicrotask(() => {
+            void (async () => {
+              state = "busy"
+              src.push(busy())
+              live.resolve()
+              await done.promise
+              state = "idle"
+              src.push(idle())
+            })()
+          })
+        },
+        status: async () => {
+          const data: Record<string, { type: string }> = state === "idle" ? {} : { "session-1": { type: "busy" } }
+          return { data }
+        },
+      }),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      await transport.runPromptTurn({
+        agent: undefined,
+        model: undefined,
+        variant: undefined,
+        prompt: { text: "one", parts: [] },
+        files: [],
+        includeFiles: false,
+      })
+
+      let ok = false
+      const task = transport.runPromptTurn({
+        agent: undefined,
+        model: undefined,
+        variant: undefined,
+        prompt: { text: "two", parts: [] },
+        files: [],
+        includeFiles: false,
+      })
+      void task.then(() => {
+        ok = true
+      })
+
+      await live.promise
+      await flush()
+      src.push(idle())
+      await flush()
+      expect(ok).toBe(false)
+
+      done.resolve()
+      await task
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
+  test("rejects concurrent turns", async () => {
+    const src = feed()
+    const ui = footer()
+    const transport = await createSessionTransport({
+      sdk: sdk(src),
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    const ctrl = new AbortController()
+
+    try {
+      const task = transport.runPromptTurn({
+        agent: undefined,
+        model: undefined,
+        variant: undefined,
+        prompt: { text: "one", parts: [] },
+        files: [],
+        includeFiles: false,
+        signal: ctrl.signal,
+      })
+
+      await expect(
+        transport.runPromptTurn({
+          agent: undefined,
+          model: undefined,
+          variant: undefined,
+          prompt: { text: "two", parts: [] },
+          files: [],
+          includeFiles: false,
+        }),
+      ).rejects.toThrow("prompt already running")
+
+      ctrl.abort()
+      await task
+    } finally {
+      src.close()
+      await transport.close()
+    }
+  })
+
+  test("surfaces event stream faults on later turns", async () => {
+    const ui = footer()
+    const hit = defer()
+    const boom = defer()
+    const transport = await createSessionTransport({
+      sdk: {
+        event: {
+          subscribe: async () => ({
+            stream: (async function* () {
+              hit.resolve()
+              await boom.promise
+              throw new Error("boom")
+            })(),
+          }),
+        },
+        session: {
+          promptAsync: async () => {},
+          status: async () => ({ data: {} }),
+          messages: async () => ({ data: [] }),
+          children: async () => ({ data: [] }),
+        },
+        permission: {
+          list: async () => ({ data: [] }),
+        },
+        question: {
+          list: async () => ({ data: [] }),
+        },
+      } as unknown as OpencodeClient,
+      sessionID: "session-1",
+      thinking: true,
+      limits: () => ({}),
+      footer: ui.api,
+    })
+
+    try {
+      await hit.promise
+      boom.resolve()
+      await flush()
+      await expect(
+        transport.runPromptTurn({
+          agent: undefined,
+          model: undefined,
+          variant: undefined,
+          prompt: { text: "hello", parts: [] },
+          files: [],
+          includeFiles: false,
+        }),
+      ).rejects.toThrow("boom")
+    } finally {
+      await transport.close()
+    }
+  })
+})

+ 394 - 0
packages/opencode/test/cli/run/subagent-data.test.ts

@@ -0,0 +1,394 @@
+import { describe, expect, test } from "bun:test"
+import { entryBody } from "@/cli/cmd/run/entry.body"
+import {
+  bootstrapSubagentData,
+  clearFinishedSubagents,
+  createSubagentData,
+  reduceSubagentData,
+  snapshotSelectedSubagentData,
+  snapshotSubagentData,
+} from "@/cli/cmd/run/subagent-data"
+
+function visible(commits: Array<Parameters<typeof entryBody>[0]>) {
+  return commits.flatMap((item) => {
+    const body = entryBody(item)
+    if (body.type === "none") {
+      return []
+    }
+
+    if (body.type === "structured") {
+      if (body.snapshot.kind === "code" || body.snapshot.kind === "task") {
+        return [body.snapshot.title]
+      }
+
+      if (body.snapshot.kind === "diff") {
+        return body.snapshot.items.map((item) => item.title)
+      }
+
+      if (body.snapshot.kind === "todo") {
+        return ["# Todos"]
+      }
+
+      return ["# Questions"]
+    }
+
+    return [body.content]
+  })
+}
+
+function taskMessage(sessionID: string, status: "running" | "completed" | "error" = "completed") {
+  return {
+    info: {
+      id: `msg-${sessionID}`,
+      role: "assistant",
+    },
+    parts: [
+      {
+        id: `part-${sessionID}`,
+        sessionID: "parent-1",
+        messageID: `msg-${sessionID}`,
+        type: "tool",
+        callID: `call-${sessionID}`,
+        tool: "task",
+        state: {
+          status,
+          input: {
+            description: "Scan reducer paths",
+            subagent_type: "explore",
+          },
+          title: "Reducer touchpoints",
+          metadata: {
+            sessionId: sessionID,
+            toolcalls: 4,
+          },
+          time: status === "running" ? { start: 1 } : { start: 1, end: 2 },
+        },
+      },
+    ],
+  } as const
+}
+
+function question(id: string, sessionID: string) {
+  return {
+    id,
+    sessionID,
+    questions: [
+      {
+        question: "Mode?",
+        header: "Mode",
+        options: [{ label: "Fast", description: "Quick pass" }],
+      },
+    ],
+  }
+}
+
+describe("run subagent data", () => {
+  test("bootstraps tabs and child blockers from parent task parts", () => {
+    const data = createSubagentData()
+
+    expect(
+      bootstrapSubagentData({
+        data,
+        messages: [taskMessage("child-1") as never],
+        children: [{ id: "child-1" }, { id: "child-2" }],
+        permissions: [
+          {
+            id: "perm-1",
+            sessionID: "child-1",
+            permission: "read",
+            patterns: ["src/**/*.ts"],
+            metadata: {},
+            always: [],
+          },
+          {
+            id: "perm-2",
+            sessionID: "other",
+            permission: "read",
+            patterns: ["src/**/*.ts"],
+            metadata: {},
+            always: [],
+          },
+        ],
+        questions: [question("question-1", "child-1"), question("question-2", "other")],
+      }),
+    ).toBe(true)
+
+    expect(snapshotSubagentData(data)).toEqual({
+      tabs: [
+        expect.objectContaining({
+          sessionID: "child-1",
+          label: "Explore",
+          description: "Scan reducer paths",
+          title: "Reducer touchpoints",
+          status: "completed",
+          toolCalls: 4,
+        }),
+      ],
+      details: {
+        "child-1": {
+          sessionID: "child-1",
+          commits: [],
+        },
+      },
+      permissions: [expect.objectContaining({ id: "perm-1", sessionID: "child-1" })],
+      questions: [expect.objectContaining({ id: "question-1", sessionID: "child-1" })],
+    })
+  })
+
+  test("reduces child text tool and blocker events into footer detail state", () => {
+    const data = createSubagentData()
+
+    bootstrapSubagentData({
+      data,
+      messages: [taskMessage("child-1", "running") as never],
+      children: [{ id: "child-1" }],
+      permissions: [],
+      questions: [],
+    })
+
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "txt-1",
+            messageID: "msg-user-1",
+            sessionID: "child-1",
+            type: "text",
+            text: "Inspect footer tabs",
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.updated",
+        properties: {
+          sessionID: "child-1",
+          info: {
+            id: "msg-user-1",
+            role: "user",
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.updated",
+        properties: {
+          sessionID: "child-1",
+          info: {
+            id: "msg-assistant-1",
+            role: "assistant",
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "reason-1",
+            messageID: "msg-assistant-1",
+            sessionID: "child-1",
+            type: "reasoning",
+            text: "planning next steps",
+            time: { start: 1 },
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "tool-1",
+            messageID: "msg-assistant-1",
+            sessionID: "child-1",
+            type: "tool",
+            callID: "call-1",
+            tool: "bash",
+            state: {
+              status: "running",
+              input: {
+                command: "git status --short",
+              },
+              time: { start: 1 },
+            },
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "permission.asked",
+        properties: {
+          id: "perm-1",
+          sessionID: "child-1",
+          permission: "bash",
+          patterns: ["git status --short"],
+          metadata: {},
+          always: [],
+          tool: {
+            messageID: "msg-assistant-1",
+            callID: "call-1",
+          },
+        },
+      } as never,
+    })
+
+    const snapshot = snapshotSubagentData(data)
+
+    expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })])
+    expect(snapshot.details["child-1"]).toEqual({
+      sessionID: "child-1",
+      commits: expect.any(Array),
+    })
+    expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
+      "› Inspect footer tabs",
+      "_Thinking:_ planning next steps",
+      "# Shell\n$ git status --short",
+    ])
+    expect(snapshot.permissions).toEqual([
+      expect.objectContaining({
+        id: "perm-1",
+        metadata: {
+          input: {
+            command: "git status --short",
+          },
+        },
+      }),
+    ])
+    expect(snapshot.questions).toEqual([])
+  })
+
+  test("continues live child text streams", () => {
+    const data = createSubagentData()
+
+    bootstrapSubagentData({
+      data,
+      messages: [taskMessage("child-1", "running") as never],
+      children: [{ id: "child-1" }],
+      permissions: [],
+      questions: [],
+    })
+
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.updated",
+        properties: {
+          sessionID: "child-1",
+          info: {
+            id: "msg-assistant-1",
+            role: "assistant",
+          },
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "txt-1",
+            messageID: "msg-assistant-1",
+            sessionID: "child-1",
+            type: "text",
+            text: "hello",
+          },
+        },
+      } as never,
+    })
+
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.delta",
+        properties: {
+          sessionID: "child-1",
+          messageID: "msg-assistant-1",
+          partID: "txt-1",
+          field: "text",
+          delta: " world",
+        },
+      } as never,
+    })
+    reduceSubagentData({
+      data,
+      sessionID: "parent-1",
+      thinking: true,
+      limits: {},
+      event: {
+        type: "message.part.updated",
+        properties: {
+          part: {
+            id: "txt-1",
+            messageID: "msg-assistant-1",
+            sessionID: "child-1",
+            type: "text",
+            text: "hello world",
+            time: { start: 1, end: 2 },
+          },
+        },
+      } as never,
+    })
+
+    expect(visible(snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits ?? [])).toEqual([
+      "hello world",
+    ])
+  })
+
+  test("clears finished tabs on the next parent prompt", () => {
+    const data = createSubagentData()
+
+    bootstrapSubagentData({
+      data,
+      messages: [taskMessage("child-1", "completed") as never, taskMessage("child-2", "running") as never],
+      children: [{ id: "child-1" }, { id: "child-2" }],
+      permissions: [],
+      questions: [],
+    })
+
+    expect(clearFinishedSubagents(data)).toBe(true)
+    expect(snapshotSubagentData(data).tabs).toEqual([
+      expect.objectContaining({ sessionID: "child-2", status: "running" }),
+    ])
+  })
+})

+ 81 - 0
packages/opencode/test/cli/run/theme.test.ts

@@ -0,0 +1,81 @@
+import { expect, test } from "bun:test"
+import { RGBA, SyntaxStyle, type CliRenderer, type TerminalColors } from "@opentui/core"
+import { opaqueSyntaxStyle, resolveRunTheme } from "@/cli/cmd/run/theme"
+import { generateSystem, resolveTheme } from "@/cli/cmd/tui/context/theme"
+
+test("flattens subtle syntax alpha against the run background", () => {
+  const syntax = SyntaxStyle.fromStyles({
+    default: {
+      fg: RGBA.fromInts(169, 177, 214, 153),
+    },
+    emphasis: {
+      fg: RGBA.fromInts(224, 175, 104, 153),
+      italic: true,
+      bold: true,
+    },
+  })
+  const subtle = opaqueSyntaxStyle(syntax, RGBA.fromInts(42, 43, 61))
+
+  try {
+    expect(subtle?.getStyle("default")?.fg?.toInts()).toEqual([118, 123, 153, 255])
+    expect(subtle?.getStyle("emphasis")?.fg?.toInts()).toEqual([151, 122, 87, 255])
+    expect(subtle?.getStyle("emphasis")?.italic).toBe(true)
+    expect(subtle?.getStyle("emphasis")?.bold).toBe(true)
+  } finally {
+    syntax.destroy()
+    subtle?.destroy()
+  }
+})
+
+const colors: TerminalColors = {
+  palette: [
+    "#15161e",
+    "#f7768e",
+    "#9ece6a",
+    "#e0af68",
+    "#7aa2f7",
+    "#bb9af7",
+    "#7dcfff",
+    "#a9b1d6",
+    "#414868",
+    "#f7768e",
+    "#9ece6a",
+    "#e0af68",
+    "#7aa2f7",
+    "#bb9af7",
+    "#7dcfff",
+    "#c0caf5",
+  ],
+  defaultBackground: "#1a1b26",
+  defaultForeground: "#c0caf5",
+  cursorColor: "#ff9e64",
+  mouseForeground: null,
+  mouseBackground: null,
+  tekForeground: null,
+  tekBackground: null,
+  highlightBackground: "#33467c",
+  highlightForeground: "#c0caf5",
+}
+
+function renderer(themeMode: "dark" | "light") {
+  const item = {
+    themeMode,
+    getPalette: async () => colors,
+  } satisfies Pick<CliRenderer, "themeMode" | "getPalette">
+
+  return item as CliRenderer
+}
+
+test("system theme uses terminal ui colors for primary", () => {
+  const theme = resolveTheme(generateSystem(colors, "dark"), "dark")
+
+  expect(theme.primary).toEqual(RGBA.fromHex(colors.cursorColor!))
+  expect(theme.primary).not.toEqual(RGBA.fromHex(colors.palette[6]!))
+})
+
+test("resolve run theme uses the system primary for footer highlight", async () => {
+  const expected = resolveTheme(generateSystem(colors, "dark"), "dark")
+  const theme = await resolveRunTheme(renderer("dark"))
+
+  expect(theme.footer.highlight).toEqual(expected.primary)
+})

+ 166 - 0
packages/opencode/test/cli/run/variant.shared.test.ts

@@ -0,0 +1,166 @@
+import path from "path"
+import { NodeFileSystem } from "@effect/platform-node"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { describe, expect, test } from "bun:test"
+import { Effect, FileSystem, Layer } from "effect"
+import { Global } from "@/global"
+import {
+  createVariantRuntime,
+  cycleVariant,
+  formatModelLabel,
+  pickVariant,
+  resolveVariant,
+} from "@/cli/cmd/run/variant.shared"
+import type { SessionMessages } from "@/cli/cmd/run/session.shared"
+import { testEffect } from "../../lib/effect"
+
+const model = {
+  providerID: "openai",
+  modelID: "gpt-5",
+}
+
+const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, NodeFileSystem.layer))
+
+function remap(root: string, file: string) {
+  if (file === Global.Path.state) {
+    return root
+  }
+
+  if (file.startsWith(Global.Path.state + path.sep)) {
+    return path.join(root, path.relative(Global.Path.state, file))
+  }
+
+  return file
+}
+
+function remappedFs(root: string) {
+  return Layer.effect(
+    AppFileSystem.Service,
+    Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+      return AppFileSystem.Service.of({
+        ...fs,
+        readJson: (file) => fs.readJson(remap(root, file)),
+        writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
+      })
+    }),
+  ).pipe(Layer.provide(AppFileSystem.defaultLayer))
+}
+
+describe("run variant shared", () => {
+  test("prefers cli then session then saved variants", () => {
+    expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max")
+    expect(resolveVariant(undefined, "high", "low", ["low", "high"])).toBe("high")
+    expect(resolveVariant(undefined, "missing", "low", ["low", "high"])).toBe("low")
+  })
+
+  test("cycles through variants and back to default", () => {
+    expect(cycleVariant(undefined, ["low", "high"])).toBe("low")
+    expect(cycleVariant("low", ["low", "high"])).toBe("high")
+    expect(cycleVariant("high", ["low", "high"])).toBeUndefined()
+    expect(cycleVariant(undefined, [])).toBeUndefined()
+  })
+
+  test("formats model labels", () => {
+    expect(formatModelLabel(model, undefined)).toBe("gpt-5 · openai")
+    expect(formatModelLabel(model, "high")).toBe("gpt-5 · openai · high")
+  })
+
+  test("picks the latest matching variant from raw session messages", () => {
+    const msgs = [
+      {
+        info: {
+          role: "user",
+          model: {
+            providerID: "openai",
+            modelID: "gpt-5",
+            variant: "high",
+          },
+        },
+        parts: [],
+      },
+      {
+        info: {
+          role: "user",
+          model: {
+            providerID: "anthropic",
+            modelID: "sonnet",
+            variant: "max",
+          },
+        },
+        parts: [],
+      },
+      {
+        info: {
+          role: "user",
+          model: {
+            providerID: "openai",
+            modelID: "gpt-5",
+            variant: "minimal",
+          },
+        },
+        parts: [],
+      },
+    ] as unknown as SessionMessages
+
+    expect(pickVariant(model, msgs)).toBe("minimal")
+  })
+
+  it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
+    Effect.gen(function* () {
+      const filesys = yield* FileSystem.FileSystem
+      const fs = yield* AppFileSystem.Service
+      const root = yield* filesys.makeTempDirectoryScoped()
+      const file = path.join(root, "model.json")
+
+      yield* fs.writeJson(file, {
+        recent: [{ providerID: "anthropic", modelID: "sonnet" }],
+        variant: {
+          "openai/gpt-4.1": "low",
+        },
+      })
+
+      const svc = createVariantRuntime(remappedFs(root))
+
+      yield* Effect.promise(() => svc.saveVariant(model, "high"))
+      expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
+      expect(yield* fs.readJson(file)).toEqual({
+        recent: [{ providerID: "anthropic", modelID: "sonnet" }],
+        variant: {
+          "openai/gpt-4.1": "low",
+          "openai/gpt-5": "high",
+        },
+      })
+
+      yield* Effect.promise(() => svc.saveVariant(model, undefined))
+      expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBeUndefined()
+      expect(yield* fs.readJson(file)).toEqual({
+        recent: [{ providerID: "anthropic", modelID: "sonnet" }],
+        variant: {
+          "openai/gpt-4.1": "low",
+        },
+      })
+    }),
+  )
+
+  it.live("repairs malformed saved variant state on the next write", () =>
+    Effect.gen(function* () {
+      const filesys = yield* FileSystem.FileSystem
+      const fs = yield* AppFileSystem.Service
+      const root = yield* filesys.makeTempDirectoryScoped()
+      const file = path.join(root, "model.json")
+
+      yield* filesys.writeFileString(file, "{")
+
+      const svc = createVariantRuntime(remappedFs(root))
+
+      yield* Effect.promise(() => svc.saveVariant(model, "high"))
+      expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
+      expect(yield* fs.readJson(file)).toEqual({
+        variant: {
+          "openai/gpt-5": "high",
+        },
+      })
+    }),
+  )
+})

+ 4 - 4
packages/plugin/package.json

@@ -22,8 +22,8 @@
     "zod": "catalog:"
   },
   "peerDependencies": {
-    "@opentui/core": ">=0.1.101",
-    "@opentui/solid": ">=0.1.101"
+    "@opentui/core": ">=0.1.102",
+    "@opentui/solid": ">=0.1.102"
   },
   "peerDependenciesMeta": {
     "@opentui/core": {
@@ -34,8 +34,8 @@
     }
   },
   "devDependencies": {
-    "@opentui/core": "0.1.101",
-    "@opentui/solid": "0.1.101",
+    "@opentui/core": "0.1.102",
+    "@opentui/solid": "0.1.102",
     "@tsconfig/node22": "catalog:",
     "@types/node": "catalog:",
     "typescript": "catalog:",