Compare commits

..

2 Commits

Author SHA1 Message Date
Dax Raad 20f6635ca0 fix(cli): pass initialized SDK to TUI 2026-06-07 13:15:12 -04:00
Dax Raad a7f335aec0 refactor(tui): use authenticated HTTP SDK transport 2026-06-07 13:14:25 -04:00
89 changed files with 1233 additions and 3818 deletions
+4 -2
View File
@@ -1,3 +1,5 @@
# web + desktop packages
packages/app/ @Hona @Brendonovich
packages/desktop/ @Hona @Brendonovich
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin
+2 -4
View File
@@ -526,7 +526,7 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -1645,7 +1645,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -5673,8 +5673,6 @@
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
"@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-OttgLsPJSYW1b9RGqiYSi7qPdDC3GuD2FE7k5tPe6Iw=",
"aarch64-linux": "sha256-VsmW8tTiT3VkTxp5oiYc/maJLSsBMxK6VVUSVshWVT8=",
"aarch64-darwin": "sha256-/MaQBX2zoJMtLA8jHt8+uKpIFve/eec3RfcKT3FvXC8=",
"x86_64-darwin": "sha256-3ggap9rgR2TvVbYgnXsgkg6Rj1TqNZu32QgTVRdN0f0="
"x86_64-linux": "sha256-yZeq16sWAtsAHZO3pbsr90t3+8PlOueRym2J/Kgpj1U=",
"aarch64-linux": "sha256-m54/gm6R8MyQXBh68K8McAfZ9nLLK08nYQwMvAsTs8o=",
"aarch64-darwin": "sha256-pbtcF4ZCUqKO/SVLxv4wskl+O1LlT2RNsUV5d4s9+WY=",
"x86_64-darwin": "sha256-o3ucdbFNy2y9Hrb594Y9AtpRBUPglhqZLtTyiqMECR8="
}
}
@@ -509,16 +509,11 @@ export function useServerManagementController(options: { onSelect?: () => void;
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
async function handleRemove(url: ServerConnection.Key) {
tabs.removeServer(url)
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)
}
}
+11 -11
View File
@@ -71,17 +71,6 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
@@ -117,6 +106,17 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === selectedDistro()
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
+5 -1
View File
@@ -76,7 +76,11 @@ export function WslServerSettings(props: {
}))
const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.controller.handleRemove(key))
if (!api) return
request.mutate(async () => {
await api.removeServer(key)
await props.controller.handleRemove(key)
})
}
return (
+2 -2
View File
@@ -2,14 +2,14 @@ import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
return run({
...transport,
sdk: createOpencodeClient({ baseUrl: transport.url, headers: transport.headers, fetch: gracefulFetch }),
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
@@ -501,7 +501,7 @@ export default function PrivacyPolicy() {
otherwise use the Services or send us any Personal Data. If we learn we have collected Personal Data
from a child under 18 years of age, we will delete that information as quickly as possible. If you
believe that a child under 18 years of age may have provided Personal Data to us, please contact us at{" "}
<a href="mailto:help@anoma.ly">help@anoma.ly</a>.
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h2 id="california-resident-rights">California Resident Rights</h2>
@@ -520,7 +520,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a California resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access</h3>
@@ -605,7 +605,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Colorado resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -676,7 +676,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Connecticut resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -745,7 +745,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Delaware resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -818,7 +818,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are an Iowa resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -864,7 +864,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Montana resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -937,7 +937,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Nebraska resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1007,7 +1007,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a New Hampshire resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1078,7 +1078,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a New Jersey resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1151,7 +1151,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are an Oregon resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1225,7 +1225,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Texas resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1293,7 +1293,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Utah resident, the portion that is more protective of Personal Data shall control to the extent of
such conflict. If you have any questions about this section or whether any of the following rights apply
to you, please contact us at help@anoma.ly.
to you, please contact us at contact@anoma.ly.
</p>
<h3>Access and Portability</h3>
@@ -1339,7 +1339,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Virginia resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1418,7 +1418,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1430,7 +1430,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1457,7 +1457,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1474,8 +1474,8 @@ export default function PrivacyPolicy() {
<p>
Under California Civil Code Sections 1798.83-1798.84, California residents are entitled to contact us to
prevent disclosure of Personal Data to third parties for such third parties' direct marketing purposes;
in order to submit such a request, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
.
in order to submit such a request, please contact us at{" "}
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<p>
@@ -1500,7 +1500,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email: <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Phone: +1 415 794-0209</li>
<li>Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States</li>
@@ -30,7 +30,7 @@ export default function TermsOfService() {
</p>
<p>
Email: <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</p>
<p>
@@ -114,7 +114,7 @@ export default function TermsOfService() {
attempt to register for or otherwise use the Services or send us any personal information. If we learn
we have collected personal information from a child under 13 years of age, we will delete that
information as quickly as possible. If you believe that a child under 13 years of age may have provided
us personal information, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
us personal information, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h2 id="what-are-the-basics">What are the basics of using OpenCode?</h2>
@@ -315,7 +315,7 @@ export default function TermsOfService() {
specified time of the trial. You must stop using a Paid Service before the end of the trial period in
order to avoid being charged for that Paid Service. If you cancel prior to the end of the trial period
and are inadvertently charged for a Paid Service, please contact us at{" "}
<a href="mailto:help@anoma.ly">help@anoma.ly</a>.
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h2 id="what-if-i-want-to-stop">What if I want to stop using the Services?</h2>
@@ -226,7 +226,7 @@ export async function POST(input: APIEvent) {
expand: ["discounts", "payments"],
})
const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string
const couponID = (invoice.discounts[0] as Stripe.Discount)?.coupon?.id as string
const couponID = (invoice.discounts[0] as Stripe.Discount).coupon?.id as string
if (!paymentID) {
// payment id can be undefined when using coupon
if (!couponID) throw new Error("Payment ID not found")
@@ -143,7 +143,7 @@ export function BillingSection() {
<h2>{i18n.t("workspace.billing.title")}</h2>
<p>
{i18n.t("workspace.billing.subtitle.beforeLink")}{" "}
<a href="mailto:help@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
<a href="mailto:contact@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
{i18n.t("workspace.billing.subtitle.afterLink")}
</p>
</div>
-4
View File
@@ -1,6 +1,5 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -101,9 +100,6 @@ describe("SkillTool", () => {
type: "text",
value: SkillTool.toModelOutput(info, [reference]),
})
expect(SkillTool.toModelOutput(info, [reference])).toContain(
`Base directory for this skill: ${pathToFileURL(directory).href}`,
)
expect(
yield* settleTool(registry, {
sessionID,
+4
View File
@@ -4,4 +4,8 @@ type Channel = "dev" | "beta" | "prod"
const raw = import.meta.env.OPENCODE_CHANNEL
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
+3 -7
View File
@@ -145,10 +145,8 @@ const main = Effect.gen(function* () {
})
},
{
logger: {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
)
const stopSidecars = async () => {
@@ -329,9 +327,7 @@ const main = Effect.gen(function* () {
password,
})
if (process.platform === "win32") {
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
}
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
yield* Effect.promise(() => health.wait).pipe(
Effect.timeout("30 seconds"),
+2 -3
View File
@@ -2,10 +2,9 @@ import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { app, utilityProcess } from "electron"
import type { Details } from "electron"
import { getLogger } from "./logging"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
export type HealthCheck = { wait: Promise<void> }
@@ -44,7 +43,7 @@ export function setDefaultServerUrl(url: string | null) {
export function preferAppEnv(userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
Object.assign(process.env, {
...(shell ? loadShellEnv(shell, getLogger()) : null),
...(shell ? loadShellEnv(shell) : null),
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
+3 -4
View File
@@ -1,13 +1,11 @@
import { spawnSync } from "node:child_process"
import { userInfo } from "node:os"
import { basename } from "node:path"
import { getLogger } from "./logging"
const TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
type ShellEnvLogger = {
log: (message: string) => void
}
export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) {
const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined
@@ -67,7 +65,8 @@ export function isNushell(shell: string) {
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
}
export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
export function loadShellEnv(shell: string) {
const logger = getLogger()
if (isNushell(shell)) {
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
return null
-4
View File
@@ -1,4 +0,0 @@
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
+3 -3
View File
@@ -1,7 +1,7 @@
import Store from "electron-store"
import electron from "electron"
import { app } from "electron"
import { SETTINGS_STORE } from "./store-keys"
import { SETTINGS_STORE } from "./constants"
const cache = new Map<string, Store>()
@@ -14,7 +14,7 @@ export function getStore(name = SETTINGS_STORE) {
if (cached) return cached
const next = new Store({
name,
cwd: electron.app.getPath("userData"),
cwd: app.getPath("userData"),
fileExtension: "",
accessPropertiesByDotNotation: false,
})
+1 -1
View File
@@ -6,9 +6,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
import { PINCH_ZOOM_ENABLED_KEY } from "./constants"
import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore } from "./store"
import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
const root = dirname(fileURLToPath(import.meta.url))
-43
View File
@@ -2,14 +2,8 @@ import { app, ipcMain } from "electron"
import type { IpcMainInvokeEvent } from "electron"
import type { WslServersController } from "./servers"
import { requireWslIpcString } from "./policy"
import type { WslServersState } from "../../preload/types"
export function registerWslIpcHandlers(controller: WslServersController) {
if (process.platform !== "win32") {
registerUnavailableWslIpcHandlers()
return
}
const subscriptions = new Map<number, () => void>()
const unsubscribe = (id: number) => {
const off = subscriptions.get(id)
@@ -68,40 +62,3 @@ export function registerWslIpcHandlers(controller: WslServersController) {
controller.startServer(requireWslIpcString("server id", id)),
)
}
function registerUnavailableWslIpcHandlers() {
const unavailable = () => {
throw new Error("WSL is only available on Windows")
}
const state = (): WslServersState => ({
runtime: {
available: false,
version: null,
error: "WSL is only available on Windows",
},
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
})
ipcMain.handle("wsl-servers-subscribe", (event) => {
event.sender.send("wsl-servers-event", { type: "state", state: state() })
})
ipcMain.handle("wsl-servers-unsubscribe", () => undefined)
ipcMain.handle("wsl-servers-get-state", () => state())
ipcMain.handle("wsl-servers-probe-runtime", unavailable)
ipcMain.handle("wsl-servers-refresh-distros", unavailable)
ipcMain.handle("wsl-servers-install-wsl", unavailable)
ipcMain.handle("wsl-servers-install-distro", unavailable)
ipcMain.handle("wsl-servers-probe-distro", unavailable)
ipcMain.handle("wsl-servers-probe-opencode", unavailable)
ipcMain.handle("wsl-servers-install-opencode", unavailable)
ipcMain.handle("wsl-servers-open-terminal", unavailable)
ipcMain.handle("wsl-servers-add", unavailable)
ipcMain.handle("wsl-servers-remove", unavailable)
ipcMain.handle("wsl-servers-start", unavailable)
}
@@ -6,10 +6,6 @@ import {
pollWslHealth,
wslServerIdsToStartOnInitialize,
} from "./startup"
import { createWslServersController, type WslServerConfig } from "./servers"
let persistedServers: WslServerConfig[] = []
let releaseOpencodeResolve: (() => void) | undefined
test("starts every configured WSL server on initialization", () => {
expect(
@@ -95,73 +91,3 @@ test("derives a required Windows restart from the post-install runtime probe", (
expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
})
test("ignores stale background OpenCode checks after removing a WSL server", async () => {
persistedServers = []
releaseOpencodeResolve = undefined
const controller = createWslServersController(
"1.16.2",
async () => ({
listener: {
stop: () => undefined,
onExit: () => undefined,
},
url: "http://127.0.0.1:4096",
username: "opencode",
password: "secret",
}),
testControllerOptions(),
)
await controller.addServer("Debian")
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
})
test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
releaseOpencodeResolve = undefined
const controller = createWslServersController(
"1.16.2",
async () => new Promise<never>(() => undefined),
testControllerOptions(),
)
await controller.initialize()
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
})
async function waitFor(check: () => boolean) {
for (let attempt = 0; attempt < 20; attempt++) {
if (check()) return
await new Promise((resolve) => setTimeout(resolve, 0))
}
throw new Error("Timed out waiting for condition")
}
function testControllerOptions() {
return {
readServers: () => persistedServers,
writeServers: (servers: WslServerConfig[]) => {
persistedServers = servers
},
readCommandVersion: async () => "1.16.2",
resolveOpencode: async () => {
await new Promise<void>((resolve) => {
releaseOpencodeResolve = resolve
})
return "/home/me/.opencode/bin/opencode"
},
}
}
+13 -72
View File
@@ -11,7 +11,7 @@ import type {
WslServersEvent,
WslServersState,
} from "../../preload/types"
import { WSL_SERVERS_KEY } from "../store-keys"
import { WSL_SERVERS_KEY } from "../constants"
import { getStore } from "../store"
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
@@ -43,33 +43,18 @@ type ControllerLogger = {
error: (message: string, meta?: unknown) => void
}
type WslServersControllerOptions = {
logger?: ControllerLogger
readServers?: () => WslServerConfig[]
writeServers?: (servers: WslServerConfig[]) => void
resolveOpencode?: typeof resolveWslOpencode
readCommandVersion?: typeof readWslCommandVersion
}
export type WslServersController = ReturnType<typeof createWslServersController>
export function wslServerIdForDistro(distro: string) {
return `wsl:${distro}`
}
export function createWslServersController(
appVersion: string,
spawnSidecar: SpawnSidecar,
options?: WslServersControllerOptions,
) {
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
const logger = options?.logger
const readServers = options?.readServers ?? readPersistedServers
const writeServers = options?.writeServers ?? writePersistedServers
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
@@ -81,7 +66,7 @@ export function createWslServersController(
}
const persistServers = (servers: WslServerConfig[]) => {
writeServers(servers)
getStore().set(WSL_SERVERS_KEY, { servers })
}
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
@@ -104,7 +89,7 @@ export function createWslServersController(
}
const refreshFromStore = () => {
const persisted = readServers()
const persisted = readPersistedServers()
const items: WslServerItem[] = persisted.map((config) => {
const existing = state.servers.find((item) => item.config.id === config.id)
return {
@@ -128,52 +113,10 @@ export function createWslServersController(
})
}
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
const version = resolved
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
: null
return opencodeCheck(distro, resolved, version, appVersion)
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
setOpencodeCheck(distro, await checkOpencode(distro, opts))
}
const hasServer = (id: string, distro: string) => {
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
}
const refreshOpencodeCheckBackground = (id: string, distro: string) => {
void checkOpencode(distro)
.then((check) => {
if (!hasServer(id, distro)) return
setOpencodeCheck(distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro, message })
})
}
const refreshOpencodeChecks = async () => {
await Promise.all(
state.servers.map((item) =>
checkOpencode(item.config.distro)
.then((check) => {
if (!hasServer(item.config.id, item.config.distro)) return
setOpencodeCheck(item.config.distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", {
id: item.config.id,
distro: item.config.distro,
message,
})
}),
),
)
const resolved = await resolveWslOpencode(distro, opts)
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
}
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
@@ -227,7 +170,10 @@ export function createWslServersController(
setRuntime(id, { kind: "failed", message })
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
})
refreshOpencodeCheckBackground(id, item.config.distro)
void refreshOpencodeCheck(item.config.distro).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
})
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -279,7 +225,6 @@ export function createWslServersController(
async initialize() {
refreshFromStore()
void refreshOpencodeChecks()
for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
},
@@ -366,7 +311,7 @@ export function createWslServersController(
id,
distro,
}
persistServers([...readServers(), config])
persistServers([...readPersistedServers(), config])
setState({
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
})
@@ -378,7 +323,7 @@ export function createWslServersController(
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
invalidateStartAttempt(id)
await stopServerInternal(id)
const remaining = readServers().filter((item) => item.id !== id)
const remaining = readPersistedServers().filter((item) => item.id !== id)
persistServers(remaining)
setState({
servers: state.servers.filter((item) => item.config.id !== id),
@@ -426,10 +371,6 @@ function readPersistedServers(): WslServerConfig[] {
return []
}
function writePersistedServers(servers: WslServerConfig[]) {
getStore().set(WSL_SERVERS_KEY, { servers })
}
function normalizePersistedServer(value: unknown): WslServerConfig[] {
if (!value || typeof value !== "object") return []
const record = value as Record<string, unknown>
+1 -1
View File
@@ -80,7 +80,7 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
-1
View File
@@ -330,7 +330,6 @@ export class Subscription {
...pendingToolCall({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
+4 -8
View File
@@ -118,18 +118,14 @@ export function completedToolContent(toolName: string, state: CompletedToolState
return content
}
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
}): ToolCall {
export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall {
return {
toolCallId: input.toolCallId,
title: input.state.title || input.toolName,
title: input.toolName,
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
locations: [],
rawInput: {},
}
}
+20 -11
View File
@@ -1,8 +1,10 @@
import { cmd } from "./cmd"
import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -61,15 +63,22 @@ export const AttachCommand = cmd({
}
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const sdk = createOpencodeClient({ baseUrl: args.url, directory, headers })
const config = await TuiConfig.get()
try {
await validateSession({
url: args.url,
sessionID: args.session,
directory,
headers,
})
if (args.session) {
const sessionID = (() => {
try {
return Schema.decodeUnknownSync(SessionID)(args.session)
} catch (error) {
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, {
cause: error,
})
}
})()
await sdk.session.get({ sessionID }, { throwOnError: true })
}
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
@@ -77,11 +86,12 @@ export const AttachCommand = cmd({
}
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { Global } = await import("@opencode-ai/core/global")
const { run } = await import("@opencode-ai/tui")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
url: args.url,
sdk,
config,
pluginHost: createLegacyTuiPluginHost(),
args: {
@@ -90,8 +100,7 @@ export const AttachCommand = cmd({
fork: args.fork,
},
directory,
headers,
}),
}).pipe(Effect.provide(Global.defaultLayer)),
)
},
})
+1 -1
View File
@@ -181,7 +181,7 @@ function showSubagent(
callID: string
label: string
description: string
status: "running" | "completed" | "cancelled" | "error"
status: "running" | "completed" | "error"
title?: string
toolCalls?: number
commits: StreamCommit[]
@@ -79,13 +79,6 @@ function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
}
export function entryFlags(commit: StreamCommit): EntryFlags {
if (commit.summary) {
return {
startOnNewLine: true,
trailingNewline: false,
}
}
if (commit.kind === "user") {
return {
startOnNewLine: true,
@@ -163,10 +156,6 @@ export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolea
}
export function entryBody(commit: StreamCommit): RunEntryBody {
if (commit.summary) {
return RUN_ENTRY_NONE
}
const raw = cleanRunText(commit.text)
if (commit.kind === "user") {
@@ -14,8 +14,6 @@ type PanelEntry = RunFooterMenuItem & {
type CommandEntry =
| (PanelEntry & { action: "model" })
| (PanelEntry & { action: "editor" })
| (PanelEntry & { action: "skill" })
| (PanelEntry & { action: "queued" })
| (PanelEntry & { action: "subagent" })
| (PanelEntry & { action: "variant.cycle" })
@@ -35,10 +33,6 @@ type VariantEntry = PanelEntry & {
current: boolean
}
type SkillEntry = PanelEntry & {
name: string
}
type SubagentEntry = PanelEntry & {
sessionID: string
current: boolean
@@ -113,10 +107,6 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) {
return "done"
}
if (status === "cancelled") {
return "cancelled"
}
if (status === "error") {
return "error"
}
@@ -213,129 +203,94 @@ function PanelShell(props: {
inputRef: (input: InputRenderable) => void
onQuery: (query: string) => void
children: JSX.Element
dark?: boolean
chrome?: "default" | "minimal"
}) {
const background = () => (props.dark ? props.theme().shade : props.theme().surface)
const minimal = () => props.chrome === "minimal"
const content = (
<>
<box height={1} flexShrink={0} backgroundColor={background()} />
return (
<box id={props.id} width="100%" flexDirection="column" backgroundColor="transparent" flexShrink={0}>
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexDirection="row"
gap={1}
flexDirection="column"
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BORDER}
flexShrink={0}
backgroundColor={background()}
>
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
{props.title}
</text>
{props.countVisible !== false ? (
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
{countLabel(props.count, props.total, props.query)}
</text>
) : null}
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexShrink={0}
backgroundColor={background()}
>
<input
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
<box
width="100%"
focusedBackgroundColor={background()}
focusedTextColor={props.theme().text}
placeholder={props.placeholder}
placeholderColor={props.theme().muted}
cursorColor={props.theme().highlight}
onInput={props.onQuery}
ref={(input) => {
props.inputRef(input)
input.traits = { status: "FILTER" }
queueMicrotask(() => {
if (!input.isDestroyed) {
input.focus()
}
})
}}
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexDirection="row"
gap={1}
flexShrink={0}
backgroundColor={props.theme().surface}
>
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
{props.title}
</text>
{props.countVisible !== false ? (
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
{countLabel(props.count, props.total, props.query)}
</text>
) : null}
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexShrink={0}
backgroundColor={props.theme().surface}
>
<input
width="100%"
focusedBackgroundColor={props.theme().surface}
focusedTextColor={props.theme().text}
placeholder={props.placeholder}
placeholderColor={props.theme().muted}
cursorColor={props.theme().highlight}
onInput={props.onQuery}
ref={(input) => {
props.inputRef(input)
input.traits = { status: "FILTER" }
queueMicrotask(() => {
if (!input.isDestroyed) {
input.focus()
}
})
}}
/>
</box>
<box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={props.theme().surface}>
{props.children}
</box>
</box>
<box
id={`${props.id}-bottom`}
width="100%"
height={1}
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BOTTOM_BORDER}
flexShrink={0}
>
<box
width="100%"
height={1}
border={["bottom"]}
borderColor={props.theme().surface}
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
/>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
{props.children}
</box>
</>
)
return (
<box id={props.id} width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{minimal() ? (
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{content}
</box>
) : (
<box
width="100%"
flexDirection="column"
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BORDER}
flexShrink={0}
>
{content}
</box>
)}
{minimal() ? (
<box
id={`${props.id}-bottom`}
width="100%"
height={1}
border={false}
backgroundColor="transparent"
flexShrink={0}
>
<box
width="100%"
height={1}
border={["bottom"]}
borderColor={background()}
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
/>
</box>
) : (
<box
id={`${props.id}-bottom`}
width="100%"
height={1}
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BOTTOM_BORDER}
flexShrink={0}
>
<box
width="100%"
height={1}
border={["bottom"]}
borderColor={background()}
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
/>
</box>
)}
</box>
)
}
@@ -349,8 +304,6 @@ export function RunCommandMenuBody(props: {
variantCycle: string
onClose: () => void
onModel: () => void
onEditor: () => void
onSkill: () => void
onSubagent: () => void
onQueued: () => void
onVariant: () => void
@@ -361,26 +314,35 @@ export function RunCommandMenuBody(props: {
}) {
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
const entries = createMemo<CommandEntry[]>(() => {
const builtins = ["editor", "new"]
const session: CommandEntry[] = [
const builtins = ["new"]
return [
{
action: "editor",
category: "Session",
display: "Open editor",
footer: "/editor",
keywords: "editor compose draft external editor",
action: "model",
category: "Suggested",
display: "Switch model",
},
...(props.queued().length > 0
? [
{
action: "queued" as const,
category: "Suggested",
display: "Manage queued prompts",
footer: `${props.queued().length} queued`,
keywords: props
.queued()
.map((item) => item.prompt.text)
.join(" "),
},
]
: []),
...(props.subagents().length > 0
? [
{
action: "subagent" as const,
category: "Session",
category: "Suggested",
display: "View subagents",
footer:
activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`,
footer: `${props.subagents().length} active`,
keywords: props
.subagents()
.map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
@@ -388,6 +350,23 @@ export function RunCommandMenuBody(props: {
},
]
: []),
{
action: "variant.cycle",
category: "Suggested",
display: "Variant cycle",
footer: props.variantCycle,
keywords: "variant cycle",
},
...(props.variants().length > 0
? [
{
action: "variant.list" as const,
category: "Suggested",
display: "Switch model variant",
keywords: `variant variants ${props.variants().join(" ")}`,
},
]
: []),
{
action: "slash",
category: "Session",
@@ -396,82 +375,23 @@ export function RunCommandMenuBody(props: {
footer: "/new",
keywords: "new session clear",
},
]
const prompt: CommandEntry[] =
props.commands() === undefined || skills().length > 0
? [
{
action: "skill" as const,
category: "Prompt",
display: "Skills",
footer: "/skills",
keywords: `skill skills ${skills()
.map((item) => `${item.name} ${item.description ?? ""}`)
.join(" ")}`.trim(),
},
]
: []
const agent: CommandEntry[] = [
{
action: "model",
category: "Agent",
display: "Switch model",
},
...(props.queued().length > 0
? [
{
action: "queued" as const,
category: "Agent",
display: "Manage queued prompts",
footer: `${props.queued().length} queued`,
keywords: props
.queued()
.map((item) => item.prompt.text)
.join(" "),
},
]
: []),
{
action: "variant.cycle",
category: "Agent",
display: "Variant cycle",
footer: props.variantCycle,
keywords: "variant cycle",
},
...(props.variants().length > 0
? [
{
action: "variant.list" as const,
category: "Agent",
display: "Switch model variant",
keywords: `variant variants ${props.variants().join(" ")}`,
},
]
: []),
]
const commands = (props.commands() ?? [])
.filter((item) => item.source !== "skill" && !builtins.includes(item.name))
.map(
(item) =>
({
action: "slash",
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
name: item.name,
display: item.name,
footer: `/${item.name}`,
keywords:
item.source === "mcp"
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
: `/${item.name} ${item.name} ${item.description ?? ""}`,
}) satisfies CommandEntry,
)
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display))
return [
...session,
...prompt,
...agent,
...commands,
...(props.commands() ?? [])
.filter((item) => item.source !== "skill" && !builtins.includes(item.name))
.map(
(item) =>
({
action: "slash",
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
name: item.name,
display: item.name,
footer: `/${item.name}`,
keywords:
item.source === "mcp"
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
: `/${item.name} ${item.name} ${item.description ?? ""}`,
}) satisfies CommandEntry,
)
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)),
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
]
})
@@ -483,16 +403,6 @@ export function RunCommandMenuBody(props: {
return
}
if (item.action === "editor") {
props.onEditor()
return
}
if (item.action === "skill") {
props.onSkill()
return
}
if (item.action === "subagent") {
props.onSubagent()
return
@@ -561,8 +471,6 @@ export function RunCommandMenuBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-command-list"
@@ -577,8 +485,6 @@ export function RunCommandMenuBody(props: {
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!query().trim()}
background
headerColor={props.theme().muted}
/>
</PanelShell>
)
@@ -660,8 +566,6 @@ export function RunSubagentSelectBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-subagent-list"
@@ -671,12 +575,11 @@ export function RunSubagentSelectBody(props: {
offset={menu.offset}
rows={menu.rows}
limit={SUBAGENT_LIST_ROWS}
empty="No subagents found"
empty="No active subagents"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
/>
</PanelShell>
)
@@ -759,8 +662,6 @@ export function RunQueuedPromptSelectBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-queued-list"
@@ -775,86 +676,6 @@ export function RunQueuedPromptSelectBody(props: {
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
/>
</PanelShell>
)
}
export function RunSkillSelectBody(props: {
theme: Accessor<RunFooterTheme>
commands: Accessor<RunCommand[] | undefined>
onClose: () => void
onSelect: (name: string) => void
}) {
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
const entries = createMemo<SkillEntry[]>(() =>
(props.commands() ?? [])
.filter((item) => item.source === "skill")
.map((item) => ({
category: "",
display: item.name,
description: item.description?.replace(/\s+/g, " ").trim() || undefined,
keywords: `skill ${item.name} ${item.description ?? ""}`,
name: item.name,
}))
.sort((a, b) => a.display.localeCompare(b.display)),
)
const items = createMemo<SkillEntry[]>(() => match(query(), entries()))
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
const select = () => {
const item = items()[menu.selected()]
if (!item) {
return
}
props.onSelect(item.name)
}
createEffect(() => {
query()
menu.reset()
})
useKeyboard((event) => {
if (event.defaultPrevented) {
return
}
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
})
return (
<PanelShell
id="run-direct-footer-skill-panel"
title="Skills"
query={query()}
count={items().length}
total={entries().length}
placeholder="Search"
theme={props.theme}
inputRef={(input) => {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-skill-list"
theme={props.theme}
items={items}
selected={menu.selected}
offset={menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
empty={props.commands() ? "No skills found" : "Skills loading"}
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
/>
</PanelShell>
)
@@ -938,8 +759,6 @@ export function RunVariantSelectBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-variant-list"
@@ -954,7 +773,6 @@ export function RunVariantSelectBody(props: {
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
/>
</PanelShell>
)
@@ -1061,8 +879,6 @@ export function RunModelSelectBody(props: {
field = input
}}
onQuery={setQuery}
dark
chrome="minimal"
>
<RunFooterMenu
id="run-direct-footer-model-list"
@@ -1077,8 +893,6 @@ export function RunModelSelectBody(props: {
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!query().trim()}
background
headerColor={props.theme().muted}
/>
</PanelShell>
)
@@ -1,9 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type ColorInput } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { transparent, type RunFooterTheme } from "./theme"
import * as Locale from "@/util/locale"
export const FOOTER_MENU_ROWS = 8
@@ -127,10 +125,7 @@ export function RunFooterMenu(props: {
paddingLeft?: number
paddingRight?: number
grouped?: boolean
background?: boolean
headerColor?: ColorInput
}) {
const term = useTerminalDimensions()
const limit = () => props.limit ?? FOOTER_MENU_ROWS
const border = () => props.border ?? true
const [groupOffset, setGroupOffset] = createSignal(0)
@@ -208,36 +203,16 @@ export function RunFooterMenu(props: {
return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display)))
}
const descriptionText = (item: RunFooterMenuItem) => {
if (!item.description) {
return
}
const footerWidth = item.footer ? Bun.stringWidth(item.footer) + 1 : 0
const available =
term().width -
(border() ? 1 : 0) -
(props.paddingLeft ?? 1) -
(props.paddingRight ?? 0) -
descriptionColumn() -
footerWidth -
4
return Locale.truncate(item.description, Math.max(12, available))
}
return (
<box
id={props.id ?? "run-direct-footer-menu"}
width="100%"
height={props.rows()}
backgroundColor={props.background ? props.theme().shade : transparent}
backgroundColor={transparent}
flexDirection="column"
>
{rows().length === 0 ? (
<box
paddingRight={0}
flexDirection="row"
backgroundColor={props.background ? props.theme().shade : transparent}
>
<box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
{border() ? (
<text fg={props.theme().border} wrapMode="none">
@@ -248,7 +223,7 @@ export function RunFooterMenu(props: {
flexShrink={1}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
backgroundColor={props.background ? props.theme().shade : transparent}
backgroundColor={props.theme().surface}
>
<text fg={props.theme().muted} wrapMode="none" truncate>
{props.empty ?? "No matching items"}
@@ -264,12 +239,7 @@ export function RunFooterMenu(props: {
if (row.type === "header") {
return (
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
<text
fg={props.headerColor ?? props.theme().highlight}
attributes={TextAttributes.BOLD}
wrapMode="none"
truncate
>
<text fg={props.theme().highlight} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{row.label}
</text>
</box>
@@ -277,71 +247,54 @@ export function RunFooterMenu(props: {
}
const active = () => row.index === props.selected()
const background = () =>
active()
? props.background
? props.theme().selected
: props.theme().shade
: props.background
? props.theme().shade
: transparent
const inset = () => (active() ? 1 : 0)
return (
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
<box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
{border() ? (
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
{active() ? "▌" : " "}
<text fg={active() ? props.theme().highlight : props.theme().border} wrapMode="none">
</text>
) : undefined}
<box
flexGrow={1}
flexShrink={1}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
backgroundColor={background()}
paddingLeft={inset()}
paddingRight={inset()}
backgroundColor={props.theme().surface}
>
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
<box
flexGrow={1}
flexShrink={1}
paddingLeft={Math.max(0, (props.paddingLeft ?? 1) - inset())}
paddingRight={Math.max(0, (props.paddingRight ?? 0) - inset())}
backgroundColor={active() ? props.theme().highlight : props.theme().surface}
>
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
<text
fg={active() ? props.theme().selectedText : props.theme().text}
attributes={active() ? TextAttributes.BOLD : undefined}
fg={active() ? props.theme().surface : props.theme().text}
wrapMode="none"
truncate
flexShrink={0}
flexGrow={1}
>
{row.item.display}
</text>
{row.item.description ? (
<>
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
wrapMode="none"
flexShrink={0}
>
{row.item.description ? (
<span style={{ fg: active() ? props.theme().surface : props.theme().muted }}>
{descriptionPad(row.item)}
</text>
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
{descriptionText(row.item)}
</text>
</>
{row.item.description}
</span>
) : undefined}
</text>
{row.item.footer ? (
<text
fg={active() ? props.theme().surface : props.theme().muted}
wrapMode="none"
truncate
flexShrink={0}
>
{row.item.footer}
</text>
) : undefined}
</box>
{row.item.footer ? (
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
flexShrink={0}
>
{row.item.footer}
</text>
) : undefined}
</box>
</box>
</box>
@@ -29,7 +29,6 @@ import {
permissionShift,
type PermissionOption,
} from "./permission.shared"
import { footerWidthPolicy } from "./footer.width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { PermissionReply, RunDiffStyle } from "./types"
@@ -141,7 +140,7 @@ export function RunPermissionBody(props: {
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
const info = createMemo(() => permissionInfo(props.request))
const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const narrow = createMemo(() => dims().width < 80)
const opts = createMemo(() => permissionOptions(state().stage))
const busy = createMemo(() => state().submitting)
const title = createMemo(() => {
@@ -258,13 +257,7 @@ export function RunPermissionBody(props: {
})
return (
<box
id="run-direct-footer-permission-body"
width="100%"
height="100%"
flexDirection="column"
backgroundColor={props.theme.surface}
>
<box id="run-direct-footer-permission-body" width="100%" height="100%" flexDirection="column">
<box
id="run-direct-footer-permission-head"
flexDirection="column"
@@ -1,14 +1,13 @@
// Prompt composer and its state machine for direct interactive mode.
// Prompt textarea component and its state machine for direct interactive mode.
//
// createPromptState() wires keymap command layers, history navigation, and
// `@` autocomplete for files, subagents, and MCP resources.
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
// while the footer view renders the current menu state below it.
/** @jsxImportSource @opentui/solid */
import { pathToFileURL } from "bun"
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { StyledText, bg, fg, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { normalizePromptContent } from "@opencode-ai/tui/editor"
import fuzzysort from "fuzzysort"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -24,7 +23,6 @@ import {
pushPromptHistory,
} from "./prompt.shared"
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
@@ -36,6 +34,13 @@ export const TEXTAREA_MIN_ROWS = 1
export const TEXTAREA_MAX_ROWS = 6
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
export const HINT_BREAKPOINTS = {
send: 50,
newline: 66,
history: 80,
command: 95,
}
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
type Auto = RunFooterMenuItem & {
@@ -48,7 +53,6 @@ type Auto = RunFooterMenuItem & {
type SlashOption = RunFooterMenuItem & {
kind: "slash"
name: string
action?: "skill-menu" | "editor"
}
type PromptOption = Auto | SlashOption
@@ -71,11 +75,9 @@ type PromptInput = {
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void
onExitRequest?: () => boolean
onExit: () => void
onSkillMenu: () => void
onRows: (rows: number) => void
onStatus: (text: string) => void
}
@@ -91,7 +93,6 @@ export type PromptState = {
requestExit: () => boolean
onSubmit: () => void
submitText: (text: string) => void
openEditor: (input?: { value?: string }) => Promise<void>
onKeyDown: (event: KeyEvent) => void
onContentChange: () => void
replaceDraft: (text: string) => void
@@ -108,7 +109,6 @@ function clonePrompt(prompt: RunPrompt): RunPrompt {
text: prompt.text,
parts: structuredClone(prompt.parts),
...(prompt.mode ? { mode: prompt.mode } : {}),
...(prompt.command ? { command: prompt.command } : {}),
}
}
@@ -182,25 +182,17 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
}
function selectedCommand(text: string, command: RunPrompt["command"]) {
if (!command) {
return
}
const head = slashHead(text)
if (!head || head.name !== command.name) {
return
}
export function hintFlags(width: number) {
return {
name: command.name,
arguments: head.arguments,
send: width >= HINT_BREAKPOINTS.send,
newline: width >= HINT_BREAKPOINTS.newline,
history: width >= HINT_BREAKPOINTS.history,
command: width >= HINT_BREAKPOINTS.command,
}
}
export function RunPromptBody(props: {
theme: () => RunFooterTheme
background: () => ColorInput
placeholder: () => StyledText | string
onSubmit: () => void
onKeyDown: (event: KeyEvent) => void
@@ -251,7 +243,7 @@ export function RunPromptBody(props: {
return (
<box id="run-direct-footer-prompt" width="100%">
<box id="run-direct-footer-input-shell" paddingTop={1} paddingBottom={1} paddingRight={2}>
<box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
<textarea
id="run-direct-footer-composer"
width="100%"
@@ -262,8 +254,8 @@ export function RunPromptBody(props: {
placeholderColor={props.theme().muted}
textColor={props.theme().text}
focusedTextColor={props.theme().text}
backgroundColor={props.background()}
focusedBackgroundColor={props.background()}
backgroundColor={props.theme().surface}
focusedBackgroundColor={props.theme().surface}
cursorColor={props.theme().text}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
@@ -284,14 +276,16 @@ export function createPromptState(input: PromptInput): PromptState {
const [shell, setShell] = createSignal(false)
const placeholder = createMemo(() => {
if (shell()) {
return new StyledText([fg(input.theme().muted)('Run a command... "git status"')])
return new StyledText([bg(input.theme().surface)(fg(input.theme().muted)('Run a command... "git status"'))])
}
if (!input.state().first) {
return ""
}
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
return new StyledText([
bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
])
})
let history = createPromptHistory(input.history)
@@ -418,40 +412,13 @@ export function createPromptState(input: PromptInput): PromptState {
{ initialValue: [] as Auto[] },
)
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
const hasSkillsCommand = createMemo(() =>
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
)
const slashOptions = createMemo<SlashOption[]>(() => {
const builtins = [
{
kind: "slash",
action: "editor" as const,
name: "editor",
display: "/editor",
description: "compose in your external editor",
} satisfies SlashOption,
{ kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close direct mode" } satisfies SlashOption,
]
const hidden = new Set(builtins.map((item) => item.name))
const showSkillMenu = !shell() && skillCommands().length > 0 && !hasSkillsCommand()
if (showSkillMenu) {
hidden.add("skills")
}
return [
...(showSkillMenu
? [
{
kind: "slash",
action: "skill-menu" as const,
name: "skills",
display: "/skills",
description: "browse available skills",
} satisfies SlashOption,
]
: []),
...(input.commands() ?? [])
.filter((item) => item.source !== "skill" && !hidden.has(item.name))
.map(
@@ -494,7 +461,7 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
input.onRows(clamp(Math.max(area.lineCount, area.virtualLineCount)) + popup())
input.onRows(clamp(area.virtualLineCount || 1) + popup())
}
const scheduleRows = () => {
@@ -728,7 +695,6 @@ export function createPromptState(input: PromptInput): PromptState {
}
syncParts()
const command = shell() ? undefined : selectedCommand(area.plainText, draft.command)
draft = shell()
? {
text: area.plainText,
@@ -738,7 +704,6 @@ export function createPromptState(input: PromptInput): PromptState {
: {
text: area.plainText,
parts: structuredClone(parts),
...(command ? { command } : {}),
}
}
@@ -818,33 +783,6 @@ export function createPromptState(input: PromptInput): PromptState {
area.focus()
}
const openEditor = async (inputValue?: { value?: string }) => {
input.onInputClear()
syncDraft()
hide()
const current = clonePrompt(draft)
try {
const content = await input.onEditorOpen({
value: inputValue?.value ?? current.text,
})
if (content === undefined) {
return
}
const normalized = normalizePromptContent(content)
restore({
text: normalized,
parts: realignEditorPromptParts(normalized, current.parts),
...(current.mode ? { mode: current.mode } : {}),
...(current.command ? { command: current.command } : {}),
})
} catch {
restore(current)
input.onStatus("failed to open editor")
}
}
const select = (item?: PromptOption) => {
const next = item ?? options()[menu.selected()]
if (!next || !area || area.isDestroyed) {
@@ -852,19 +790,6 @@ export function createPromptState(input: PromptInput): PromptState {
}
if (next.kind === "slash") {
if (next.action === "editor") {
void openEditor({
value: resolveEditorSlashValue(area.plainText),
})
return
}
if (next.action === "skill-menu") {
cancelAutocomplete()
input.onSkillMenu()
return
}
const cursor = area.cursorOffset
const head = slashHead(area.plainText)
const local = !shell() && (next.name === "new" || next.name === "exit")
@@ -973,7 +898,6 @@ export function createPromptState(input: PromptInput): PromptState {
const baseBindingsEnabled = () => {
const current = input.view()
if (current === "command") return false
if (current === "skill") return false
if (current === "model") return false
if (current === "variant") return false
if (current === "queued-menu") return false
@@ -1015,22 +939,6 @@ export function createPromptState(input: PromptInput): PromptState {
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [
{
name: "prompt.editor",
title: "Open editor",
category: "Prompt",
run() {
void openEditor()
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
@@ -1160,11 +1068,7 @@ export function createPromptState(input: PromptInput): PromptState {
]),
}))
const onKeyDown = (event: KeyEvent) => {
if (input.state().phase === "idle" && event.name.toLowerCase() === "escape") {
input.onInputClear()
}
}
const onKeyDown = (_event: KeyEvent) => {}
const submitPrompt = (next: RunPrompt) => {
if (!area || area.isDestroyed) {
@@ -1185,26 +1089,19 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
if (next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
}
const parsed =
command || next.mode === "shell" || isNewCommand(next.text)
? undefined
: parseSlashCommand(next.text, input.commands())
next.mode === "shell" || isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
if (parsed?.type === "pending") {
input.onStatus("loading commands")
return
}
const submit = command
? { ...next, command }
: parsed?.type === "command"
? { ...next, command: parsed.command }
: next
const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next
const shellMode = next.mode === "shell"
resetDraft()
@@ -1297,10 +1194,8 @@ export function createPromptState(input: PromptInput): PromptState {
requestExit,
onSubmit,
submitText,
openEditor,
onKeyDown,
onContentChange: () => {
input.onInputClear()
syncDraft()
refresh()
scheduleRows()
@@ -40,7 +40,6 @@ import {
questionTabs,
questionTotal,
} from "./question.shared"
import { footerWidthPolicy } from "./footer.width"
import type { RunFooterTheme } from "./theme"
import type { QuestionReject, QuestionReply } from "./types"
@@ -59,7 +58,7 @@ export function RunQuestionBody(props: {
const other = createMemo(() => questionOther(props.request, state()))
const picked = createMemo(() => questionPicked(state()))
const disabled = createMemo(() => state().submitting)
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const narrow = createMemo(() => dims().width < 80)
const verb = createMemo(() => {
if (confirm()) {
return "submit"
@@ -15,10 +15,6 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
return theme.highlight
}
if (status === "cancelled") {
return theme.muted
}
if (status === "error") {
return theme.error
}
@@ -31,10 +27,6 @@ function statusIcon(status: FooterSubagentTab["status"]) {
return "●"
}
if (status === "cancelled") {
return "○"
}
if (status === "error") {
return "◍"
}
+43 -104
View File
@@ -29,7 +29,7 @@ import type { Keymap } from "@opentui/keymap"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
import { OpencodeKeymapProvider, formatKeyBindings } from "@opencode-ai/tui/keymap"
import { withRunSpan } from "./otel"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
@@ -37,7 +37,6 @@ import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { RunFooterView } from "./footer.view"
import { RunScrollbackStream } from "./scrollback.surface"
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
import { modelInfo } from "./variant.shared"
import type {
FooterApi,
FooterEvent,
@@ -95,7 +94,6 @@ type RunFooterOptions = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onExit?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
treeSitterClient?: TreeSitterClient
@@ -104,11 +102,10 @@ type RunFooterOptions = {
const PERMISSION_ROWS = 12
const QUESTION_ROWS = 14
const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
const SKILL_ROWS = RUN_COMMAND_PANEL_ROWS
const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
const NOTICE_DURATION = 3000
const AUTOCOMPLETE_COMPACT_ROWS = 2
const THEME_REFRESH_DELAYS = [1000, 1000] as const
function createEmptySubagentState(): FooterSubagentState {
@@ -138,8 +135,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
phase: "running",
status: "sending prompt",
queue: next.queue,
interrupt: 0,
exit: 0,
}
}
@@ -158,6 +153,10 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
}
}
if (next.type === "turn.duration") {
return { duration: next.duration }
}
if (next.type === "stream.patch") {
return next.patch
}
@@ -208,9 +207,6 @@ export class RunFooter implements FooterApi {
private autocomplete = false
private interruptTimeout: NodeJS.Timeout | undefined
private exitTimeout: NodeJS.Timeout | undefined
private noticeTimeout: NodeJS.Timeout | undefined
private noticeRestoreStatus = ""
private statusVersion = 0
private requestExitHandler: (() => boolean) | undefined
private scrollback: RunScrollbackStream
private themes: RunTheme[]
@@ -331,7 +327,6 @@ export class RunFooter implements FooterApi {
onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
onEditorOpen: options.onEditorOpen,
onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit,
onRequestExit: footer.setRequestExitHandler,
@@ -389,23 +384,6 @@ export class RunFooter implements FooterApi {
}
public event(next: FooterEvent): void {
if (next.type === "turn.duration") {
const current = this.currentModel()
this.flush()
this.flushing = this.flushing
.then(() =>
this.scrollback.writeTurnSummary({
agent: this.options.agentLabel,
model: current ? modelInfo(this.providers(), current).model : this.state().model,
duration: next.duration,
}),
)
.catch((error) => {
this.flushError = error
})
return
}
if (next.type === "catalog") {
if (this.isGone) {
return
@@ -449,13 +427,6 @@ export class RunFooter implements FooterApi {
const patch = eventPatch(next)
if (patch) {
if (typeof patch.status === "string") {
this.clearNoticeTimer()
}
if (next.type === "turn.send") {
this.clearInterruptTimer()
this.clearExitTimer()
}
this.patch(patch)
return
}
@@ -481,9 +452,6 @@ export class RunFooter implements FooterApi {
}
const prev = this.state()
if (typeof next.status === "string") {
this.statusVersion++
}
const state = {
phase: next.phase ?? prev.phase,
status: typeof next.status === "string" ? next.status : prev.status,
@@ -658,31 +626,7 @@ export class RunFooter implements FooterApi {
}
private setStatus = (status: string): void => {
this.setNotice(status)
}
private setNotice(status: string): void {
const restore = this.noticeTimeout ? this.noticeRestoreStatus : this.state().status
this.clearNoticeTimer(false)
this.patch({ status })
if (!status) {
this.noticeRestoreStatus = ""
return
}
this.noticeRestoreStatus = restore
const version = this.statusVersion
this.noticeTimeout = setTimeout(() => {
this.noticeTimeout = undefined
if (this.isGone || version !== this.statusVersion) {
this.noticeRestoreStatus = ""
return
}
const next = this.noticeRestoreStatus
this.noticeRestoreStatus = ""
this.patch({ status: next })
}, NOTICE_DURATION)
}
private setRequestExitHandler = (fn?: () => boolean): void => {
@@ -708,6 +652,8 @@ export class RunFooter implements FooterApi {
// get fixed extra rows; the prompt view scales with textarea line count.
private applyHeight(): void {
const type = this.view().type
const compact = this.promptRoute.type === "composer" && this.autocomplete ? AUTOCOMPLETE_COMPACT_ROWS : 0
const base = this.base - compact
const height =
type === "permission"
? this.base + PERMISSION_ROWS
@@ -715,19 +661,17 @@ export class RunFooter implements FooterApi {
? this.base + QUESTION_ROWS
: this.promptRoute.type === "command"
? 1 + COMMAND_ROWS
: this.promptRoute.type === "skill"
? 1 + SKILL_ROWS
: this.promptRoute.type === "model"
? 1 + MODEL_ROWS
: this.promptRoute.type === "variant"
? 1 + VARIANT_ROWS
: this.promptRoute.type === "queued-menu"
: this.promptRoute.type === "model"
? 1 + MODEL_ROWS
: this.promptRoute.type === "variant"
? 1 + VARIANT_ROWS
: this.promptRoute.type === "queued-menu"
? 1 + this.subagentMenuRows
: this.promptRoute.type === "subagent-menu"
? 1 + this.subagentMenuRows
: this.promptRoute.type === "subagent-menu"
? 1 + this.subagentMenuRows
: this.promptRoute.type === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
: this.promptRoute.type === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
@@ -769,7 +713,7 @@ export class RunFooter implements FooterApi {
}
if (this.prompts.size === 0) {
this.setNotice("input queue unavailable")
this.patch({ status: "input queue unavailable" })
return false
}
@@ -807,11 +751,13 @@ export class RunFooter implements FooterApi {
private handleCycle = (): void => {
const result = this.options.onCycleVariant?.()
if (!result) {
this.setNotice("no variants available")
this.patch({ status: "no variants available" })
return
}
const patch: FooterPatch = {}
const patch: FooterPatch = {
status: result.status ?? "variant updated",
}
if ("variants" in result) {
this.setVariants(result.variants ?? [])
@@ -826,7 +772,6 @@ export class RunFooter implements FooterApi {
}
this.patch(patch)
this.setNotice(result.status ?? "variant updated")
}
private handleModelSelect = (model: NonNullable<RunInput["model"]>): void => {
@@ -866,11 +811,12 @@ export class RunFooter implements FooterApi {
patch.model = result.modelLabel
}
if (patch.model) {
this.patch(patch)
}
if (result.status) {
this.setNotice(result.status)
patch.status = result.status
}
if (patch.model || patch.status) {
this.patch(patch)
}
})
.catch(() => {})
@@ -907,11 +853,12 @@ export class RunFooter implements FooterApi {
patch.model = result.modelLabel
}
if (patch.model) {
this.patch(patch)
}
if (result.status) {
this.setNotice(result.status)
patch.status = result.status
}
if (patch.model || patch.status) {
this.patch(patch)
}
})
.catch(() => {})
@@ -926,21 +873,6 @@ export class RunFooter implements FooterApi {
this.interruptTimeout = undefined
}
private clearNoticeTimer(reset = true): void {
if (!this.noticeTimeout) {
if (reset) {
this.noticeRestoreStatus = ""
}
return
}
clearTimeout(this.noticeTimeout)
this.noticeTimeout = undefined
if (reset) {
this.noticeRestoreStatus = ""
}
}
private armInterruptTimer(): void {
this.clearInterruptTimer()
this.interruptTimeout = setTimeout(() => {
@@ -953,6 +885,13 @@ export class RunFooter implements FooterApi {
}, 5000)
}
private interruptHint(): string {
const bindings = this.options.keymap
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
.get("session.interrupt")
return formatKeyBindings(bindings, this.options.tuiConfig) || "esc"
}
private clearExitTimer(): void {
if (!this.exitTimeout) {
return
@@ -987,12 +926,12 @@ export class RunFooter implements FooterApi {
if (next < 2) {
this.armInterruptTimer()
this.patch({ status: `${this.interruptHint()} again to interrupt` })
return true
}
this.clearInterruptTimer()
this.patch({ interrupt: 0 })
this.setNotice("interrupting")
this.patch({ interrupt: 0, status: "interrupting" })
this.options.onInterrupt?.()
return true
}
@@ -1008,6 +947,7 @@ export class RunFooter implements FooterApi {
if (next < 2) {
this.armExitTimer()
this.patch({ status: "Press Ctrl-c again to exit" })
return true
}
@@ -1104,7 +1044,6 @@ export class RunFooter implements FooterApi {
this.notifyClose()
this.clearInterruptTimer()
this.clearExitTimer()
this.clearNoticeTimer()
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
File diff suppressed because it is too large Load Diff
@@ -1,27 +0,0 @@
// Shared responsive width policy
const FOOTER_WIDTH_BREAKPOINTS = {
compact: 80,
commandHint: 66,
model: 120,
spacious: 150,
} as const
export function footerWidthPolicy(width: number) {
const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
const model = width >= FOOTER_WIDTH_BREAKPOINTS.model
const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
return {
dialog: {
narrow: !compact,
},
statusline: {
showActivityMeta: compact,
showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
showContextHints: compact,
contextHintLimit: !compact ? 0 : spacious ? undefined : model ? 2 : 1,
showModel: model,
},
}
}
@@ -1,157 +0,0 @@
import type { RunPromptPart } from "./types"
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
export function resolveEditorSlashValue(text: string) {
const head = slashHead(text)
if (!head || head.name.toLowerCase() !== "editor") {
return text
}
return head.arguments
}
export function realignEditorPromptParts(content: string, parts: RunPromptPart[]): RunPromptPart[] {
const matches = new Map<number, Mention | undefined>()
const used: Array<{ start: number; end: number }> = []
for (const [index, part] of parts.entries()) {
if (part.type !== "file" && part.type !== "agent") {
continue
}
const text = promptPartText(part)
if (!text) {
continue
}
const start = findPromptPartIndex(content, text, used, promptPartStart(part))
if (start === -1) {
matches.set(index, undefined)
continue
}
const end = start + text.length
used.push({ start, end })
matches.set(index, updatePromptPart(part, start, end, text))
}
const next: RunPromptPart[] = []
for (const [index, part] of parts.entries()) {
if (part.type !== "file" && part.type !== "agent") {
next.push(part)
continue
}
if (!promptPartText(part)) {
next.push(part)
continue
}
const match = matches.get(index)
if (match) {
next.push(match)
}
}
return next
}
function slashHead(text: string) {
if (!text.startsWith("/")) {
return
}
for (let i = 1; i < text.length; i++) {
switch (text[i]) {
case " ":
case "\t":
case "\n":
return {
name: text.slice(1, i),
arguments: text.slice(i + 1),
}
}
}
return {
name: text.slice(1),
arguments: "",
}
}
function promptPartText(part: Mention) {
if (part.type === "agent") {
return part.source?.value
}
return part.source?.text.value
}
function promptPartStart(part: Mention) {
if (part.type === "agent") {
return part.source?.start ?? Number.POSITIVE_INFINITY
}
return part.source?.text.start ?? Number.POSITIVE_INFINITY
}
function findPromptPartIndex(content: string, text: string, used: Array<{ start: number; end: number }>, hint: number) {
let searchFrom = 0
let best = -1
let distance = Number.POSITIVE_INFINITY
const hinted = Number.isFinite(hint)
while (true) {
const start = content.indexOf(text, searchFrom)
if (start === -1) {
return best
}
const end = start + text.length
searchFrom = start + 1
if (used.some((range) => start < range.end && end > range.start)) {
continue
}
if (!hinted) {
return start
}
const nextDistance = Math.abs(start - hint)
if (nextDistance < distance) {
best = start
distance = nextDistance
}
}
}
function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
if (part.type === "agent") {
return {
...part,
source: {
start,
end,
value: text,
},
}
}
if (!part.source?.text) {
return part
}
return {
...part,
source: {
...part.source,
text: {
...part.source.text,
start,
end,
value: text,
},
},
}
}
@@ -30,17 +30,11 @@ export function promptCopy(prompt: RunPrompt): RunPrompt {
text: prompt.text,
parts: structuredClone(prompt.parts),
...(prompt.mode ? { mode: prompt.mode } : {}),
...(prompt.command ? { command: prompt.command } : {}),
}
}
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
return (
a.mode === b.mode &&
a.text === b.text &&
JSON.stringify(a.parts) === JSON.stringify(b.parts) &&
JSON.stringify(a.command) === JSON.stringify(b.command)
)
return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
}
export function isExitCommand(input: string): boolean {
@@ -8,13 +8,10 @@
//
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global"
import { openEditor } from "@opencode-ai/tui/editor"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { Session as SessionApi } from "@/session/session"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import * as Locale from "@/util/locale"
import { withRunSpan } from "./otel"
import { resolveInteractiveStdin } from "./runtime.stdin"
@@ -33,7 +30,7 @@ import type {
} from "./types"
import { formatModelLabel } from "./variant.shared"
const FOOTER_HEIGHT = 4
const FOOTER_HEIGHT = 7
type SplashState = {
entry: boolean
@@ -138,17 +135,6 @@ function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): Foo
}
}
function directoryLabel(directory: string) {
const resolved = path.resolve(directory)
const display =
resolved === Global.Path.home
? "~"
: resolved.startsWith(`${Global.Path.home}${path.sep}`)
? resolved.replace(Global.Path.home, "~")
: resolved
return display.replaceAll("\\", "/")
}
function queueSplash(
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
state: SplashState,
@@ -219,11 +205,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
title: splash.title,
session_id: input.sessionID,
})
const labels = footerLabels({
agent: input.agent,
model: input.model,
variant: input.variant,
})
const footerTask = import("./footer")
const wrote = queueSplash(
renderer,
@@ -233,15 +214,17 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
...meta,
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.directory),
}),
)
await renderer.idle().catch(() => {})
const { RunFooter } = await footerTask
let closed = false
let sigintRegistered = false
const labels = footerLabels({
agent: input.agent,
model: input.model,
variant: input.variant,
})
const footer = new RunFooter(renderer, {
directory: input.directory,
findFiles: input.findFiles,
@@ -267,54 +250,15 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt,
onBackground: input.onBackground,
onEditorOpen: async ({ value }) => {
if (closed || renderer.isDestroyed) {
return
}
await renderer.idle().catch(() => {})
const ignore = () => {}
detachSigint()
process.on("SIGINT", ignore)
try {
return await openEditor({
value,
cwd: input.directory,
renderer,
stdin: source.stdin,
})
} finally {
process.off("SIGINT", ignore)
attachSigint()
}
},
onSubagentSelect: input.onSubagentSelect,
})
const sigint = () => {
footer.requestExit()
}
process.on("SIGINT", sigint)
const attachSigint = () => {
if (closed || sigintRegistered) {
return
}
process.on("SIGINT", sigint)
sigintRegistered = true
}
const detachSigint = () => {
if (!sigintRegistered) {
return
}
process.off("SIGINT", sigint)
sigintRegistered = false
}
attachSigint()
let closed = false
const close = async (next: {
showExit: boolean
sessionTitle?: string
@@ -333,8 +277,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
"session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined,
},
async () => {
detachSigint()
let wroteExit = false
process.off("SIGINT", sigint)
try {
await footer.idle().catch(() => {})
@@ -343,7 +286,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
if (!renderer.isDestroyed && show) {
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
wroteExit = queueSplash(
queueSplash(
renderer,
state,
"exit",
@@ -363,9 +306,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.destroy()
unregisterKeymap?.()
shutdown(renderer)
if (!wroteExit) {
process.stdout.write("\n")
}
source.cleanup?.()
}
},
@@ -413,7 +353,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
}),
theme: footer.currentTheme().splash,
showSession: splash.showSession,
detail: directoryLabel(input.directory),
}),
)
renderer.requestRender()
@@ -228,18 +228,16 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
state.ctrl = undefined
}
if (sent.mode !== "shell") {
const duration = Locale.duration(Math.max(0, Date.now() - start))
emit(
{
type: "turn.duration",
duration,
},
{
duration,
},
)
}
const duration = Locale.duration(Math.max(0, Date.now() - start))
emit(
{
type: "turn.duration",
duration,
},
{
duration,
},
)
state.active = undefined
}
}
+26 -49
View File
@@ -77,19 +77,9 @@ type RunLocalInput = {
demo?: RunInput["demo"]
}
type StreamTransportModule = Pick<
Awaited<typeof import("./stream.transport")>,
"createSessionTransport" | "formatUnknownError"
>
export type RunRuntimeDeps = {
createRuntimeLifecycle?: typeof createRuntimeLifecycle
streamTransport?: Promise<StreamTransportModule>
}
type StreamState = {
mod: StreamTransportModule
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
mod: Awaited<typeof import("./stream.transport")>
handle: Awaited<ReturnType<Awaited<typeof import("./stream.transport")>["createSessionTransport"]>>
}
type ResolvedSession = {
@@ -179,7 +169,7 @@ async function resolveExitTitle(
//
// 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, deps: RunRuntimeDeps = {}): Promise<void> {
async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
return withRunSpan(
"RunInteractive.session",
{
@@ -247,7 +237,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return state.session
}
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
const shell = await createRuntimeLifecycle({
directory: ctx.directory,
findFiles: (query) =>
ctx.sdk.find
@@ -489,7 +479,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
})
const streamTask = deps.streamTransport ?? import("./stream.transport")
const streamTask = import("./stream.transport")
const ensureStream = () => {
if (state.stream) {
return state.stream
@@ -516,7 +506,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
replay: input.replay,
replayLimit: input.replayLimit,
limits: () => state.limits,
providers: () => state.providers,
footer,
trace: log,
})
@@ -757,12 +746,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
try {
const eager = eagerStream(input, ctx)
if (eager) {
if (input.replay && state.shown) {
// Replay commits immutable scrollback rows, so wait for provider names
// before bootstrapping existing session history.
await modelTask
}
await ensureStream()
}
@@ -863,10 +846,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
}
// Attach mode. Uses the caller-provided SDK client directly.
export async function runInteractiveMode(
input: RunInput & { createSession?: CreateSession },
deps?: RunRuntimeDeps,
): Promise<void> {
export async function runInteractiveMode(input: RunInput & { createSession?: CreateSession }): Promise<void> {
return withRunSpan(
"RunInteractive.attachMode",
{
@@ -875,28 +855,25 @@ export async function runInteractiveMode(
"session.id": input.sessionID,
},
async () =>
runInteractiveRuntime(
{
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
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,
}),
createSession: createSessionResolver(input.createSession),
},
deps,
),
runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
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,
}),
createSession: createSessionResolver(input.createSession),
}),
)
}
@@ -16,8 +16,7 @@ import {
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
import { withRunSpan } from "./otel"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { turnSummaryCommit } from "./turn-summary"
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter } from "./scrollback.writer"
import { type RunTheme } from "./theme"
import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
@@ -358,14 +357,6 @@ export class RunScrollbackStream {
this.markRendered(await this.finishActive(false))
}
if (commit.summary) {
this.writeSpacer(1)
this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme }))
this.markRendered(commit)
this.tail = commit
return
}
const body = entryBody(commit)
if (body.type === "none") {
if (entryDone(commit)) {
@@ -437,10 +428,6 @@ export class RunScrollbackStream {
)
}
public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise<void> {
await this.append(turnSummaryCommit(input))
}
public destroy(): void {
this.resetActive()
this.releasePendingThemes()
@@ -24,7 +24,7 @@ function todoText(item: { status: string; content: string }): string {
}
function todoColor(theme: RunTheme, status: string) {
return status === "in_progress" ? theme.block.warning : theme.block.muted
return status === "in_progress" ? theme.footer.warning : theme.block.muted
}
export function entryGroupKey(commit: StreamCommit): string | undefined {
@@ -333,21 +333,3 @@ export function spacerWriter(): ScrollbackWriter {
trailingNewline: true,
})
}
export function turnSummaryWriter(input: { agent: string; model: string; duration: string; theme: RunTheme }) {
return createScrollbackWriter(
() => (
<box width="100%" height={1}>
<text wrapMode="none" truncate>
<span style={{ fg: input.theme.block.highlight }}> </span>
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
<span style={{ fg: input.theme.block.muted }}>
{" "}
· {input.model} · {input.duration}
</span>
</text>
</box>
),
{ startOnNewLine: true, trailingNewline: false },
)
}
@@ -1,8 +1,7 @@
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { messagePrompt, type SessionMessages } from "./session.shared"
import { messageTurnSummaryCommit } from "./turn-summary"
import type { FooterPatch, LocalReplayRow, RunProvider, StreamCommit } from "./types"
import type { FooterPatch, LocalReplayRow, StreamCommit } from "./types"
type ReplayInput = {
messages: SessionMessages
@@ -10,13 +9,6 @@ type ReplayInput = {
questions: QuestionRequest[]
thinking: boolean
limits: Record<string, number>
providers?: RunProvider[]
}
type ReplayConfig = {
limits: Record<string, number>
providers?: RunProvider[]
summaries: ReadonlySet<string>
}
export type SessionReplay = {
@@ -30,8 +22,6 @@ type ReplayMessage = {
patch?: FooterPatch
}
const SHELL_SYNTHETIC_USER_TEXT = "The following tool was executed by the user"
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
return reduceSessionData({
data,
@@ -99,62 +89,11 @@ function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
} satisfies FooterPatch
}
function isShellSyntheticUser(message: SessionMessages[number]) {
if (message.info.role !== "user") {
return false
}
const prompt = messagePrompt(message)
return (
!prompt.text.trim() &&
prompt.parts.length === 0 &&
message.parts.some((part) => part.type === "text" && part.synthetic && part.text === SHELL_SYNTHETIC_USER_TEXT)
)
}
function isShellSyntheticAssistant(message: SessionMessages[number], shellParents: ReadonlySet<string>) {
return (
message.info.role === "assistant" &&
shellParents.has(message.info.parentID) &&
message.parts.some((part) => part.type === "tool" && part.tool === "bash")
)
}
function summaryMessageIDs(messages: SessionMessages): ReadonlySet<string> {
const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id))
const parents = new Set<string>()
const summaries = new Set<string>()
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
const message = messages[idx]
if (!message || message.info.role !== "assistant") {
continue
}
if (isShellSyntheticAssistant(message, shellParents)) {
continue
}
if (parents.has(message.info.parentID)) {
continue
}
parents.add(message.info.parentID)
const completed = message.info.time.completed
if (typeof completed === "number" && completed > message.info.time.created) {
summaries.add(message.info.id)
}
}
return summaries
}
function replayMessage(
data: SessionData,
message: SessionMessages[number],
thinking: boolean,
config: ReplayConfig,
limits: Record<string, number>,
): ReplayMessage {
if (message.info.role === "user") {
const prompt = messagePrompt(message)
@@ -192,7 +131,7 @@ function replayMessage(
},
message.info.sessionID,
thinking,
config.limits,
limits,
)
commits.push(...info.commits)
patch = mergePatch(patch, info.footer?.patch)
@@ -211,19 +150,12 @@ function replayMessage(
},
message.info.sessionID,
thinking,
config.limits,
limits,
)
patch = mergePatch(patch, next.footer?.patch)
commits.push(...next.commits)
}
const summary = config.summaries.has(message.info.id)
? messageTurnSummaryCommit(message, config.providers)
: undefined
if (summary) {
commits.push(summary)
}
return {
commits,
patch,
@@ -234,7 +166,6 @@ export function replaySession(input: ReplayInput): SessionReplay {
const data = createSessionData()
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
const summaries = summaryMessageIDs(input.messages)
bootstrapSessionData({
data,
@@ -244,11 +175,7 @@ export function replaySession(input: ReplayInput): SessionReplay {
})
for (const message of input.messages) {
const next = replayMessage(data, message, input.thinking, {
limits: input.limits,
providers: input.providers,
summaries,
})
const next = replayMessage(data, message, input.thinking, input.limits)
commits.push(...next.commits)
patch = mergePatch(patch, next.patch)
}
+48 -22
View File
@@ -11,6 +11,7 @@
import {
BoxRenderable,
type ColorInput,
RGBA,
TextAttributes,
TextRenderable,
type ScrollbackRenderContext,
@@ -18,7 +19,7 @@ import {
type ScrollbackWriter,
} from "@opentui/core"
import * as Locale from "@/util/locale"
import { go } from "@/cli/logo"
import { go, logo } from "@/cli/logo"
import type { RunSplashTheme } from "./theme"
export const SPLASH_TITLE_LIMIT = 50
@@ -32,7 +33,6 @@ type SplashInput = {
type SplashWriterInput = SplashInput & {
theme: RunSplashTheme
showSession?: boolean
detail?: string
}
export type SplashMeta = {
@@ -144,6 +144,28 @@ function push(
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 fallback(index: number, hex: string): RGBA {
return RGBA.fromIndex(index, RGBA.fromHex(hex))
}
function draw(
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
row: string,
@@ -178,37 +200,41 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
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 left = input.theme.left
const right = input.theme.right
const leftShadow = input.theme.leftShadow
const left = color(input.theme.left, fallback(81, "#38bdf8"))
const right = color(input.theme.right, RGBA.defaultForeground(RGBA.fromHex("#f8fafc")))
const leftShadow = color(input.theme.leftShadow, fallback(238, "#334155"))
let height = 1
if (kind === "entry") {
const mark = go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
const rightShadow = color(input.theme.rightShadow, fallback(240, "#475569"))
for (let i = 0; i < mark.length; i += 1) {
draw(lines, mark[i] ?? "", {
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: top + i,
top: i,
fg: left,
shadow: leftShadow,
})
draw(lines, rightText, {
left: leftText.length + 1,
top: i,
fg: right,
shadow: rightShadow,
})
}
push(lines, body_left, top, "OpenCode", right, undefined, TextAttributes.BOLD)
if (input.detail) {
push(
lines,
body_left,
top + 1,
Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
left,
undefined,
)
height = logo.left.length
if (input.showSession !== false) {
const top = logo.left.length + 1
const label = "Session".padEnd(10, " ")
push(lines, 0, top, label, left, undefined, TextAttributes.DIM)
push(lines, label.length, top, meta.title, right, undefined, TextAttributes.BOLD)
height = top + 1
}
height = top + mark.length
}
if (kind === "exit") {
@@ -31,6 +31,7 @@ import { replayActiveText, replayLocalRows, replaySession } from "./session-repl
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
clearFinishedSubagents,
createSubagentData,
listSubagentPermissions,
listSubagentQuestions,
@@ -56,7 +57,6 @@ import type {
RunInput,
RunPrompt,
RunPromptPart,
RunProvider,
StreamCommit,
} from "./types"
@@ -74,7 +74,6 @@ type StreamInput = {
replay?: boolean
replayLimit?: number
limits: () => Record<string, number>
providers?: () => RunProvider[]
footer: FooterApi
trace?: Trace
signal?: AbortSignal
@@ -715,7 +714,6 @@ function createLayer(input: StreamInput) {
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
})
: undefined
const replay =
@@ -726,7 +724,6 @@ function createLayer(input: StreamInput) {
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
})
: history
@@ -754,6 +751,7 @@ function createLayer(input: StreamInput) {
permissions,
questions,
})
clearFinishedSubagents(state.subagent)
for (const request of [
...state.data.permissions,
@@ -1028,7 +1026,6 @@ function createLayer(input: StreamInput) {
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
})
const activeCommits = replayActiveText(history.data, state.data)
return {
@@ -1046,7 +1043,6 @@ function createLayer(input: StreamInput) {
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
})
: history,
}
@@ -1199,6 +1195,13 @@ function createLayer(input: StreamInput) {
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,
@@ -292,25 +292,10 @@ function metadata(part: ToolPart, key: string) {
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
}
function taskStatus(part: ToolPart): FooterSubagentTab["status"] {
if (part.state.status === "completed") {
return "completed"
}
if (part.state.status === "error") {
if (metadata(part, "interrupted") === true || text(part.state.error) === "Tool execution aborted") {
return "cancelled"
}
return "error"
}
return "running"
}
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,
@@ -318,7 +303,7 @@ function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
callID: part.callID,
label,
description,
status: taskStatus(part),
status,
background: metadata(part, "background") === true,
title: stateTitle(part),
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
@@ -472,29 +457,6 @@ function ensureBlockerTab(
return true
}
function isAbortedAssistantMessage(info: Message) {
return info.role === "assistant" && info.error?.name === "MessageAbortedError"
}
function cancelSubagentTab(data: SubagentData, sessionID: string) {
const current = data.tabs.get(sessionID)
if (!current || current.status !== "running") {
return false
}
const next = {
...current,
status: "cancelled" as const,
lastUpdatedAt: Date.now(),
}
if (sameSubagentTab(current, next)) {
return false
}
data.tabs.set(sessionID, next)
return true
}
function compactCallMap(detail: DetailState) {
const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
@@ -789,6 +751,22 @@ export function bootstrapSubagentCalls(input: {
return changed || 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
@@ -829,48 +807,38 @@ export function reduceSubagentData(input: {
}
const detail = ensureDetail(input.data, sessionID)
const cancelled =
event.type === "message.updated" && isAbortedAssistantMessage(event.properties.info)
? cancelSubagentTab(input.data, sessionID)
: false
if (event.type === "session.status") {
if (event.properties.status.type !== "retry") {
return cancelled
return false
}
return (
appendCommits(detail, [
{
kind: "error",
text: event.properties.status.message,
phase: "start",
source: "system",
messageID: `retry:${event.properties.status.attempt}`,
},
]) || cancelled
)
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)}`,
},
]) || cancelled
)
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,
}) || cancelled
)
return applyChildEvent({
detail,
event,
thinking: input.thinking,
limits: input.limits,
})
}
+50 -137
View File
@@ -25,15 +25,11 @@ export type RunSplashTheme = {
export type RunFooterTheme = {
highlight: ColorInput
selected: ColorInput
selectedText: ColorInput
warning: ColorInput
success: ColorInput
error: ColorInput
muted: ColorInput
text: ColorInput
status: ColorInput
statusAccent: ColorInput
shade: ColorInput
surface: ColorInput
pane: ColorInput
@@ -42,8 +38,6 @@ export type RunFooterTheme = {
}
export type RunBlockTheme = {
highlight: ColorInput
warning: ColorInput
text: ColorInput
muted: ColorInput
syntax?: SyntaxStyle
@@ -101,11 +95,8 @@ function rgba(hex: string, value?: number): RGBA {
}
function mode(bg: RGBA): "dark" | "light" {
return luminance(bg) > 0.5 ? "light" : "dark"
}
function luminance(color: RGBA): number {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
return lum > 0.5 ? "light" : "dark"
}
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
@@ -215,39 +206,13 @@ function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.p
})
}
function srgbToLinear(value: number): number {
if (value <= 0.04045) {
return value / 12.92
}
return ((value + 0.055) / 1.055) ** 2.4
}
function oklab(color: RGBA) {
const r = srgbToLinear(color.r)
const g = srgbToLinear(color.g)
const b = srgbToLinear(color.b)
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
return {
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
}
}
function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
const target = oklab(rgba)
const hit = indexed.reduce(
(best, item) => {
const sample = oklab(item)
const dl = sample.l - target.l
const da = sample.a - target.a
const db = sample.b - target.b
const dist = dl * dl * 2 + da * da + db * db
const dr = item.r - rgba.r
const dg = item.g - rgba.g
const db = item.b - rgba.b
const dist = dr * dr + dg * dg + db * db
if (dist >= best.dist) return best
return {
dist,
@@ -263,11 +228,6 @@ function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
return RGBA.clone(hit.item)
}
function paletteColor(colors: TerminalColors, index: number): RGBA {
const value = colors.palette[index]
return value ? RGBA.fromHex(value) : ansiToRgba(index)
}
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
const mixed = tint(base, overlay, value)
return nearestIndexed(indexed, mixed)
@@ -386,10 +346,13 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
const fg = RGBA.defaultForeground(fg_snapshot)
const isDark = pick === "dark"
const color = (index: number) => paletteColor(colors, index)
const indexed = indexedPalette(colors)
const color = (index: number) => RGBA.clone(indexed[index]!)
const nearest = (rgba: RGBA) => nearestIndexed(indexed, rgba)
const grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
const textMuted = generateMutedTextColor(bg_snapshot, isDark, (rgba) => rgba)
const grays = generateGrayScale(bg_snapshot, isDark, nearest)
const menu_grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
const textMuted = generateMutedTextColor(bg_snapshot, isDark, nearest)
const ansi = {
red: color(1),
@@ -422,7 +385,7 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
background: alpha(bg, 0),
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
backgroundMenu: menu_grays[3],
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
@@ -432,12 +395,12 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
diffHunkHeader: grays[7],
diffHighlightAdded: ansi.green_bright,
diffHighlightRemoved: ansi.red_bright,
diffAddedBg: tint(bg_snapshot, ansi.green, diff_alpha),
diffRemovedBg: tint(bg_snapshot, ansi.red, diff_alpha),
diffAddedBg: nearest(tint(bg_snapshot, ansi.green, diff_alpha)),
diffRemovedBg: nearest(tint(bg_snapshot, ansi.red, diff_alpha)),
diffContextBg: diff_context_bg,
diffLineNumber: textMuted,
diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
diffAddedLineNumberBg: nearest(tint(diff_context_bg, ansi.green, diff_alpha)),
diffRemovedLineNumberBg: nearest(tint(diff_context_bg, ansi.red, diff_alpha)),
markdownText: fg,
markdownHeading: fg,
markdownLink: ansi.blue,
@@ -465,27 +428,6 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
}
}
function quantizeColor(indexed: RGBA[], rgba: RGBA): RGBA {
if (rgba.a === 0 || rgba.intent === "default" || rgba.intent === "indexed") {
return RGBA.clone(rgba)
}
return nearestIndexed(indexed, rgba)
}
function quantizeTheme(theme: TuiThemeCurrent, indexed: RGBA[]): TuiThemeCurrent {
const resolved = Object.fromEntries(
Object.entries(theme)
.filter(([key]) => key !== "thinkingOpacity")
.map(([key, value]) => [key, quantizeColor(indexed, value as RGBA)]),
) as Partial<Record<ThemeColor, RGBA>>
return {
...(resolved as Record<ThemeColor, RGBA>),
thinkingOpacity: theme.thinkingOpacity,
}
}
function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
const left = nearestIndexed(indexed, theme.textMuted)
const right = nearestIndexed(indexed, theme.text)
@@ -498,85 +440,69 @@ function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
}
function map(
footerTheme: TuiThemeCurrent,
scrollbackTheme: TuiThemeCurrent,
theme: TuiThemeCurrent,
splash: RunSplashTheme,
syntax?: SyntaxStyle,
subtleSyntax?: SyntaxStyle,
): RunTheme {
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, theme.background)
subtleSyntax?.destroy()
const footerBackground = alpha(footerTheme.background, 1)
const footerMode = mode(footerBackground)
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
const surface = fade(footerTheme.backgroundMenu, footerTheme.background, 0.18, 0.76, 0.9)
const line = fade(footerTheme.backgroundMenu, footerTheme.background, 0.24, 0.9, 0.98)
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.12 : 0.06)
const statusAccentBase =
footerMode === "dark" ? tint(footerBackground, rgba("#ffffff"), 0.06) : tint(statusBase, rgba("#000000"), 0.04)
const collapsedStatus = footerMode === "dark" && luminance(statusBase) <= 0.04
// Pure-black backgrounds need a slight lift or the row disappears into the terminal background.
const status = collapsedStatus ? tint(statusBase, statusAccentBase, 0.7) : statusBase
const statusAccent = collapsedStatus ? tint(status, rgba("#ffffff"), 0.06) : statusAccentBase
const shade = fade(theme.backgroundMenu, theme.background, 0.12, 0.56, 0.72)
const surface = fade(theme.backgroundMenu, theme.background, 0.18, 0.76, 0.9)
const line = fade(theme.backgroundMenu, theme.background, 0.24, 0.9, 0.98)
return {
background: footerTheme.background,
background: theme.background,
footer: {
highlight: footerTheme.primary,
selected: footerTheme.backgroundElement,
selectedText: footerTheme.selectedListItemText,
warning: footerTheme.warning,
success: footerTheme.success,
error: footerTheme.error,
muted: footerTheme.textMuted,
text: footerTheme.text,
status,
statusAccent,
highlight: theme.primary,
warning: theme.warning,
success: theme.success,
error: theme.error,
muted: theme.textMuted,
text: theme.text,
shade,
surface,
pane: footerTheme.backgroundMenu,
border: footerTheme.border,
pane: theme.backgroundMenu,
border: theme.border,
line,
},
entry: {
system: {
body: scrollbackTheme.textMuted,
body: theme.textMuted,
},
user: {
body: scrollbackTheme.primary,
body: theme.primary,
},
assistant: {
body: scrollbackTheme.text,
body: theme.text,
},
reasoning: {
body: scrollbackTheme.textMuted,
body: theme.textMuted,
},
tool: {
body: scrollbackTheme.text,
start: scrollbackTheme.textMuted,
body: theme.text,
start: theme.textMuted,
},
error: {
body: scrollbackTheme.error,
body: theme.error,
},
},
splash,
block: {
highlight: scrollbackTheme.primary,
warning: scrollbackTheme.warning,
text: scrollbackTheme.text,
muted: scrollbackTheme.textMuted,
text: theme.text,
muted: theme.textMuted,
syntax,
subtleSyntax: opaqueSubtleSyntax,
diffAdded: scrollbackTheme.diffAdded,
diffRemoved: scrollbackTheme.diffRemoved,
diffAdded: theme.diffAdded,
diffRemoved: theme.diffRemoved,
diffAddedBg: transparent,
diffRemovedBg: transparent,
diffContextBg: transparent,
diffHighlightAdded: scrollbackTheme.diffHighlightAdded,
diffHighlightRemoved: scrollbackTheme.diffHighlightRemoved,
diffLineNumber: scrollbackTheme.diffLineNumber,
diffAddedLineNumberBg: scrollbackTheme.diffAddedLineNumberBg,
diffRemovedLineNumberBg: scrollbackTheme.diffRemovedLineNumberBg,
diffHighlightAdded: theme.diffHighlightAdded,
diffHighlightRemoved: theme.diffHighlightRemoved,
diffLineNumber: theme.diffLineNumber,
diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
},
}
}
@@ -606,15 +532,11 @@ export const RUN_THEME_FALLBACK: RunTheme = {
background: RGBA.fromValues(0, 0, 0, 0),
footer: {
highlight: seed.highlight,
selected: seed.text,
selectedText: seed.panel,
warning: seed.warning,
success: seed.success,
error: seed.error,
muted: seed.muted,
text: seed.text,
status: tint(seed.panel, rgba("#000000"), 0.12),
statusAccent: tint(seed.panel, rgba("#ffffff"), 0.06),
shade: alpha(seed.panel, 0.68),
surface: alpha(seed.panel, 0.86),
pane: seed.panel,
@@ -636,8 +558,6 @@ export const RUN_THEME_FALLBACK: RunTheme = {
rightShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashRight, 0.14),
},
block: {
highlight: seed.highlight,
warning: seed.warning,
text: seed.text,
muted: seed.muted,
diffAdded: seed.success,
@@ -668,22 +588,15 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
const pick = colors.defaultBackground
? mode(RGBA.fromHex(colors.defaultBackground))
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
const theme = resolveTheme(generateSystem(colors, pick), pick)
const indexed = indexedPalette(colors, 256)
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
const shared = await import("@opencode-ai/tui/context/theme")
const syntaxTheme: SharedSyntaxTheme = {
...scrollbackTheme,
...theme,
_hasSelectedListItemText: true,
}
const syntax = shared.generateSyntax(syntaxTheme)
return map(
footerTheme,
scrollbackTheme,
splashTheme(scrollbackTheme, indexed),
syntax,
shared.generateSubtleSyntax(syntaxTheme),
)
return map(theme, splashTheme(theme, indexed), syntax, shared.generateSubtleSyntax(syntaxTheme))
} catch {
return RUN_THEME_FALLBACK
}
@@ -1,47 +0,0 @@
import * as Locale from "@/util/locale"
import type { SessionMessages } from "./session.shared"
import type { RunProvider, StreamCommit } from "./types"
export function turnSummaryCommit(input: {
agent: string
model: string
duration: string
messageID?: string
}): StreamCommit {
return {
kind: "system",
text: `${input.agent} · ${input.model} · ${input.duration}`,
phase: "final",
source: "system",
summary: {
agent: input.agent,
model: input.model,
duration: input.duration,
},
messageID: input.messageID,
}
}
export function messageTurnSummaryCommit(
message: SessionMessages[number],
providers?: RunProvider[],
): StreamCommit | undefined {
const info = message.info
if (info.role !== "assistant") {
return
}
const completed = info.time.completed
if (typeof completed !== "number" || completed <= info.time.created) {
return
}
const model = providers?.find((item) => item.id === info.providerID)?.models[info.modelID]?.name
return turnSummaryCommit({
agent: Locale.titlecase(info.agent),
model: model ?? info.modelID,
duration: Locale.duration(completed - info.time.created),
messageID: info.id,
})
}
+1 -9
View File
@@ -97,12 +97,6 @@ export type FooterPatch = Partial<FooterState>
export type RunDiffStyle = "auto" | "stacked"
export type TurnSummary = {
agent: string
model: string
duration: string
}
export type ScrollbackOptions = {
diffStyle?: RunDiffStyle
suppressBackgrounds?: boolean
@@ -181,7 +175,6 @@ export type FooterPromptRoute =
| { type: "subagent-menu" }
| { type: "subagent"; sessionID: string }
| { type: "command" }
| { type: "skill" }
| { type: "model" }
| { type: "variant" }
@@ -191,7 +184,7 @@ export type FooterSubagentTab = {
callID: string
label: string
description: string
status: "running" | "completed" | "cancelled" | "error"
status: "running" | "completed" | "error"
background?: boolean
title?: string
toolCalls?: number
@@ -305,7 +298,6 @@ export type StreamCommit = {
text: string
phase: StreamPhase
source: StreamSource
summary?: TurnSummary
messageID?: string
partID?: string
tool?: string
+46 -60
View File
@@ -1,7 +1,12 @@
import { cmd } from "@/cli/cmd/cmd"
import { Rpc } from "@/util/rpc"
import { type rpc } from "../tui/worker"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import path from "path"
import { randomBytes } from "node:crypto"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import * as Log from "@opencode-ai/core/util/log"
@@ -9,8 +14,6 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { writeHeapSnapshot } from "v8"
import {
OPENCODE_PROCESS_ROLE,
@@ -18,43 +21,12 @@ import {
ensureRunID,
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
declare global {
const OPENCODE_WORKER_PATH: string
}
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
function createWorkerFetch(client: RpcClient): typeof fetch {
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init)
const body = request.body ? await request.text() : undefined
const result = await client.call("fetch", {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body,
})
return new Response(result.body, {
status: result.status,
headers: result.headers,
})
}
return fn as typeof fetch
}
function createEventSource(client: RpcClient): EventSource {
return {
subscribe: async (handler) => {
return client.on<GlobalEvent>("global.event", (e) => {
handler(e)
})
},
}
}
async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
const dist = new URL("./cli/tui/worker.js", import.meta.url)
@@ -132,6 +104,15 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const password = external ? undefined : randomBytes(32).toString("base64url")
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
@@ -183,34 +164,41 @@ export const TuiThreadCommand = cmd({
const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
events: undefined,
headers: ServerAuth.headers(),
}
: {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
events: createEventSource(client),
url: (
await client.call("server", {
hostname: "127.0.0.1",
port: 0,
preferredPort: 0,
auth: { password },
})
).url,
headers: ServerAuth.headers({ password }),
}
const sdk = createOpencodeClient({
baseUrl: transport.url,
directory: cwd,
headers: transport.headers,
})
try {
await validateSession({
url: transport.url,
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
})
if (args.session) {
const sessionID = (() => {
try {
return Schema.decodeUnknownSync(SessionID)(args.session)
} catch (error) {
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, {
cause: error,
})
}
})()
await sdk.session.get({ sessionID }, { throwOnError: true })
}
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
@@ -223,11 +211,12 @@ export const TuiThreadCommand = cmd({
try {
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { Global } = await import("@opencode-ai/core/global")
const { run } = await import("@opencode-ai/tui")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
url: transport.url,
sdk,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
@@ -236,8 +225,6 @@ export const TuiThreadCommand = cmd({
config,
pluginHost: createLegacyTuiPluginHost(),
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
@@ -246,12 +233,11 @@ export const TuiThreadCommand = cmd({
prompt,
fork: args.fork,
},
}),
}).pipe(Effect.provide(Global.defaultLayer)),
)
} finally {
await stop()
}
process.exit(0)
} finally {
try {
unguard?.()
@@ -259,6 +245,6 @@ export const TuiThreadCommand = cmd({
Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) })
}
}
process.exit(0)
},
})
// scratch
-7
View File
@@ -1,7 +0,0 @@
import { run as runTui, type TuiInput } from "@opencode-ai/tui"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
export function run(input: TuiInput) {
return runTui(input).pipe(Effect.provide(Global.defaultLayer))
}
@@ -1,29 +0,0 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
const decodeSessionID = Schema.decodeUnknownSync(SessionID)
export async function validateSession(input: {
url: string
sessionID?: string
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
}) {
if (!input.sessionID) return
let sessionID: SessionID
try {
sessionID = decodeSessionID(input.sessionID)
} catch (error) {
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error })
}
await createOpencodeClient({
baseUrl: input.url,
directory: input.directory,
fetch: input.fetch,
headers: input.headers,
}).session.get({ sessionID }, { throwOnError: true })
}
+9 -27
View File
@@ -5,8 +5,6 @@ import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { ServerAuth } from "@/server/auth"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
@@ -39,38 +37,22 @@ process.on("uncaughtException", (e) => {
})
})
// Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = ServerAuth.header()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
const request = new Request(input.url, {
method: input.method,
headers,
body: input.body,
})
const response = await Server.Default().app.fetch(request)
const body = await response.text()
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body,
}
},
snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot")
return result
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
async server(input: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
preferredPort?: number
auth?: { username?: string; password?: string }
}) {
if (server) await server.stop(true)
server = await Server.listen(input)
return { url: server.url.toString() }
+5 -19
View File
@@ -204,7 +204,7 @@ function fetchFromClient<T extends { name: string }>(
return Effect.tryPromise({
try: () => listFn(client),
catch: (e: any) => {
log.warn(`failed to get ${label}`, { clientName, error: e.message })
log.error(`failed to get ${label}`, { clientName, error: e.message })
return e
},
}).pipe(
@@ -472,7 +472,7 @@ export const layer = Layer.effect(
return { status } satisfies CreateResult
}
const listed = mcpClient.getServerCapabilities()?.tools ? yield* defs(key, mcpClient, mcp.timeout) : []
const listed = yield* defs(key, mcpClient, mcp.timeout)
if (!listed) {
yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore)
return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult
@@ -508,7 +508,6 @@ export const layer = Layer.effect(
)
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
if (!client.getServerCapabilities()?.tools) return
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
log.info("tools list changed notification received", { server: name })
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
@@ -719,21 +718,12 @@ export const layer = Layer.effect(
const prompts = Effect.fn("MCP.prompts")(function* () {
const s = yield* InstanceState.get(state)
return yield* collectFromConnected(
s,
(c) => (c.getServerCapabilities()?.prompts ? c.listPrompts().then((r) => r.prompts) : Promise.resolve([])),
"prompts",
)
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
})
const resources = Effect.fn("MCP.resources")(function* () {
const s = yield* InstanceState.get(state)
return yield* collectFromConnected(
s,
(c) =>
c.getServerCapabilities()?.resources ? c.listResources().then((r) => r.resources) : Promise.resolve([]),
"resources",
)
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
})
const withClient = Effect.fnUntraced(function* <A>(
@@ -858,11 +848,7 @@ export const layer = Layer.effect(
Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)),
)
const listed = client
? client.getServerCapabilities()?.tools
? yield* defs(mcpName, client, mcpConfig.timeout)
: []
: undefined
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
if (!client || !listed) {
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
return { status: "failed", error: "Failed to get tools" } as Status
+17 -3
View File
@@ -13,6 +13,7 @@ import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
import { PublicApi } from "./routes/instance/httpapi/public"
import type { CorsOptions } from "./cors"
import { lazy } from "@/util/lazy"
import { ServerAuth } from "./auth"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -36,6 +37,8 @@ type ListenOptions = CorsOptions & {
hostname: string
mdns?: boolean
mdnsDomain?: string
preferredPort?: number
auth?: ServerAuth.Credentials
}
type ListenerState = {
scope: Scope.Scope
@@ -109,19 +112,30 @@ function listenerLayer(opts: ListenOptions, port: number) {
Layer.provideMerge(WebSocketTracker.layer),
Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })),
// Install a fresh `ConfigProvider` per listener so `Config.string(...)`
// reads reflect the current `process.env`. Effect's default
// reads reflect the current `process.env` and listener-local credentials. Effect's default
// `ConfigProvider` snapshots `process.env` on first read and caches the
// result on a module-singleton Reference; without overriding it here,
// every later `Server.listen()` keeps observing that initial snapshot.
Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())),
Layer.provide(
ConfigProvider.layer(
opts.auth
? ConfigProvider.fromUnknown({
...process.env,
OPENCODE_SERVER_PASSWORD: opts.auth.password,
OPENCODE_SERVER_USERNAME: opts.auth.username ?? "opencode",
})
: ConfigProvider.fromEnv(),
),
),
)
}
function startWithPortFallback(opts: ListenOptions) {
if (opts.port !== 0) return startListener(opts, opts.port)
if (opts.preferredPort === 0) return startListener(opts, 0)
// Match the legacy listener port-resolution behavior: explicit `0` prefers
// 4096 first, then any free port.
return startListener(opts, 4096).pipe(Effect.catch(() => startListener(opts, 0)))
return startListener(opts, opts.preferredPort ?? 4096).pipe(Effect.catch(() => startListener(opts, 0)))
}
function startListener(opts: ListenOptions, port: number) {
+25 -1
View File
@@ -112,6 +112,7 @@ export const layer: Layer.Layer<
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const agents = yield* Agent.Service
const skill = yield* Skill.Service
const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
@@ -275,6 +276,25 @@ export const layer: Layer.Layer<
return (yield* all()).map((tool) => tool.id)
})
const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) {
const list = yield* skill.available(agent)
if (list.length === 0) return "No skills are currently available."
return [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})
const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) {
const items = (yield* agents.list()).filter((item) => item.mode !== "primary")
const filtered = items.filter(
@@ -320,7 +340,11 @@ export const layer: Layer.Layer<
: undefined
return {
id: tool.id,
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined]
description: [
output.description,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
+1 -2
View File
@@ -1,10 +1,9 @@
import { type ChildProcess } from "child_process"
import type { Stream } from "node:stream"
import launch from "cross-spawn"
import { buffer } from "node:stream/consumers"
import { errorMessage } from "./error"
export type Stdio = "inherit" | "pipe" | "ignore" | number | Stream
export type Stdio = "inherit" | "pipe" | "ignore"
export type Shell = boolean | string
export interface Options {
-32
View File
@@ -531,38 +531,6 @@ describe("acp event routing", () => {
expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
})
it("includes available input in the synthetic pending tool call", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_pending_input", cwd: "/workspace" }))
await harness.subscription.handle(
toolUpdated({
id: "part_call_read",
sessionID: "ses_pending_input",
messageID: "msg_call_read",
type: "tool",
callID: "call_read",
tool: "read",
state: {
status: "running",
input: { filePath: "/workspace/file.ts" },
title: "Read file.ts",
time: { start: Date.now() },
},
} satisfies ToolPart),
)
expect(harness.updates[0]?.update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "call_read",
status: "pending",
title: "Read file.ts",
kind: "read",
rawInput: { filePath: "/workspace/file.ts" },
locations: [{ path: "/workspace/file.ts" }],
})
})
it("does not emit duplicate synthetic pending after a replayed running tool", async () => {
const harness = createHarness()
await Effect.runPromise(harness.session.create({ id: "ses_replay", cwd: "/workspace" }))
@@ -12,7 +12,6 @@ import {
RunCommandMenuBody,
RunModelSelectBody,
RunQueuedPromptSelectBody,
RunSkillSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "@/cli/cmd/run/footer.command"
@@ -156,23 +155,13 @@ async function renderFooter(
tuiConfig?: RunTuiConfig
commands?: RunCommand[]
theme?: () => RunTheme
providers?: RunProvider[]
currentModel?: RunInput["model"]
currentVariant?: string
subagents?: FooterSubagentState
backgroundSubagents?: boolean
width?: number
height?: number
state?: Partial<FooterState>
onCycle?: () => void
onSubmit?: (prompt: RunPrompt) => boolean
} = {},
) {
const [view] = createSignal<FooterView>({ type: "prompt" })
const [subagents] = createSignal<FooterSubagentState>(
input.subagents ?? { tabs: [], details: {}, permissions: [], questions: [] },
)
const state = footerState(input.state)
const [subagents] = createSignal<FooterSubagentState>({ tabs: [], details: {}, permissions: [], questions: [] })
const state = footerState()
const config = input.tuiConfig ?? tuiConfig
let offKeymap: (() => void) | undefined
@@ -189,16 +178,16 @@ async function renderFooter(
agents={() => []}
resources={() => []}
commands={() => input.commands ?? []}
providers={() => input.providers}
currentModel={() => input.currentModel}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => input.currentVariant}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
tuiConfig={config}
backgroundSubagents={input.backgroundSubagents ?? true}
backgroundSubagents={true}
agent="opencode"
onSubmit={input.onSubmit ?? (() => true)}
onPermissionReply={() => {}}
@@ -206,7 +195,6 @@ async function renderFooter(
onQuestionReject={() => {}}
onCycle={input.onCycle ?? (() => {})}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
@@ -222,11 +210,11 @@ async function renderFooter(
const app = await testRender(
() => (
<box width={input.width ?? 100} height={input.height ?? 8}>
<box width={100} height={8}>
<Harness />
</box>
),
{ width: input.width ?? 100, height: input.height ?? 8, kittyKeyboard: true },
{ width: 100, height: 8, kittyKeyboard: true },
)
return {
@@ -241,14 +229,7 @@ async function renderFooter(
}
}
function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
(RUN_THEME_FALLBACK.footer.selected as RGBA).toInts(),
)
}
test("direct footer composer area does not adopt footer surface", async () => {
test("direct footer updates composer background when theme changes", async () => {
const surface = RGBA.fromHex("#123456")
const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
const app = await renderFooter({ theme })
@@ -267,7 +248,7 @@ test("direct footer composer area does not adopt footer surface", async () => {
})
await app.renderOnce()
expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
expect(area.backgroundColor.toInts()).toEqual(surface.toInts())
} finally {
app.cleanup()
}
@@ -338,8 +319,6 @@ test("direct command panel renders grouped command palette", async () => {
variantCycle="ctrl+t"
onClose={() => {}}
onModel={() => {}}
onEditor={() => {}}
onSkill={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
@@ -362,17 +341,17 @@ test("direct command panel renders grouped command palette", async () => {
expect(frame).toContain("Commands")
expect(frame).toContain("Search")
expect(frame).toContain("Session")
expect(frame).toContain("Agent")
expect(frame).toContain("Prompt")
expect(frame).toContain("Open editor")
expect(frame).toContain("/editor")
expect(frame).toContain("Suggested")
expect(frame).toContain("Switch model")
expect(frame).toContain("Skills")
expect(frame).toContain("/skills")
expect(frame.match(/\bAgent\b/g)?.length).toBe(1)
expect(frame).not.toContain("")
expect(frame).not.toContain("")
expect(frame).toContain("Variant cycle")
expect(frame).toContain("ctrl+t")
expect(frame).toContain("Switch model variant")
expect(frame).toContain("Session")
expect(frame).toContain("New session")
expect(frame).toContain("/new")
expect(frame).toContain("Project Commands")
expect(frame).toContain("review")
expect(frame).toContain("/review")
expect(frame).not.toContain("/internal")
expect(frame).not.toContain("Choose model for future turns")
expect(frame).not.toContain("Cycle reasoning effort for future turns")
@@ -383,85 +362,6 @@ test("direct command panel renders grouped command palette", async () => {
}
})
test("direct skill panel renders searchable skill list", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([
command({ name: "review", description: "Review code" }),
command({ name: "internal", description: "Skill command", source: "skill" }),
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
])
const app = await testRender(
() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunSkillSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
onClose={() => {}}
onSelect={() => {}}
/>
</box>
),
{
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
},
)
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Skills")
expect(frame).toContain("Search")
expect(frame).toContain("internal")
expect(frame).not.toContain("/internal")
expect(frame).toContain("formatter")
expect(frame).toContain("Apply formatter fixes")
expect(frame).not.toContain("review")
} finally {
app.renderer.destroy()
}
})
test("direct skill panel truncates long descriptions from the end", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([
command({
name: "terminal-control",
description:
"Control and test terminal applications, REPLs, interactive CLIs, shell processes, OpenTUI applications, or other terminal-backed workflows.",
source: "skill",
}),
])
const app = await testRender(
() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunSkillSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
onClose={() => {}}
onSelect={() => {}}
/>
</box>
),
{
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
},
)
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("terminal-control")
expect(frame).toContain("Control and test terminal applications")
expect(frame).not.toMatch(/application(?:…|\.\.\.)ocess/)
} finally {
app.renderer.destroy()
}
})
test("direct command panel shows subagent entry when available", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([])
const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })])
@@ -479,8 +379,6 @@ test("direct command panel shows subagent entry when available", async () => {
variantCycle="ctrl+t"
onClose={() => {}}
onModel={() => {}}
onEditor={() => {}}
onSkill={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
@@ -508,54 +406,6 @@ test("direct command panel shows subagent entry when available", async () => {
}
})
test("direct command panel keeps completed subagents available", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([])
const [subagents] = createSignal([
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" }),
])
const [variants] = createSignal<string[]>([])
const app = await testRender(
() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunCommandMenuBody
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
subagents={subagents}
queued={() => []}
variants={variants}
variantCycle="ctrl+t"
onClose={() => {}}
onModel={() => {}}
onEditor={() => {}}
onSkill={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onCommand={() => {}}
onNew={() => {}}
onExit={() => {}}
/>
</box>
),
{
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
},
)
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("View subagents")
expect(frame).toContain("1 recent")
} finally {
app.renderer.destroy()
}
})
test("direct subagent panel renders active subagents", async () => {
const [tabs] = createSignal([
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
@@ -588,15 +438,11 @@ test("direct subagent panel renders active subagents", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-subagent-list") as BoxRenderable
expect(frame).toContain("Select subagent")
expect(frame).toContain("Inspect auth flow")
expect(frame).toContain("Write migration plan")
expect(frame).toContain("done")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 0)
expect(rows).toBe(8)
} finally {
app.renderer.destroy()
@@ -625,41 +471,25 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-queued-list") as BoxRenderable
expect(frame).toContain("Queued prompts")
expect(frame).toContain("fix the auth test")
expect(frame).toContain("queued")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 0)
expect(app.captureCharFrame()).toContain("Queued prompts")
expect(app.captureCharFrame()).toContain("fix the auth test")
expect(app.captureCharFrame()).toContain("queued")
} finally {
app.renderer.destroy()
}
})
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
// Re-enable after the upstream OpenTUI fix lands in this repo.
test.skip("direct footer recreates the frame across command panel transitions", async () => {
// OpenTUI currently segfaults when the full footer view suite creates several
// keymap-backed test renderers in one process. Re-enable after the runtime fix.
test.skip("direct footer opens command panel through keymap binding", async () => {
const app = await renderFooter()
try {
await app.renderOnce()
app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce()
for (let index = 0; index < 3; index++) {
const composerFrame = app.renderer.root.findDescendantById("run-direct-footer-composer-frame") as BoxRenderable
app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Commands")
expect(app.renderer.root.findDescendantById("run-direct-footer-composer-frame")).not.toBe(composerFrame)
app.mockInput.pressKey("c", { ctrl: true })
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Commands")
expect(app.captureCharFrame()).not.toContain("┃")
expect(app.captureCharFrame()).not.toContain("█")
}
expect(app.captureCharFrame()).toContain("Commands")
} finally {
app.cleanup()
}
@@ -766,104 +596,6 @@ test("direct footer submits slash autocomplete selections without dispatching sh
}
})
test("direct footer slash autocomplete keeps a real skills command", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [
command({ name: "skills", description: "Run the real skills command" }),
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/skills".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
} finally {
app.cleanup()
}
})
// OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition.
// Re-enable after the upstream renderer teardown fix lands.
test.skip("direct footer skill picker inserts an editable bound skill command", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "new", description: "Skill named new", source: "skill" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/skills".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Skill named new")
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([])
expect(app.captureCharFrame()).toContain("/new")
"task".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task" } }])
} finally {
app.cleanup()
}
})
// OpenTUI currently segfaults Bun while tearing down this skill-panel close transition.
// Re-enable after the upstream renderer teardown fix lands.
test.skip("direct footer clears the synthetic skills draft when the panel closes", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/skills".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Apply formatter fixes")
app.mockInput.pressKey("c", { ctrl: true })
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([])
expect(app.captureCharFrame()).not.toContain("/skills")
} finally {
app.cleanup()
}
})
test("direct footer shows editable prompts and additional queued work while running", async () => {
const [state] = createSignal<FooterState>({
phase: "running",
@@ -898,10 +630,7 @@ test("direct footer shows editable prompts and additional queued work while runn
resources={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => ({
providerID: "opencode",
modelID: "a-model-name-long-enough-to-force-responsive-truncation",
})}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
@@ -920,7 +649,6 @@ test("direct footer shows editable prompts and additional queued work while runn
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
@@ -948,34 +676,10 @@ test("direct footer shows editable prompts and additional queued work while runn
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
const statusline = app.renderer.root.findDescendantById("run-direct-footer-statusline") as BoxRenderable
const mode = app.renderer.root.findDescendantById("run-direct-footer-statusline-mode") as BoxRenderable
const main = app.renderer.root.findDescendantById("run-direct-footer-statusline-main") as BoxRenderable
const spinner = app.renderer.root.findDescendantById("run-direct-footer-status-spinner")
const model = app.renderer.root.findDescendantById("run-direct-footer-statusline-model") as BoxRenderable
const queued = app.renderer.root.findDescendantById("run-direct-footer-statusline-queued") as BoxRenderable
const hint = app.renderer.root.findDescendantById("run-direct-footer-statusline-hint") as BoxRenderable
expect(spinner).toBeDefined()
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).toContain("3 queued")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 3 queued")
expect(frame).toContain("ctrl+x down subagents")
expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).toContain("subagents · ctrl+p cmd")
expect(frame).not.toContain("1 agent")
expect(statusline.backgroundColor.toInts()).toEqual(tinted)
expect(mode.backgroundColor.toInts()).toEqual(accent)
expect(main.backgroundColor.toInts()).toEqual(transparent)
expect(model.backgroundColor.toInts()).toEqual(transparent)
expect(queued.backgroundColor.toInts()).toEqual(transparent)
expect(hint.backgroundColor.toInts()).toEqual(transparent)
expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • ctrl+b background • 1 queued ctrl+x q")
expect(app.captureCharFrame()).toContain("2 queued")
expect(app.captureCharFrame()).not.toContain("to view")
expect(app.captureCharFrame()).not.toContain("edit/remove")
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
@@ -984,110 +688,6 @@ test("direct footer shows editable prompts and additional queued work while runn
}
})
test("direct footer separates a lone context hint from model and command hint", async () => {
const app = await renderFooter({
providers: [provider()],
currentModel: { providerID: "opencode", modelID: "gpt-5" },
currentVariant: "xhigh",
subagents: {
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
details: {},
permissions: [],
questions: [],
},
backgroundSubagents: false,
width: 160,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd")
expect(frame).not.toContain("ctrl+b background")
expect(frame).not.toContain("queued")
} finally {
app.cleanup()
}
})
test("direct footer hides the subagent hint when only completed subagents remain", async () => {
const app = await renderFooter({
providers: [provider()],
currentModel: { providerID: "opencode", modelID: "gpt-5" },
currentVariant: "xhigh",
subagents: {
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" })],
details: {},
permissions: [],
questions: [],
},
backgroundSubagents: false,
width: 160,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+p cmd")
expect(frame).not.toContain("ctrl+x down subagents")
} finally {
app.cleanup()
}
})
test("direct footer omits interrupt key hint when interrupt is unbound", async () => {
const app = await renderFooter({
tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }),
state: { phase: "running" },
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("interrupt")
expect(frame).not.toContain("ctrl+l")
} finally {
app.cleanup()
}
})
test("direct footer shows full usage metadata when room is available", async () => {
const app = await renderFooter({
state: { usage: "159.6K (16%) · $4.23" },
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("159.6K (16%) · $4.23")
} finally {
app.cleanup()
}
})
test("direct footer mode label keeps left padding without a status pill", async () => {
const app = await renderFooter()
try {
await app.renderOnce()
const statusline = app
.captureCharFrame()
.split("\n")
.find((line) => line.includes("BUILD") && line.includes("cmd"))
expect(statusline).toBeDefined()
expect(statusline?.startsWith(" BUILD ")).toBe(true)
} finally {
app.cleanup()
}
})
test("direct question body separates single-select checkmark from label", async () => {
const request = {
id: "question-1",
@@ -1276,7 +876,6 @@ test("direct model panel renders current model selector", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-model-list") as BoxRenderable
expect(frame).toContain("Select model")
expect(frame).toContain("Search")
@@ -1285,10 +884,7 @@ test("direct model panel renders current model selector", async () => {
expect(frame).toContain("current")
expect(frame).toContain("GPT Free")
expect(frame).toContain("Free")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expect(frame).not.toContain("Old Model")
expectPaletteList(list, 2)
} finally {
app.renderer.destroy()
}
@@ -1319,16 +915,12 @@ test("direct variant panel renders current variant selector", async () => {
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-variant-list") as BoxRenderable
expect(frame).toContain("Select variant")
expect(frame).toContain("Default")
expect(frame).toContain("high")
expect(frame).toContain("minimal")
expect(frame).toContain("current")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 1)
} finally {
app.renderer.destroy()
}
@@ -1,35 +0,0 @@
import { describe, expect, test } from "bun:test"
import { footerWidthPolicy } from "@/cli/cmd/run/footer.width"
describe("run footer width", () => {
test("preserves shared dialog and statusline breakpoints", () => {
const narrow = footerWidthPolicy(79)
expect(narrow.dialog.narrow).toBe(true)
expect(narrow.statusline.showActivityMeta).toBe(false)
expect(narrow.statusline.showCommandHint).toBe(true)
expect(narrow.statusline.showContextHints).toBe(false)
expect(narrow.statusline.contextHintLimit).toBe(0)
expect(narrow.statusline.showModel).toBe(false)
const command = footerWidthPolicy(65)
expect(command.statusline.showCommandHint).toBe(false)
const commandHint = footerWidthPolicy(66)
expect(commandHint.statusline.showCommandHint).toBe(true)
const compact = footerWidthPolicy(80)
expect(compact.dialog.narrow).toBe(false)
expect(compact.statusline.showActivityMeta).toBe(true)
expect(compact.statusline.showContextHints).toBe(true)
expect(compact.statusline.contextHintLimit).toBe(1)
expect(compact.statusline.showModel).toBe(false)
const model = footerWidthPolicy(120)
expect(model.statusline.contextHintLimit).toBe(2)
expect(model.statusline.showModel).toBe(true)
const spacious = footerWidthPolicy(150)
expect(spacious.statusline.contextHintLimit).toBeUndefined()
expect(spacious.statusline.showModel).toBe(true)
})
})
@@ -1,101 +0,0 @@
import { describe, expect, test } from "bun:test"
import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor"
import type { RunPromptPart } from "@/cli/cmd/run/types"
describe("run prompt editor helpers", () => {
test("strips the local /editor command from the initial editor text", () => {
expect(resolveEditorSlashValue("/editor")).toBe("")
expect(resolveEditorSlashValue("/editor draft message")).toBe("draft message")
expect(resolveEditorSlashValue("/editor first line\nsecond line")).toBe("first line\nsecond line")
})
test("realigns file and agent parts after external editing", () => {
const filePart = {
type: "file",
mime: "text/plain",
filename: "src/app.ts",
url: "file:///src/app.ts",
source: {
type: "file",
path: "src/app.ts",
text: {
start: 0,
end: 11,
value: "@src/app.ts",
},
},
} satisfies RunPromptPart
const agentPart = {
type: "agent",
name: "helper",
source: {
start: 12,
end: 19,
value: "@helper",
},
} satisfies RunPromptPart
const parts = [filePart, agentPart]
expect(realignEditorPromptParts("Please check @helper before @src/app.ts", parts)).toEqual([
{
...filePart,
source: {
...filePart.source,
text: {
...filePart.source.text,
start: 28,
end: 39,
value: "@src/app.ts",
},
},
},
{
...agentPart,
source: {
start: 13,
end: 20,
value: "@helper",
},
},
])
})
test("drops parts whose virtual text was deleted", () => {
const filePart = {
type: "file",
mime: "text/plain",
filename: "src/app.ts",
url: "file:///src/app.ts",
source: {
type: "file",
path: "src/app.ts",
text: {
start: 0,
end: 11,
value: "@src/app.ts",
},
},
} satisfies RunPromptPart
const agentPart = {
type: "agent",
name: "helper",
source: {
start: 12,
end: 19,
value: "@helper",
},
} satisfies RunPromptPart
const parts = [filePart, agentPart]
expect(realignEditorPromptParts("Only @helper remains", parts)).toEqual([
{
...agentPart,
source: {
start: 5,
end: 12,
value: "@helper",
},
},
])
})
})
@@ -206,22 +206,6 @@ describe("run runtime queue", () => {
await task
})
test("shell mode does not emit a turn duration summary", async () => {
const ui = footer()
const task = runPromptQueue({
footer: ui.api,
run: async () => {
ui.api.close()
},
})
ui.submit("ls", "shell")
await task
expect(ui.events.some((event) => event.type === "turn.duration")).toBe(false)
})
test("preserves whitespace for initial input", async () => {
const ui = footer()
const seen: string[] = []
@@ -1,238 +0,0 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { runInteractiveMode } from "@/cli/cmd/run/runtime"
import type { FooterApi, RunProvider } from "@/cli/cmd/run/types"
type SessionMessage = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["data"]>[number]
const provider: RunProvider = {
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
api: {
id: "openai",
url: "https://openai.test",
npm: "@ai-sdk/openai",
},
name: "Little Frank",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
},
},
}
const transportProviders: RunProvider[][] = []
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
}
function footer(): FooterApi {
let closed = false
const closes = new Set<() => void>()
const notify = () => {
for (const fn of closes) fn()
}
return {
get isClosed() {
return closed
},
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event() {},
append() {},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
notify()
},
destroy() {
if (closed) {
return
}
closed = true
notify()
},
}
}
afterEach(() => {
mock.restore()
transportProviders.length = 0
})
describe("run interactive runtime", () => {
test("waits for provider metadata before eager replay transport bootstrap", async () => {
const providersStarted = defer<void>()
const providers = defer<void>()
const sdk = new OpencodeClient()
spyOn(sdk.config, "providers").mockImplementation(async () => {
providersStarted.resolve()
await providers.promise
return ok({ providers: [provider], default: {} })
})
spyOn(sdk.session, "messages").mockImplementation(() =>
ok([
{
info: {
id: "msg-user-1",
sessionID: "ses-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
variant: undefined,
},
},
parts: [
{
id: "part-user-1",
sessionID: "ses-1",
messageID: "msg-user-1",
type: "text",
text: "hello",
},
],
} satisfies SessionMessage,
]),
)
spyOn(sdk.session, "get").mockRejectedValue(new Error("not needed"))
spyOn(sdk.app, "agents").mockImplementation(() => ok([]))
spyOn(sdk.experimental.resource, "list").mockImplementation(() => ok({}))
spyOn(sdk.command, "list").mockImplementation(() => ok([]))
const task = runInteractiveMode(
{
sdk,
directory: "/tmp",
sessionID: "ses-1",
sessionTitle: "Session",
resume: true,
replay: true,
replayLimit: 100,
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
},
variant: undefined,
files: [],
thinking: true,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async () => ({
footer: footer(),
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}),
streamTransport: Promise.resolve({
createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => {
transportProviders.push(input.providers?.() ?? [])
setTimeout(() => {
input.footer.close()
}, 0)
return {
runPromptTurn: async () => {},
selectSubagent: () => {},
replayOnResize: async () => false,
close: async () => {},
}
},
formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}),
},
)
await providersStarted.promise
expect(transportProviders).toEqual([])
providers.resolve()
await task
expect(transportProviders).toEqual([[provider]])
})
})
@@ -111,23 +111,6 @@ function reasoning(text: string, phase: StreamCommit["phase"] = "progress"): Str
}
}
test("turn summary starts at the left edge", async () => {
const out = await setup()
try {
await out.scrollback.writeTurnSummary({ agent: "Build", model: "Little Frank", duration: "2.2s" })
const commits = claim(out.renderer)
try {
expect(renderRows(commits.at(-1)!)[0]).toBe("▣ Build · Little Frank · 2.2s")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
test("theme swaps restyle active reasoning without resetting the stream", async () => {
const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types"
function userMessage(id: string, text: string): SessionMessages[number] {
return {
@@ -30,23 +29,17 @@ function userMessage(id: string, text: string): SessionMessages[number] {
}
}
function assistantInfo(
id: string,
input: {
parentID?: string
modelID?: string
providerID?: string
time?: { created: number; completed?: number }
} = {},
) {
function assistantInfo(id: string) {
return {
id,
sessionID: "session-1",
role: "assistant" as const,
time: input.time ?? { created: 2 },
parentID: input.parentID ?? "msg-user-1",
modelID: input.modelID ?? "gpt-5",
providerID: input.providerID ?? "openai",
time: {
created: 2,
},
parentID: "msg-user-1",
modelID: "gpt-5",
providerID: "openai",
mode: "chat",
agent: "build",
path: {
@@ -66,26 +59,9 @@ function assistantInfo(
}
}
function assistantMessage(
id: string,
text: string,
input: {
parentID?: string
modelID?: string
providerID?: string
time?: { created: number; completed?: number }
} = {},
): SessionMessages[number] {
const time = input.time ?? {
created: 200,
completed: 3000,
}
function assistantMessage(id: string, text: string): SessionMessages[number] {
return {
info: assistantInfo(id, {
...input,
time,
}),
info: assistantInfo(id),
parts: [
{
id: `${id}-text`,
@@ -94,71 +70,14 @@ function assistantMessage(
type: "text",
text,
time: {
start: time.created,
end: time.completed,
start: 2,
end: 3,
},
},
],
}
}
const provider = (name: string): RunProvider => ({
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
api: {
id: "openai",
url: "https://openai.test",
npm: "@ai-sdk/openai",
},
name,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
},
},
})
function runningToolMessage(id: string): SessionMessages[number] {
return {
info: assistantInfo(id),
@@ -184,74 +103,8 @@ function runningToolMessage(id: string): SessionMessages[number] {
}
}
function shellUserMessage(id: string): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
providerID: "openai",
modelID: "gpt-5",
},
},
parts: [
{
id: `${id}-text`,
sessionID: "session-1",
messageID: id,
type: "text",
text: "The following tool was executed by the user",
synthetic: true,
},
],
}
}
function shellAssistantMessage(id: string, parentID: string): SessionMessages[number] {
return {
info: assistantInfo(id, {
parentID,
time: {
created: 200,
completed: 3000,
},
}),
parts: [
{
id: `${id}-tool`,
sessionID: "session-1",
messageID: id,
type: "tool",
callID: `${id}-call`,
tool: "bash",
state: {
status: "completed",
input: {
command: "ls",
},
output: "account.ts\n",
title: "",
metadata: {
output: "account.ts\n",
description: "",
},
time: {
start: 200,
end: 3000,
},
},
},
],
}
}
describe("run session replay", () => {
test("replays persisted user, assistant, and turn summary history into scrollback commits", () => {
test("replays persisted user and assistant history into scrollback commits", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Hello, whats the weather today?"),
@@ -278,18 +131,6 @@ describe("run session replay", () => {
source: "assistant",
messageID: "msg-1",
}),
expect.objectContaining({
kind: "system",
text: "▣ Build · gpt-5 · 2.8s",
phase: "final",
source: "system",
messageID: "msg-1",
summary: {
agent: "Build",
model: "gpt-5",
duration: "2.8s",
},
}),
])
expect(out.patch).toEqual(
expect.objectContaining({
@@ -299,60 +140,6 @@ describe("run session replay", () => {
)
})
test("uses provider model names for replayed turn summaries when available", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Hello, whats the weather today?"),
assistantMessage("msg-1", "What city or ZIP code should I check?"),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
providers: [provider("Little Frank")],
})
expect(out.commits.at(-1)).toEqual(
expect.objectContaining({
kind: "system",
text: "▣ Build · Little Frank · 2.8s",
summary: {
agent: "Build",
model: "Little Frank",
duration: "2.8s",
},
}),
)
})
test("replays one turn summary for the final assistant in a multi-step turn", () => {
const out = replaySession({
messages: [
userMessage("msg-user-1", "Plan and then answer"),
assistantMessage("msg-step-1", "Working", {
parentID: "msg-user-1",
time: { created: 200, completed: 900 },
}),
assistantMessage("msg-step-2", "Done", {
parentID: "msg-user-1",
time: { created: 1000, completed: 3000 },
}),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.commits.filter((commit) => commit.summary)).toEqual([
expect.objectContaining({
kind: "system",
text: "▣ Build · gpt-5 · 2.0s",
messageID: "msg-step-2",
}),
])
})
test("keeps the footer in a running state for resumed active tools", () => {
const out = replaySession({
messages: [runningToolMessage("msg-1")],
@@ -370,29 +157,6 @@ describe("run session replay", () => {
)
})
test("does not replay turn summaries for shell-mode commands", () => {
const out = replaySession({
messages: [
shellUserMessage("msg-shell-user-1"),
shellAssistantMessage("msg-shell-assistant-1", "msg-shell-user-1"),
],
permissions: [],
questions: [],
thinking: true,
limits: {},
})
expect(out.commits.some((commit) => commit.summary)).toBe(false)
expect(out.commits).toContainEqual(
expect.objectContaining({
kind: "tool",
text: "account.ts\n",
tool: "bash",
toolState: "completed",
}),
)
})
test("merges failed local rows ahead of later persisted prompts", () => {
const persisted = {
kind: "user",
@@ -1183,7 +1183,7 @@ describe("run stream transport", () => {
}
})
test("keeps completed historical subagent tabs during bootstrap", async () => {
test("drops completed historical subagent tabs during bootstrap", async () => {
const src = eventFeed()
const ui = footer()
const transport = await createSessionTransport({
@@ -1231,7 +1231,7 @@ describe("run stream transport", () => {
return item?.type === "stream.subagent" ? item.state : undefined
})
expect(state.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "completed" })])
expect(state.tabs).toEqual([])
expect(state.details).toEqual({})
} finally {
src.close()
@@ -4,6 +4,7 @@ import { entryBody } from "@/cli/cmd/run/entry.body"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
clearFinishedSubagents,
createSubagentData,
reduceSubagentData,
snapshotSubagentData,
@@ -49,7 +50,7 @@ function reduce(data: ReturnType<typeof createSubagentData>, event: unknown) {
})
}
function taskMessage(sessionID: string, status: "running" | "completed" | "interrupted" = "completed"): SessionMessage {
function taskMessage(sessionID: string, status: "running" | "completed" = "completed"): SessionMessage {
if (status === "running") {
return {
parts: [
@@ -78,35 +79,6 @@ function taskMessage(sessionID: string, status: "running" | "completed" | "inter
}
}
if (status === "interrupted") {
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "error",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
error: "Tool execution aborted",
metadata: {
sessionId: sessionID,
toolcalls: 4,
interrupted: true,
},
time: { start: 1, end: 2 },
},
},
],
}
}
return {
parts: [
{
@@ -262,25 +234,6 @@ describe("run subagent data", () => {
expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"])
})
test("marks interrupted task tabs as cancelled during bootstrap", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "interrupted")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
expect(snapshotSubagentData(data).tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
status: "cancelled",
}),
])
})
test("captures child activity and blocker metadata in the footer detail state", () => {
const data = createSubagentData()
@@ -484,64 +437,20 @@ describe("run subagent data", () => {
])
})
test("marks a running tab cancelled when the child session aborts", () => {
test("clears finished tabs on the next parent prompt", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "running")],
children: [{ id: "child-1" }],
messages: [taskMessage("child-1", "completed"), taskMessage("child-2", "running")],
children: [{ id: "child-1" }, { id: "child-2" }],
permissions: [],
questions: [],
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-assistant-1",
sessionID: "child-1",
role: "assistant",
time: {
created: 1,
completed: 2,
},
error: {
name: "MessageAbortedError",
data: {
message: "Aborted",
},
},
parentID: "msg-user-1",
providerID: "openai",
modelID: "gpt-5",
mode: "default",
agent: "explore",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
finish: "error",
},
},
})
expect(clearFinishedSubagents(data)).toBe(true)
expect(snapshotSubagentData(data).tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
status: "cancelled",
}),
expect.objectContaining({ sessionID: "child-2", status: "running" }),
])
})
})
@@ -74,33 +74,8 @@ test("returns syntax styles and indexed splash colors", async () => {
expectIndexed(theme.splash.right)
expectIndexed(theme.splash.leftShadow)
expectIndexed(theme.splash.rightShadow)
expectIndexed(theme.block.highlight)
expectIndexed(theme.block.warning)
expectRgba(theme.footer.highlight)
expectRgba(theme.footer.statusAccent)
expectRgba(theme.footer.surface)
expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
test("keeps footer surfaces exact while scrollback stays palette matched", async () => {
const colors = terminalColors({
defaultBackground: "#0f172a",
defaultForeground: "#e2e8f0",
})
const theme = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
const exact = resolveTheme(generateSystem(colors, "dark"), "dark")
try {
expect(expectRgba(theme.footer.selected).toInts()).toEqual(expectRgba(exact.backgroundElement).toInts())
expect(expectRgba(theme.footer.border).toInts()).toEqual(expectRgba(exact.border).toInts())
expect(expectRgba(theme.footer.pane).toInts()).toEqual(expectRgba(exact.backgroundMenu).toInts())
expect(expectRgba(theme.footer.selected).intent).toBe("rgb")
expectIndexed(theme.block.highlight)
expectIndexed(theme.block.warning)
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
@@ -4,7 +4,7 @@ describe("tui attach", () => {
test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
expect(source).toContain('await import("../tui/layer")')
expect(source).toContain('await import("@opencode-ai/tui")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
@@ -8,7 +8,7 @@ describe("tui thread", () => {
test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
expect(source).toContain('await import("../tui/layer")')
expect(source).toContain('await import("@opencode-ai/tui")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
@@ -7,11 +7,8 @@ import { testEffect } from "../lib/effect"
// Per-client state for controlling mock behavior
interface MockClientState {
capabilities: { tools?: object; prompts?: object; resources?: object }
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
listToolsCalls: number
listPromptsCalls: number
listResourcesCalls: number
requestCalls: number
listToolsShouldFail: boolean
listToolsError: string
@@ -38,11 +35,8 @@ function getOrCreateClientState(name?: string): MockClientState {
let state = clientStates.get(key)
if (!state) {
state = {
capabilities: { tools: {}, prompts: {}, resources: {} },
tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
listToolsCalls: 0,
listPromptsCalls: 0,
listResourcesCalls: 0,
requestCalls: 0,
listToolsShouldFail: false,
listToolsError: "listTools failed",
@@ -139,10 +133,6 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
this._state?.notificationHandlers.set(schema, handler)
}
getServerCapabilities() {
return this._state?.capabilities
}
async listTools() {
if (this._state) this._state.listToolsCalls++
if (this._state?.listToolsShouldFail) {
@@ -158,7 +148,6 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
}
async listPrompts() {
if (this._state) this._state.listPromptsCalls++
if (this._state?.listPromptsShouldFail) {
throw new Error("listPrompts failed")
}
@@ -166,7 +155,6 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
}
async listResources() {
if (this._state) this._state.listResourcesCalls++
if (this._state?.listResourcesShouldFail) {
throw new Error("listResources failed")
}
@@ -610,84 +598,6 @@ it.instance(
},
)
it.instance(
"resource-only servers connect without listing tools",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "resource-only-server"
const serverState = getOrCreateClientState("resource-only-server")
serverState.capabilities = { resources: {} }
serverState.resources = [{ name: "docs", uri: "docs://readme" }]
const result = yield* mcp.add("resource-only-server", {
type: "local",
command: ["echo", "test"],
})
expect(statusName(result.status, "resource-only-server")).toBe("connected")
expect(serverState.listToolsCalls).toBe(0)
expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs"])
expect(serverState.listResourcesCalls).toBe(1)
expect(serverState.listPromptsCalls).toBe(0)
}),
),
{ config: { mcp: {} } },
)
it.instance(
"prompt-only servers connect without listing tools",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "prompt-only-server"
const serverState = getOrCreateClientState("prompt-only-server")
serverState.capabilities = { prompts: {} }
serverState.prompts = [{ name: "review" }]
const result = yield* mcp.add("prompt-only-server", {
type: "local",
command: ["echo", "test"],
})
expect(statusName(result.status, "prompt-only-server")).toBe("connected")
expect(serverState.listToolsCalls).toBe(0)
expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only-server:review"])
expect(serverState.listPromptsCalls).toBe(1)
expect(serverState.listResourcesCalls).toBe(0)
}),
),
{ config: { mcp: {} } },
)
it.instance(
"tools-only servers skip optional prompt and resource discovery",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "tools-only-server"
const serverState = getOrCreateClientState("tools-only-server")
serverState.capabilities = { tools: {} }
const result = yield* mcp.add("tools-only-server", {
type: "local",
command: ["echo", "test"],
})
expect(statusName(result.status, "tools-only-server")).toBe("connected")
expect(serverState.listToolsCalls).toBe(1)
expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only-server_test_tool"])
expect(yield* mcp.prompts()).toEqual({})
expect(yield* mcp.resources()).toEqual({})
expect(serverState.listPromptsCalls).toBe(0)
expect(serverState.listResourcesCalls).toBe(0)
}),
),
{ config: { mcp: {} } },
)
it.instance(
"prompts() skips disconnected servers",
() =>
@@ -21,8 +21,6 @@ const transportCalls: Array<{
// auth flow (which calls provider.state()) or a simple UnauthorizedError.
let simulateAuthFlow = true
let connectSucceedsImmediately = false
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
let listToolsCalls = 0
// Mock the transport constructors to simulate OAuth auto-auth on 401
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
@@ -93,19 +91,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
setNotificationHandler() {}
getServerCapabilities() {
return serverCapabilities
}
async listTools() {
listToolsCalls++
return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] }
}
async listResources() {
return { resources: [{ name: "docs", uri: "docs://readme" }] }
}
async close() {}
},
}))
@@ -119,8 +108,6 @@ beforeEach(() => {
transportCalls.length = 0
simulateAuthFlow = true
connectSucceedsImmediately = false
serverCapabilities = { tools: {} }
listToolsCalls = 0
})
// Import modules after mocking
@@ -247,28 +234,3 @@ mcpTest.instance(
),
{ config: config("test-oauth-connect") },
)
mcpTest.instance(
"authenticate() connects a resource-only server without listing tools",
() =>
MCP.Service.use((mcp) =>
Effect.gen(function* () {
const added = yield* mcp.add("test-oauth-resources", {
type: "remote",
url: "https://example.com/mcp",
})
const before = added.status as Record<string, { status: string }>
expect(before["test-oauth-resources"]?.status).toBe("needs_auth")
simulateAuthFlow = false
connectSucceedsImmediately = true
serverCapabilities = { resources: {} }
const result = yield* mcp.authenticate("test-oauth-resources")
expect(result.status).toBe("connected")
expect(listToolsCalls).toBe(0)
expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"])
}),
),
{ config: config("test-oauth-resources") },
)
@@ -89,10 +89,6 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
async connect(transport: { start: () => Promise<void> }) {
await transport.start()
}
getServerCapabilities() {
return { tools: {} }
}
},
}))
@@ -3,6 +3,7 @@ import net from "node:net"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { ServerAuth } from "../../src/server/auth"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { withTimeout } from "../../src/util/timeout"
import { resetDatabase } from "../fixture/db"
@@ -313,6 +314,38 @@ describe("HttpApi Server.listen", () => {
}
})
test("preferred port 0 uses an OS-assigned port", async () => {
const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, preferredPort: 0 })
try {
expect(listener.port).toBeGreaterThan(0)
expect(listener.port).not.toBe(4096)
} finally {
await stop(listener, "timed out cleaning up OS-assigned listener")
}
})
test("accepts listener-local credentials without changing process auth", async () => {
const listener = await Server.listen({
hostname: "127.0.0.1",
port: 0,
preferredPort: 0,
auth: { password: "local-secret" },
})
try {
expect((await fetch(new URL("/doc", listener.url))).status).toBe(401)
expect(
(
await fetch(new URL("/doc", listener.url), {
headers: { authorization: ServerAuth.header({ password: "local-secret" }) ?? "" },
})
).status,
).toBe(200)
expect(process.env.OPENCODE_SERVER_PASSWORD).toBe(original.envPassword)
} finally {
await stop(listener, "timed out cleaning up listener-local auth listener")
}
})
test("port 0 falls back when 4096 is taken", async () => {
const blocker = await occupyPort(4096)
if (!blocker) return
@@ -9,7 +9,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { validateSession } from "../../src/cli/tui/validate-session"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -470,14 +469,8 @@ describe("HttpApi SDK", () => {
Effect.gen(function* () {
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
const fetch = yield* serverFetch(serverPath)
const thrown = yield* captureThrown(() =>
validateSession({
url: "http://localhost",
directory,
sessionID,
fetch,
}),
)
const sdk = createOpencodeClient({ baseUrl: "http://localhost", directory, fetch })
const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
return errorMessage(thrown)
}),
@@ -68,9 +68,6 @@ Use this skill.
})).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found")
expect(tool.description).not.toContain("tool-skill")
expect(tool.description).not.toContain("Skill for tool tests.")
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
+3 -6
View File
@@ -31,6 +31,7 @@ import { ProjectProvider, useProject } from "./context/project"
import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { StartupLoading } from "./component/startup-loading"
import { SyncProvider, useSync } from "./context/sync"
import { SyncProviderV2 } from "./context/sync-v2"
@@ -130,13 +131,11 @@ const appBindingCommands = [
] as const
export type TuiInput = {
url: string
sdk: OpencodeClient
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
pluginHost: TuiPluginHost
}
@@ -270,10 +269,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
url={input.url}
sdk={input.sdk}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
+5 -6
View File
@@ -26,7 +26,7 @@ import { useProject } from "../../context/project"
import { useSync } from "../../context/sync"
import { useEvent } from "../../context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
import { normalizePromptContent, openEditor } from "../../editor"
import { openEditor } from "../../editor"
import { destroyRenderer } from "../../util/renderer"
import { promptOffsetWidth } from "../../prompt/display"
import { createStore, produce, unwrap } from "solid-js/store"
@@ -442,9 +442,8 @@ export function Prompt(props: PromptProps) {
paths.cwd,
})
if (!content) return
const normalized = normalizePromptContent(content)
input.setText(normalized)
input.setText(content)
// Update positions for nonTextParts based on their location in new content
// Filter out parts whose virtual text was deleted
@@ -461,7 +460,7 @@ export function Prompt(props: PromptProps) {
if (!virtualText) return part
const newStart = normalized.indexOf(virtualText)
const newStart = content.indexOf(virtualText)
// if the virtual text is deleted, remove the part
if (newStart === -1) return null
@@ -497,13 +496,13 @@ export function Prompt(props: PromptProps) {
.filter((part) => part !== null)
setStore("prompt", {
input: normalized,
input: content,
// keep only the non-text parts because the text parts were
// already expanded inline
parts: updatedNonTextParts,
})
restoreExtmarksFromParts(updatedNonTextParts)
input.cursorOffset = Bun.stringWidth(normalized)
input.cursorOffset = Bun.stringWidth(content)
},
},
{
+3 -18
View File
@@ -1,5 +1,4 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createSimpleContext } from "./helper"
import { batch, onCleanup, onMount } from "solid-js"
@@ -11,26 +10,14 @@ export type EventSource = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: {
url: string
sdk: OpencodeClient
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}) => {
const abort = new AbortController()
let sse: AbortController | undefined
function createSDK() {
return createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
fetch: props.fetch,
headers: props.headers,
})
}
let sdk = createSDK()
const sdk = props.sdk
const handlers = new Set<(event: GlobalEvent) => void>()
const emitter = {
@@ -144,8 +131,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
},
directory: props.directory,
event: emitter,
fetch: props.fetch ?? fetch,
url: props.url,
}
},
})
+2 -19
View File
@@ -4,26 +4,9 @@ import { readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { spawn } from "node:child_process"
import type { Stream } from "node:stream"
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
export function normalizePromptContent(content: string) {
if (content.endsWith("\r\n")) {
const body = content.slice(0, -2)
return !body.includes("\n") && !body.includes("\r") ? body : content
}
if (content.endsWith("\n")) {
const body = content.slice(0, -1)
return !body.includes("\n") && !body.includes("\r") ? body : content
}
return content
}
export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string }) {
const editor = process.env.VISUAL || process.env.EDITOR
if (!editor) return
const file = path.join(os.tmpdir(), `${Date.now()}.md`)
@@ -35,7 +18,7 @@ export async function openEditor(input: { value: string; renderer: CliRenderer;
const parts = editor.split(" ")
const child = spawn(parts[0]!, [...parts.slice(1), file], {
cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
stdio: [input.stdin ?? "inherit", "inherit", "inherit"],
stdio: "inherit",
shell: process.platform === "win32",
})
child.on("error", reject)
+2 -3
View File
@@ -3,7 +3,7 @@ import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createEventSource, createFetch, directory } from "./fixture/tui-sdk"
import { createClient, createEventSource, createFetch, directory } from "./fixture/tui-sdk"
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -28,10 +28,9 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
sdk: createClient(calls.fetch),
directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: {},
pluginHost: {
@@ -6,7 +6,7 @@ import { KVProvider, useKV } from "../../../../src/context/kv"
import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk"
import { SyncProvider, useSync } from "../../../../src/context/sync"
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
import { createClient, createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
import { TestTuiContexts } from "../../../fixture/tui-environment"
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
@@ -46,7 +46,7 @@ export async function mount(override?: FetchHandler, state?: string) {
<TestTuiContexts paths={state ? { state } : undefined}>
<ArgsProvider>
<KVProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
<SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider>
<SyncProvider>
<Probe />
+8 -8
View File
@@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2"
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
import { createClient, createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
async function wait(fn: () => boolean, timeout = 2000) {
@@ -44,7 +44,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
@@ -173,7 +173,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
@@ -237,7 +237,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
@@ -285,7 +285,7 @@ test("sync v2 projects live context updates with their message ID", async () =>
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
@@ -340,7 +340,7 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
@@ -390,7 +390,7 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows",
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
@@ -459,7 +459,7 @@ test("sync v2 preserves snapshot order and metadata for in-flight updates", asyn
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
+2 -2
View File
@@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { useEvent } from "../../../src/context/event"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import { createClient, createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
const projectID = "proj_test"
@@ -61,7 +61,7 @@ async function mount() {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {
+1 -10
View File
@@ -1,5 +1,5 @@
import { afterEach, expect, test } from "bun:test"
import { normalizePromptContent, openEditor } from "../src/editor"
import { openEditor } from "../src/editor"
const editor = process.env.EDITOR
const visual = process.env.VISUAL
@@ -21,12 +21,3 @@ test("rejects when the external editor cannot start", async () => {
await expect(openEditor({ value: "original", renderer: renderer as never })).rejects.toThrow()
})
test("normalizes a single trailing editor newline for one-line prompts", () => {
expect(normalizePromptContent("hello\n")).toBe("hello")
expect(normalizePromptContent("hello\r\n")).toBe("hello")
})
test("preserves multiline prompts that end with a newline", () => {
expect(normalizePromptContent("hello\nworld\n")).toBe("hello\nworld\n")
})
+5 -1
View File
@@ -1,9 +1,13 @@
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { createOpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "../../src/context/sdk"
export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/tui`
export function createClient(fetch: typeof globalThis.fetch) {
return createOpencodeClient({ baseUrl: "http://test", directory, fetch })
}
export function json(data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), {
...init,
-6
View File
@@ -88,12 +88,6 @@ OpenCode Go includes the following limits:
Limits are defined in dollar value. This means your actual request count depends on the model you use. Cheaper models like DeepSeek V4 Flash allow for more requests, while higher-cost models like GLM-5.1 allow for fewer.
### Why Go prices may differ from provider prices
Go pools demand across subscribers and uses bulk discounts and reserved capacity to offer up to $60 of usage for a $10 subscription. Usage is therefore calculated using Go's prices, which reflect the cost of sustainably serving all Go subscribers and may differ from a model provider's public API prices.
For most models, bulk discounts create enough savings to fund this multiplier. Some providers already offer models such as DeepSeek V4 Pro and MiMo-V2.5-Pro at very low public prices, leaving little room for an additional bulk discount. Applying those public prices to Go would combine the provider's discount with Go's subscription multiplier and make those models unsustainable to include. As a result, their Go prices may be higher than their public API prices. We balance costs across users and models, with the goal of operating Go at break even.
The table below provides an estimated request count based on typical Go usage patterns:
| Model | requests per 5 hour | requests per week | requests per month |