mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20f6635ca0 | |||
| a7f335aec0 |
+4
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -330,7 +330,6 @@ export class Subscription {
|
||||
...pendingToolCall({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 "◍"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,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")
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user