mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-01 19:45:05 +00:00
Compare commits
40 Commits
test/proce
...
oc-run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e30e7f26 | ||
|
|
4e45169eec | ||
|
|
1e00672517 | ||
|
|
9c761ff619 | ||
|
|
ba82c11091 | ||
|
|
d179a5eeb3 | ||
|
|
3d6324459e | ||
|
|
d92cf629f6 | ||
|
|
857c0aa258 | ||
|
|
93acb5411f | ||
|
|
809e46c988 | ||
|
|
56c9f68368 | ||
|
|
4dad8d4bcb | ||
|
|
02a958b30c | ||
|
|
7871920b56 | ||
|
|
82075fa920 | ||
|
|
a3d3bf9a71 | ||
|
|
3146c216ec | ||
|
|
df84677212 | ||
|
|
685e237c4c | ||
|
|
44f83015cd | ||
|
|
9a1c9ae15a | ||
|
|
a3a6cf1c07 | ||
|
|
47a676111a | ||
|
|
1df5ad470a | ||
|
|
506dd75818 | ||
|
|
c8ecd64022 | ||
|
|
ca376a4cff | ||
|
|
7532d99e5b | ||
|
|
181b5f6236 | ||
|
|
6314f09c14 | ||
|
|
4b4b7832aa | ||
|
|
4280307013 | ||
|
|
9b09a7e766 | ||
|
|
3fc0367b93 | ||
|
|
954a6ca88e | ||
|
|
0c03a3ee10 | ||
|
|
53330a518f | ||
|
|
892bdebaac | ||
|
|
18121300f3 |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -27,3 +27,4 @@ rekram1-node
|
||||
-robinmordasiewicz
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-toastythebot
|
||||
|
||||
60
bun.lock
60
bun.lock
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -140,7 +140,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -164,7 +164,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -338,8 +338,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||
"@opentui/core": "0.1.93",
|
||||
"@opentui/solid": "0.1.93",
|
||||
"@opentui/core": "0.1.95",
|
||||
"@opentui/solid": "0.1.95",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -423,22 +423,22 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentui/core": "0.1.93",
|
||||
"@opentui/solid": "0.1.93",
|
||||
"@opentui/core": "0.1.95",
|
||||
"@opentui/solid": "0.1.95",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.93",
|
||||
"@opentui/solid": ">=0.1.93",
|
||||
"@opentui/core": ">=0.1.95",
|
||||
"@opentui/solid": ">=0.1.95",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -457,7 +457,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -468,7 +468,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -503,7 +503,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -550,7 +550,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -561,7 +561,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1461,21 +1461,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.93", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.93", "@opentui/core-darwin-x64": "0.1.93", "@opentui/core-linux-arm64": "0.1.93", "@opentui/core-linux-x64": "0.1.93", "@opentui/core-win32-arm64": "0.1.93", "@opentui/core-win32-x64": "0.1.93", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-HlTM16ZiBKN0mPBNMHSILkSrbzNku6Pg/ovIpVVkEPqLeWeSC2bfZS4Uhc0Ej1sckVVVoU9HKBJanfHvpP+pMg=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.95", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.95", "@opentui/core-darwin-x64": "0.1.95", "@opentui/core-linux-arm64": "0.1.95", "@opentui/core-linux-x64": "0.1.95", "@opentui/core-win32-arm64": "0.1.95", "@opentui/core-win32-x64": "0.1.95", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Ha73I+PPSy6Jk8CTZgdGRHU+nnmrPAs7m6w0k6ge1/kWbcNcZB0lY67sWQMdoa6bSINQMNWg7SjbNCC9B/0exg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.93", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4I2mwhXLqRNUv7tu88hA6cBGaGpLZXkAa8W0VqBiGDV+Tx337x4T+vbQ7G57OwKXT787oTrEOF9rOOrGLov6qw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.95", "", { "os": "darwin", "cpu": "arm64" }, "sha512-92joqr0ucGaIBCl9uYhe5DwAPbgGMTaCsCeY8Yf3VQ72wjGbOTwnC1TvU5wC6bUmiyqfijCqMyuUnj83teIVVQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.93", "", { "os": "darwin", "cpu": "x64" }, "sha512-jvYMgcg47a5qLhSv1DnQiafEWBQ1UukGutmsYV1TvNuhWtuDXYLVy2AhKIHPzbB9JNrV0IpjbxUC8QnJaP3n8g=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.95", "", { "os": "darwin", "cpu": "x64" }, "sha512-+TLL3Kp3x7DTWEAkCAYe+RjRhl58QndoeXMstZNS8GQyrjSpUuivzwidzAz0HZK9SbZJfvaxZmXsToAIdI2fag=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.93", "", { "os": "linux", "cpu": "arm64" }, "sha512-bvFqRcPftmg14iYmMc3d63XC9rhe4yF7pJRApH6klLBKp27WX/LU0iSO4mvyX7qhy65gcmyy4Sj9dl5jNJ+vlA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.95", "", { "os": "linux", "cpu": "arm64" }, "sha512-dAYeRqh7P8o0xFZleDDR1Abt4gSvCISqw6syOrbH3dl7pMbVdGgzA5stM9jqMgdPUVE7Ngumo17C23ehkGv93A=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.93", "", { "os": "linux", "cpu": "x64" }, "sha512-/wJXhwtNxdcpshrRl1KouyGE54ODAHxRQgBHtnlM/F4bB8cjzOlq2Yc+5cv5DxRz4Q0nQZFCPefwpg2U6ZwNdA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.95", "", { "os": "linux", "cpu": "x64" }, "sha512-O54TCgK8E7j2NKrDXUOTZqO4sb8JjeAfnhrStxAMMEw4RFCGWx3p3wLesqR16uKfFFJFDyoh2OWZ698tO88EAA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.93", "", { "os": "win32", "cpu": "arm64" }, "sha512-g3PQobfM2yFPSzkBKRKFp8FgTG4ulWyJcU+GYXjyYmxQIT+ZbOU7UfR//ImRq3/FxUAfUC/MhC6WwjqccjEqBw=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.95", "", { "os": "win32", "cpu": "arm64" }, "sha512-T1RlZ6U/95eYDN6rUm4SLOVA5LBR7iL3TcBroQhV/883bVczXIBPhriEXQayup5FsAemnQba1BzMNvy6128SUw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.93", "", { "os": "win32", "cpu": "x64" }, "sha512-Spllte2W7q+WfB1zVHgHilVJNp+jpp77PkkxTWyMQNvT7vJNt9LABMNjGTGiJBBMkAuKvO0GgFNKxrda7tFKrQ=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.95", "", { "os": "win32", "cpu": "x64" }, "sha512-lH2FHO0HSP2xWT+ccoz0BkLYFsMm7e6OYOh63BUHHh5b7ispnzP4aTyxiaLWrfJwdL0M9rp5cLIY32bhBKF2oA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.93", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.93", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-Qx+4qoLSjnRGoo/YY4sZJMyXj09Y5kaAMpVO+65Ax58MMj4TjABN4bOOiRT2KV7sKOMTjxiAgXAIaBuqBBJ0Qg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.95", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.95", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-iotYCvULgDurLXv3vgOzTLnEOySHFOa/6cEDex76jBt+gkniOEh2cjxxIVt6lkfTsk6UNTk6yCdwNK3nca/j+Q=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-UuVbB5lTRB4bIcaKMc8CLSbQW7m9EjXgxYvxp/uO7Co=",
|
||||
"aarch64-linux": "sha256-8D7ReLRVb7NDd5PQTVxFhRLmlLbfjK007XgIhhpNKoE=",
|
||||
"aarch64-darwin": "sha256-M+z7C/eXfVqwDiGiiwKo/LT/m4dvCjL1Pblsr1kxoyI=",
|
||||
"x86_64-darwin": "sha256-RzZS6GMwYVDPK0W+K/mlebixNMs2+JRkMG9n8OFhd0c="
|
||||
"x86_64-linux": "sha256-C7y5FMI1pGEgMw/vcPoBhK9tw5uGg1bk0gPXPUUVhgU=",
|
||||
"aarch64-linux": "sha256-cUlQ9jp4WIaJkd4GRoHMWc+REG/OnnGCmsQUNmvg4is=",
|
||||
"aarch64-darwin": "sha256-3GXmqG7yihJ91wS/jlW19qxGI62b1bFJnpGB4LcMlpY=",
|
||||
"x86_64-darwin": "sha256-cUF0TfYg2nXnU80kWFpr9kNHlu9txiatIgrHTltgx4g="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { ManagedRuntime } from "effect"
|
||||
import type { E2EWindow } from "../src/testing/terminal"
|
||||
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
||||
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
||||
import {
|
||||
healthPhase,
|
||||
cleanupSession,
|
||||
@@ -13,6 +16,24 @@ import {
|
||||
} from "./actions"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
type LLMFixture = {
|
||||
url: string
|
||||
push: (...input: (Item | Reply)[]) => Promise<void>
|
||||
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
||||
tool: (name: string, input: unknown) => Promise<void>
|
||||
toolHang: (name: string, input: unknown) => Promise<void>
|
||||
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
||||
fail: (message?: unknown) => Promise<void>
|
||||
error: (status: number, body: unknown) => Promise<void>
|
||||
hang: () => Promise<void>
|
||||
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
||||
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||
calls: () => Promise<number>
|
||||
wait: (count: number) => Promise<void>
|
||||
inputs: () => Promise<Record<string, unknown>[]>
|
||||
pending: () => Promise<number>
|
||||
}
|
||||
|
||||
export const settingsKey = "settings.v3"
|
||||
|
||||
const seedModel = (() => {
|
||||
@@ -26,6 +47,7 @@ const seedModel = (() => {
|
||||
})()
|
||||
|
||||
type TestFixtures = {
|
||||
llm: LLMFixture
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
withProject: <T>(
|
||||
@@ -36,7 +58,11 @@ type TestFixtures = {
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
}) => Promise<T>,
|
||||
options?: { extra?: string[] },
|
||||
options?: {
|
||||
extra?: string[]
|
||||
model?: { providerID: string; modelID: string }
|
||||
setup?: (directory: string) => Promise<void>
|
||||
},
|
||||
) => Promise<T>
|
||||
}
|
||||
|
||||
@@ -46,6 +72,31 @@ type WorkerFixtures = {
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
llm: async ({}, use) => {
|
||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||
try {
|
||||
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
||||
await use({
|
||||
url: svc.url,
|
||||
push: (...input) => rt.runPromise(svc.push(...input)),
|
||||
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
||||
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
||||
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
||||
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
||||
fail: (message) => rt.runPromise(svc.fail(message)),
|
||||
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
||||
hang: () => rt.runPromise(svc.hang),
|
||||
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
||||
hits: () => rt.runPromise(svc.hits),
|
||||
calls: () => rt.runPromise(svc.calls),
|
||||
wait: (count) => rt.runPromise(svc.wait(count)),
|
||||
inputs: () => rt.runPromise(svc.inputs),
|
||||
pending: () => rt.runPromise(svc.pending),
|
||||
})
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
page: async ({ page }, use) => {
|
||||
let boundary: string | undefined
|
||||
setHealthPhase(page, "test")
|
||||
@@ -99,7 +150,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
const root = await createTestProject()
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
await options?.setup?.(root)
|
||||
await seedStorage(page, { directory: root, extra: options?.extra, model: options?.model })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
@@ -133,7 +185,14 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
},
|
||||
})
|
||||
|
||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
async function seedStorage(
|
||||
page: Page,
|
||||
input: {
|
||||
directory: string
|
||||
extra?: string[]
|
||||
model?: { providerID: string; modelID: string }
|
||||
},
|
||||
) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript((model: { providerID: string; modelID: string }) => {
|
||||
const win = window as E2EWindow
|
||||
@@ -158,7 +217,7 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
}, seedModel)
|
||||
}, input.model ?? seedModel)
|
||||
}
|
||||
|
||||
export { expect }
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
async function config(dir: string, url: string) {
|
||||
await fs.writeFile(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["e2e-llm"],
|
||||
provider: {
|
||||
"e2e-llm": {
|
||||
name: "E2E LLM",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"test-model": {
|
||||
name: "Test Model",
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 32000 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
build: {
|
||||
model: "e2e-llm/test-model",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, llm, withProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const pageErrors: string[] = []
|
||||
@@ -11,42 +47,51 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
||||
}
|
||||
page.on("pageerror", onPageError)
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const sessionID = (() => {
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
return id
|
||||
})()
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.flatMap((m) => m.parts)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
await withProject(
|
||||
async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
|
||||
.toContain(token)
|
||||
await llm.text(token)
|
||||
await project.gotoSession()
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const sessionID = (() => {
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
return id
|
||||
})()
|
||||
project.trackSession(sessionID)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.flatMap((m) => m.parts)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
},
|
||||
{
|
||||
model: { providerID: "e2e-llm", modelID: "test-model" },
|
||||
setup: (dir) => config(dir, llm.url),
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
sessionComposerDockSelector,
|
||||
sessionTodoToggleButtonSelector,
|
||||
} from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||
@@ -310,6 +311,73 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked question flow supports keyboard shortcuts", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock question keyboard", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
const first = dock.locator('[data-slot="question-option"]').first()
|
||||
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await expect(first).toBeFocused()
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(second).toBeFocused()
|
||||
|
||||
await page.keyboard.press("Space")
|
||||
await page.keyboard.press(`${modKey}+Enter`)
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked question flow supports escape dismiss", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock question escape", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
const first = dock.locator('[data-slot="question-option"]').first()
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await expect(first).toBeFocused()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1344,6 +1344,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
|
||||
@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
|
||||
expect(synthetic).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("adds file parts for @mentions inside comment text", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||
context: [
|
||||
{
|
||||
key: "ctx:comment-mention",
|
||||
type: "file",
|
||||
path: "src/review.ts",
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
text: "look",
|
||||
messageID: "msg_comment_mentions",
|
||||
sessionID: "ses_comment_mentions",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||
|
||||
const parseCommentMentions = (comment: string) => {
|
||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||
if (!path) return []
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
@@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
|
||||
if (!comment) return [filePart]
|
||||
|
||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(url)) return []
|
||||
used.add(url)
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(path),
|
||||
} satisfies PromptRequestPart,
|
||||
]
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
@@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
}),
|
||||
} satisfies PromptRequestPart,
|
||||
filePart,
|
||||
...mentions,
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -1046,6 +1046,9 @@ export default function Page() {
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
commentMentions={{
|
||||
items: file.searchFilesAndDirectories,
|
||||
}}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
|
||||
@@ -29,16 +29,20 @@ function Option(props: {
|
||||
label: string
|
||||
description?: string
|
||||
disabled: boolean
|
||||
ref?: (el: HTMLButtonElement) => void
|
||||
onFocus?: VoidFunction
|
||||
onClick: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={props.ref}
|
||||
data-slot="question-option"
|
||||
data-picked={props.picked}
|
||||
role={props.multi ? "checkbox" : "radio"}
|
||||
aria-checked={props.picked}
|
||||
disabled={props.disabled}
|
||||
onFocus={props.onFocus}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Mark multi={props.multi} picked={props.picked} />
|
||||
@@ -66,16 +70,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
custom: cached?.custom ?? ([] as string[]),
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
focus: 0,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let customRef: HTMLButtonElement | undefined
|
||||
let optsRef: HTMLButtonElement[] = []
|
||||
let replied = false
|
||||
let focusFrame: number | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const count = createMemo(() => options().length + 1)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
@@ -129,6 +138,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
||||
}
|
||||
|
||||
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
||||
|
||||
const pickFocus = (tab: number = store.tab) => {
|
||||
const list = questions()[tab]?.options ?? []
|
||||
if (store.customOn[tab] === true) return list.length
|
||||
return Math.max(
|
||||
0,
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
||||
)
|
||||
}
|
||||
|
||||
const focus = (i: number) => {
|
||||
const next = clamp(i)
|
||||
setStore("focus", next)
|
||||
if (store.editing) return
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
focusFrame = requestAnimationFrame(() => {
|
||||
focusFrame = undefined
|
||||
const el = next === options().length ? customRef : optsRef[next]
|
||||
el?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let raf: number | undefined
|
||||
const update = () => {
|
||||
@@ -153,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
observer.disconnect()
|
||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
focus(pickFocus())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
if (replied) return
|
||||
cache.set(props.request.id, {
|
||||
tab: store.tab,
|
||||
@@ -231,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const customToggle = () => {
|
||||
if (sending()) return
|
||||
setStore("focus", options().length)
|
||||
|
||||
if (!multi()) {
|
||||
setStore("customOn", store.tab, true)
|
||||
@@ -250,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const value = input().trim()
|
||||
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const customOpen = () => {
|
||||
if (sending()) return
|
||||
setStore("focus", options().length)
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
}
|
||||
|
||||
const move = (step: number) => {
|
||||
if (store.editing || sending()) return
|
||||
focus(store.focus + step)
|
||||
}
|
||||
|
||||
const nav = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
void reject()
|
||||
return
|
||||
}
|
||||
|
||||
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
|
||||
if (mod && event.key === "Enter") {
|
||||
if (event.repeat) return
|
||||
event.preventDefault()
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const target =
|
||||
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
|
||||
if (store.editing) return
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
move(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
move(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault()
|
||||
focus(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== "End") return
|
||||
event.preventDefault()
|
||||
focus(count() - 1)
|
||||
}
|
||||
|
||||
const selectOption = (optIndex: number) => {
|
||||
if (sending()) return
|
||||
|
||||
@@ -270,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const opt = options()[optIndex]
|
||||
if (!opt) return
|
||||
if (multi()) {
|
||||
setStore("editing", false)
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
@@ -279,6 +369,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const commitCustom = () => {
|
||||
setStore("editing", false)
|
||||
customUpdate(input())
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||
@@ -308,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
return
|
||||
}
|
||||
|
||||
setStore("tab", store.tab + 1)
|
||||
const tab = store.tab + 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
if (sending()) return
|
||||
if (store.tab <= 0) return
|
||||
setStore("tab", store.tab - 1)
|
||||
const tab = store.tab - 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
return (
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
@@ -351,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
@@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
@@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
@@ -440,8 +547,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
|
||||
@@ -302,6 +302,9 @@ export function FileTabContent(props: { tab: string }) {
|
||||
comments: fileComments,
|
||||
label: language.t("ui.lineComment.submit"),
|
||||
draftKey: () => path() ?? props.tab,
|
||||
mention: {
|
||||
items: file.searchFilesAndDirectories,
|
||||
},
|
||||
state: {
|
||||
opened: () => note.openedComment,
|
||||
setOpened: (id) => setNote("openedComment", id),
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface SessionReviewTabProps {
|
||||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||
focusedFile?: string
|
||||
onScrollRef?: (el: HTMLDivElement) => void
|
||||
commentMentions?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
classes?: {
|
||||
root?: string
|
||||
header?: string
|
||||
@@ -162,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||
onLineCommentDelete={props.onLineCommentDelete}
|
||||
lineCommentActions={props.lineCommentActions}
|
||||
lineCommentMention={props.commentMentions}
|
||||
comments={props.comments}
|
||||
focusedComment={props.focusedComment}
|
||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { app } from "electron"
|
||||
import treeKill from "tree-kill"
|
||||
|
||||
import { WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
||||
import { store } from "./store"
|
||||
|
||||
const CLI_INSTALL_DIR = ".opencode/bin"
|
||||
@@ -135,7 +136,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||
const base = Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
const envs = {
|
||||
const env = {
|
||||
...base,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
@@ -143,8 +144,10 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||
XDG_STATE_HOME: app.getPath("userData"),
|
||||
...extraEnv,
|
||||
}
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
|
||||
|
||||
const { cmd, cmdArgs } = buildCommand(args, envs)
|
||||
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
|
||||
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
|
||||
const child = spawn(cmd, cmdArgs, {
|
||||
env: envs,
|
||||
@@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
function buildCommand(args: string, env: Record<string, string>) {
|
||||
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
|
||||
if (process.platform === "win32" && isWslEnabled()) {
|
||||
console.log(`[cli] Using WSL mode`)
|
||||
const version = app.getVersion()
|
||||
@@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record<string, string>) {
|
||||
}
|
||||
|
||||
const sidecar = getSidecarPath()
|
||||
const shell = process.env.SHELL || "/bin/sh"
|
||||
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
|
||||
return { cmd: shell, cmdArgs: ["-l", "-c", line] }
|
||||
const user = shell || getUserShell()
|
||||
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
|
||||
return { cmd: user, cmdArgs: ["-l", "-c", line] }
|
||||
}
|
||||
|
||||
function envPrefix(env: Record<string, string>) {
|
||||
|
||||
43
packages/desktop-electron/src/main/shell-env.test.ts
Normal file
43
packages/desktop-electron/src/main/shell-env.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
|
||||
|
||||
describe("shell env", () => {
|
||||
test("parseShellEnv supports null-delimited pairs", () => {
|
||||
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin:/bin")
|
||||
expect(env.FOO).toBe("bar=baz")
|
||||
})
|
||||
|
||||
test("parseShellEnv ignores invalid entries", () => {
|
||||
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
|
||||
|
||||
expect(Object.keys(env).length).toBe(1)
|
||||
expect(env.OK).toBe("1")
|
||||
})
|
||||
|
||||
test("mergeShellEnv keeps explicit overrides", () => {
|
||||
const env = mergeShellEnv(
|
||||
{
|
||||
PATH: "/shell/path",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
{
|
||||
PATH: "/desktop/path",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
},
|
||||
)
|
||||
|
||||
expect(env.PATH).toBe("/desktop/path")
|
||||
expect(env.HOME).toBe("/tmp/home")
|
||||
expect(env.OPENCODE_CLIENT).toBe("desktop")
|
||||
})
|
||||
|
||||
test("isNushell handles path and binary name", () => {
|
||||
expect(isNushell("nu")).toBe(true)
|
||||
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
|
||||
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
|
||||
expect(isNushell("/bin/zsh")).toBe(false)
|
||||
})
|
||||
})
|
||||
88
packages/desktop-electron/src/main/shell-env.ts
Normal file
88
packages/desktop-electron/src/main/shell-env.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { basename } from "node:path"
|
||||
|
||||
const SHELL_ENV_TIMEOUT = 5_000
|
||||
|
||||
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
||||
|
||||
export function getUserShell() {
|
||||
return process.env.SHELL || "/bin/sh"
|
||||
}
|
||||
|
||||
export function parseShellEnv(out: Buffer) {
|
||||
const env: Record<string, string> = {}
|
||||
for (const line of out.toString("utf8").split("\0")) {
|
||||
if (!line) continue
|
||||
const ix = line.indexOf("=")
|
||||
if (ix <= 0) continue
|
||||
env[line.slice(0, ix)] = line.slice(ix + 1)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
|
||||
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: SHELL_ENV_TIMEOUT,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
const err = out.error as NodeJS.ErrnoException | undefined
|
||||
if (err) {
|
||||
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
||||
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
if (out.status !== 0) {
|
||||
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
const env = parseShellEnv(out.stdout)
|
||||
if (Object.keys(env).length === 0) {
|
||||
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
return { type: "Loaded", value: env }
|
||||
}
|
||||
|
||||
export function isNushell(shell: string) {
|
||||
const name = basename(shell).toLowerCase()
|
||||
const raw = shell.toLowerCase()
|
||||
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
|
||||
}
|
||||
|
||||
export function loadShellEnv(shell: string) {
|
||||
if (isNushell(shell)) {
|
||||
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const interactive = probeShellEnv(shell, "-il")
|
||||
if (interactive.type === "Loaded") {
|
||||
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
return interactive.value
|
||||
}
|
||||
if (interactive.type === "Timeout") {
|
||||
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const login = probeShellEnv(shell, "-l")
|
||||
if (login.type === "Loaded") {
|
||||
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
return login.value
|
||||
}
|
||||
|
||||
console.warn(`[cli] Falling back to app environment: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
|
||||
return {
|
||||
...(shell || {}),
|
||||
...env,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.3.11"
|
||||
version = "1.3.13"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.11/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.11/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.11/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.11/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.11/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -102,8 +102,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||
"@opentui/core": "0.1.93",
|
||||
"@opentui/solid": "0.1.93",
|
||||
"@opentui/core": "0.1.95",
|
||||
"@opentui/solid": "0.1.95",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -75,6 +75,7 @@ export namespace Agent {
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const skill = yield* Skill.Service
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
@@ -330,9 +331,9 @@ export namespace Agent {
|
||||
model?: { providerID: ProviderID; modelID: ModelID }
|
||||
}) {
|
||||
const cfg = yield* config.get()
|
||||
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
|
||||
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
|
||||
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
|
||||
const model = input.model ?? (yield* provider.defaultModel())
|
||||
const resolved = yield* provider.getModel(model.providerID, model.modelID)
|
||||
const language = yield* provider.getLanguage(resolved)
|
||||
|
||||
const system = [PROMPT_GENERATE]
|
||||
yield* Effect.promise(() =>
|
||||
@@ -393,6 +394,7 @@ export namespace Agent {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
|
||||
@@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill"
|
||||
import { BashTool } from "../../tool/bash"
|
||||
import { TodoWriteTool } from "../../tool/todo"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { runInteractiveMode } from "./run/runtime"
|
||||
|
||||
type ToolProps<T extends Tool.Info> = {
|
||||
input: Tool.InferParameters<T>
|
||||
@@ -34,6 +35,13 @@ type ToolProps<T extends Tool.Info> = {
|
||||
part: ToolPart
|
||||
}
|
||||
|
||||
type FilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
|
||||
const state = part.state
|
||||
return {
|
||||
@@ -49,6 +57,11 @@ type Inline = {
|
||||
description?: string
|
||||
}
|
||||
|
||||
type SessionInfo = {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
function inline(info: Inline) {
|
||||
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
|
||||
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
|
||||
@@ -302,12 +315,40 @@ export const RunCommand = cmd({
|
||||
describe: "show thinking blocks",
|
||||
default: false,
|
||||
})
|
||||
.option("interactive", {
|
||||
alias: ["i"],
|
||||
type: "boolean",
|
||||
describe: "run in direct interactive split-footer mode",
|
||||
default: false,
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
||||
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
|
||||
if (args.interactive && args.command) {
|
||||
UI.error("--interactive cannot be used with --command")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.interactive && args.format === "json") {
|
||||
UI.error("--interactive cannot be used with --format json")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.interactive && !process.stdin.isTTY) {
|
||||
UI.error("--interactive requires a TTY")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.interactive && !process.stdout.isTTY) {
|
||||
UI.error("--interactive requires a TTY stdout")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
if (args.attach) return args.dir
|
||||
@@ -320,7 +361,7 @@ export const RunCommand = cmd({
|
||||
}
|
||||
})()
|
||||
|
||||
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
|
||||
const files: FilePart[] = []
|
||||
if (args.file) {
|
||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||
|
||||
@@ -344,7 +385,7 @@ export const RunCommand = cmd({
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
if (message.trim().length === 0 && !args.command) {
|
||||
if (message.trim().length === 0 && !args.command && !args.interactive) {
|
||||
UI.error("You must provide a message or a command")
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -378,19 +419,78 @@ export const RunCommand = cmd({
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient) {
|
||||
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
|
||||
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
||||
if (args.session) {
|
||||
const current = await sdk.session
|
||||
.get({
|
||||
sessionID: args.session,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
if (baseID && args.fork) {
|
||||
const forked = await sdk.session.fork({ sessionID: baseID })
|
||||
return forked.data?.id
|
||||
if (!current?.data) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork) {
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: args.session,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? current.data.title,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: current.data.id,
|
||||
title: current.data.title,
|
||||
}
|
||||
}
|
||||
|
||||
if (baseID) return baseID
|
||||
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
|
||||
|
||||
if (base && args.fork) {
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: base.id,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? base.title,
|
||||
}
|
||||
}
|
||||
|
||||
if (base) {
|
||||
return {
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
}
|
||||
}
|
||||
|
||||
const name = title()
|
||||
const result = await sdk.session.create({ title: name, permission: rules })
|
||||
return result.data?.id
|
||||
const result = await sdk.session.create({
|
||||
title: name,
|
||||
permission: rules,
|
||||
})
|
||||
const id = result.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: result.data?.title ?? name,
|
||||
}
|
||||
}
|
||||
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
@@ -432,21 +532,27 @@ export const RunCommand = cmd({
|
||||
|
||||
function emit(type: string, data: Record<string, unknown>) {
|
||||
if (args.format === "json") {
|
||||
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
sessionID,
|
||||
...data,
|
||||
}) + EOL,
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const events = await sdk.event.subscribe()
|
||||
let error: string | undefined
|
||||
|
||||
async function loop() {
|
||||
async function loop(events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
|
||||
const toggles = new Map<string, boolean>()
|
||||
let error: string | undefined
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (
|
||||
event.type === "message.updated" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
event.properties.info.role === "assistant" &&
|
||||
args.format !== "json" &&
|
||||
toggles.get("start") !== true
|
||||
@@ -619,28 +725,33 @@ export const RunCommand = cmd({
|
||||
return args.agent
|
||||
})()
|
||||
|
||||
const sessionID = await session(sdk)
|
||||
if (!sessionID) {
|
||||
const sess = await session(sdk)
|
||||
if (!sess?.id) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
const sessionID = sess.id
|
||||
await share(sdk, sessionID)
|
||||
|
||||
loop().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
if (args.command) {
|
||||
await sdk.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
if (!args.interactive) {
|
||||
const events = await sdk.event.subscribe()
|
||||
loop(events).catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
} else {
|
||||
|
||||
if (args.command) {
|
||||
await sdk.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
await sdk.session.prompt({
|
||||
sessionID,
|
||||
@@ -649,7 +760,23 @@ export const RunCommand = cmd({
|
||||
variant: args.variant,
|
||||
parts: [...files, { type: "text", text: message }],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
await runInteractiveMode({
|
||||
sdk,
|
||||
sessionID,
|
||||
sessionTitle: sess.title,
|
||||
resume: Boolean(args.session) && !args.fork,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
files,
|
||||
initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined,
|
||||
thinking: args.thinking,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
@@ -660,7 +787,11 @@ export const RunCommand = cmd({
|
||||
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: args.attach,
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
@@ -669,7 +800,10 @@ export const RunCommand = cmd({
|
||||
const request = new Request(input, init)
|
||||
return Server.Default().fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://opencode.internal",
|
||||
fetch: fetchFn,
|
||||
})
|
||||
await execute(sdk)
|
||||
})
|
||||
},
|
||||
|
||||
387
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
387
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { Keybind } from "../../../util/keybind"
|
||||
import { RunFooterView, TEXTAREA_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.view"
|
||||
import { entryWriter, normalizeEntry } from "./scrollback"
|
||||
import type { RunTheme } from "./theme"
|
||||
import type { EntryKind, FooterApi, FooterKeybinds, FooterPatch, FooterState } from "./types"
|
||||
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type RunFooterOptions = {
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
first: boolean
|
||||
history?: string[]
|
||||
theme: RunTheme
|
||||
keybinds: FooterKeybinds
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onInterrupt?: () => void
|
||||
onExit?: () => void
|
||||
}
|
||||
|
||||
export class RunFooter implements FooterApi {
|
||||
private closed = false
|
||||
private destroyed = false
|
||||
private prompts = new Set<(text: string) => void>()
|
||||
private closes = new Set<() => void>()
|
||||
private base: number
|
||||
private rows = TEXTAREA_MIN_ROWS
|
||||
private state: Accessor<FooterState>
|
||||
private setState: Setter<FooterState>
|
||||
private settle = false
|
||||
private interruptTimeout: NodeJS.Timeout | undefined
|
||||
private exitTimeout: NodeJS.Timeout | undefined
|
||||
private interruptHint: string
|
||||
|
||||
constructor(
|
||||
private renderer: CliRenderer,
|
||||
private options: RunFooterOptions,
|
||||
) {
|
||||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: options.modelLabel,
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: options.first,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
this.state = state
|
||||
this.setState = setState
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.interruptHint = this.printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
|
||||
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
|
||||
void render(
|
||||
() =>
|
||||
createComponent(RunFooterView, {
|
||||
state: this.state,
|
||||
theme: options.theme.footer,
|
||||
keybinds: options.keybinds,
|
||||
history: options.history,
|
||||
agent: options.agentLabel,
|
||||
onSubmit: this.handlePrompt,
|
||||
onCycle: this.handleCycle,
|
||||
onInterrupt: this.handleInterrupt,
|
||||
onExitRequest: this.handleExit,
|
||||
onExit: () => this.close(),
|
||||
onRows: this.syncRows,
|
||||
onStatus: this.setStatus,
|
||||
}),
|
||||
this.renderer as unknown as Parameters<typeof render>[1],
|
||||
).catch(() => {
|
||||
if (!this.destroyed && !this.renderer.isDestroyed) {
|
||||
this.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public get isClosed(): boolean {
|
||||
return this.closed || this.destroyed || this.renderer.isDestroyed
|
||||
}
|
||||
|
||||
public onPrompt(fn: (text: string) => void): () => void {
|
||||
this.prompts.add(fn)
|
||||
return () => {
|
||||
this.prompts.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
public onClose(fn: () => void): () => void {
|
||||
if (this.isClosed) {
|
||||
fn()
|
||||
return () => {}
|
||||
}
|
||||
|
||||
this.closes.add(fn)
|
||||
return () => {
|
||||
this.closes.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
public patch(next: FooterPatch): void {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const prev = this.state()
|
||||
const state = {
|
||||
phase: next.phase ?? prev.phase,
|
||||
status: typeof next.status === "string" ? next.status : prev.status,
|
||||
queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
|
||||
model: typeof next.model === "string" ? next.model : prev.model,
|
||||
duration: typeof next.duration === "string" ? next.duration : prev.duration,
|
||||
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
||||
first: typeof next.first === "boolean" ? next.first : prev.first,
|
||||
interrupt:
|
||||
typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
|
||||
? Math.max(0, Math.floor(next.interrupt))
|
||||
: prev.interrupt,
|
||||
exit:
|
||||
typeof next.exit === "number" && Number.isFinite(next.exit) ? Math.max(0, Math.floor(next.exit)) : prev.exit,
|
||||
}
|
||||
|
||||
if (state.phase === "idle") {
|
||||
state.interrupt = 0
|
||||
}
|
||||
|
||||
this.setState(state)
|
||||
}
|
||||
|
||||
public append(kind: EntryKind, text: string): void {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizeEntry(kind, text).trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.renderer.writeToScrollback(entryWriter(kind, text, this.options.theme.entry))
|
||||
this.scheduleSettleRender()
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.notifyClose()
|
||||
}
|
||||
|
||||
public requestExit(): boolean {
|
||||
return this.handleExit()
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.destroyed = true
|
||||
this.notifyClose()
|
||||
this.clearInterruptTimer()
|
||||
this.clearExitTimer()
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.prompts.clear()
|
||||
this.closes.clear()
|
||||
}
|
||||
|
||||
private notifyClose(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.closed = true
|
||||
for (const fn of [...this.closes]) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus = (status: string): void => {
|
||||
this.patch({ status })
|
||||
}
|
||||
|
||||
private syncRows = (value: number): void => {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, value))
|
||||
if (rows === this.rows) {
|
||||
return
|
||||
}
|
||||
|
||||
this.rows = rows
|
||||
const min = this.base + TEXTAREA_MIN_ROWS
|
||||
const max = this.base + TEXTAREA_MAX_ROWS
|
||||
const height = Math.max(min, Math.min(max, this.base + rows))
|
||||
|
||||
if (height !== this.renderer.footerHeight) {
|
||||
this.renderer.footerHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
private handlePrompt = (text: string): boolean => {
|
||||
if (this.isClosed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.state().first) {
|
||||
this.patch({ first: false })
|
||||
}
|
||||
|
||||
if (this.prompts.size === 0) {
|
||||
this.patch({ status: "input queue unavailable" })
|
||||
return false
|
||||
}
|
||||
|
||||
for (const fn of [...this.prompts]) {
|
||||
fn(text)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private handleCycle = (): void => {
|
||||
const result = this.options.onCycleVariant?.()
|
||||
if (!result) {
|
||||
this.patch({ status: "no variants available" })
|
||||
return
|
||||
}
|
||||
|
||||
const patch: FooterPatch = {
|
||||
status: result.status ?? "variant updated",
|
||||
}
|
||||
|
||||
if (result.modelLabel) {
|
||||
patch.model = result.modelLabel
|
||||
}
|
||||
|
||||
this.patch(patch)
|
||||
}
|
||||
|
||||
private clearInterruptTimer(): void {
|
||||
if (!this.interruptTimeout) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.interruptTimeout)
|
||||
this.interruptTimeout = undefined
|
||||
}
|
||||
|
||||
private armInterruptTimer(): void {
|
||||
this.clearInterruptTimer()
|
||||
this.interruptTimeout = setTimeout(() => {
|
||||
this.interruptTimeout = undefined
|
||||
if (this.destroyed || this.renderer.isDestroyed || this.state().phase !== "running") {
|
||||
return
|
||||
}
|
||||
|
||||
this.patch({ interrupt: 0 })
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
private clearExitTimer(): void {
|
||||
if (!this.exitTimeout) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.exitTimeout)
|
||||
this.exitTimeout = undefined
|
||||
}
|
||||
|
||||
private armExitTimer(): void {
|
||||
this.clearExitTimer()
|
||||
this.exitTimeout = setTimeout(() => {
|
||||
this.exitTimeout = undefined
|
||||
if (this.destroyed || this.renderer.isDestroyed || this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.patch({ exit: 0 })
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
private handleInterrupt = (): boolean => {
|
||||
if (this.isClosed || this.state().phase !== "running") {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = this.state().interrupt + 1
|
||||
this.patch({ interrupt: next })
|
||||
|
||||
if (next < 2) {
|
||||
this.armInterruptTimer()
|
||||
this.patch({ status: `${this.interruptHint} again to interrupt` })
|
||||
return true
|
||||
}
|
||||
|
||||
this.clearInterruptTimer()
|
||||
this.patch({ interrupt: 0, status: "interrupting" })
|
||||
this.options.onInterrupt?.()
|
||||
return true
|
||||
}
|
||||
|
||||
private handleExit = (): boolean => {
|
||||
if (this.isClosed) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.clearInterruptTimer()
|
||||
const next = this.state().exit + 1
|
||||
this.patch({ exit: next, interrupt: 0 })
|
||||
|
||||
if (next < 2) {
|
||||
this.armExitTimer()
|
||||
this.patch({ status: "Press Ctrl-c again to exit" })
|
||||
return true
|
||||
}
|
||||
|
||||
this.clearExitTimer()
|
||||
this.patch({ exit: 0, status: "exiting" })
|
||||
this.close()
|
||||
this.options.onExit?.()
|
||||
return true
|
||||
}
|
||||
|
||||
private printableBinding(binding: string, leader: string): string {
|
||||
const first = Keybind.parse(binding).at(0)
|
||||
if (!first) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let text = Keybind.toString(first)
|
||||
const lead = Keybind.parse(leader).at(0)
|
||||
if (lead) {
|
||||
text = text.replace("<leader>", Keybind.toString(lead))
|
||||
}
|
||||
|
||||
text = text.replace(/escape/g, "esc")
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
private handleDestroy = (): void => {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.destroyed = true
|
||||
this.notifyClose()
|
||||
this.clearInterruptTimer()
|
||||
this.clearExitTimer()
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.prompts.clear()
|
||||
this.closes.clear()
|
||||
}
|
||||
|
||||
private scheduleSettleRender(): void {
|
||||
if (this.settle || this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.settle = true
|
||||
void this.renderer
|
||||
.idle()
|
||||
.then(() => {
|
||||
if (this.destroyed || this.renderer.isDestroyed || this.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.renderer.requestRender()
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.settle = false
|
||||
})
|
||||
}
|
||||
}
|
||||
625
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
625
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
@@ -0,0 +1,625 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { StyledText, bg, fg, type KeyBinding } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Show, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import { Keybind } from "../../../util/keybind"
|
||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||
import type { FooterKeybinds, FooterState } from "./types"
|
||||
import { RUN_THEME_FALLBACK, type RunFooterTheme } from "./theme"
|
||||
|
||||
const LEADER_TIMEOUT_MS = 2000
|
||||
|
||||
export const TEXTAREA_MIN_ROWS = 1
|
||||
export const TEXTAREA_MAX_ROWS = 6
|
||||
|
||||
export const HINT_BREAKPOINTS = {
|
||||
send: 50,
|
||||
newline: 66,
|
||||
history: 80,
|
||||
variant: 95,
|
||||
}
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
type History = {
|
||||
items: string[]
|
||||
index: number | null
|
||||
draft: string
|
||||
}
|
||||
|
||||
type Area = {
|
||||
isDestroyed: boolean
|
||||
virtualLineCount: number
|
||||
visualCursor: {
|
||||
visualRow: number
|
||||
}
|
||||
plainText: string
|
||||
cursorOffset: number
|
||||
setText(text: string): void
|
||||
focus(): void
|
||||
on(event: string, fn: () => void): void
|
||||
off(event: string, fn: () => void): void
|
||||
}
|
||||
|
||||
type Key = {
|
||||
name: string
|
||||
ctrl?: boolean
|
||||
meta?: boolean
|
||||
shift?: boolean
|
||||
super?: boolean
|
||||
hyper?: boolean
|
||||
preventDefault(): void
|
||||
}
|
||||
|
||||
type RunFooterViewProps = {
|
||||
state: () => FooterState
|
||||
theme?: RunFooterTheme
|
||||
keybinds: FooterKeybinds
|
||||
history?: string[]
|
||||
agent: string
|
||||
onSubmit: (text: string) => boolean
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onRows: (rows: number) => void
|
||||
onStatus: (text: string) => void
|
||||
}
|
||||
|
||||
function isExitCommand(input: string): boolean {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
return normalized === "/exit" || normalized === "/quit"
|
||||
}
|
||||
|
||||
function mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
|
||||
return Keybind.parse(binding).map((item) => ({
|
||||
name: item.name,
|
||||
ctrl: item.ctrl || undefined,
|
||||
meta: item.meta || undefined,
|
||||
shift: item.shift || undefined,
|
||||
super: item.super || undefined,
|
||||
action,
|
||||
}))
|
||||
}
|
||||
|
||||
function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
|
||||
return [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
...mapInputBindings(keybinds.inputSubmit, "submit"),
|
||||
...mapInputBindings(keybinds.inputNewline, "newline"),
|
||||
]
|
||||
}
|
||||
|
||||
function printableBinding(binding: string, leader: string): string {
|
||||
const first = Keybind.parse(binding).at(0)
|
||||
if (!first) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let text = Keybind.toString(first)
|
||||
const lead = Keybind.parse(leader).at(0)
|
||||
if (lead) {
|
||||
text = text.replace("<leader>", Keybind.toString(lead))
|
||||
}
|
||||
|
||||
text = text.replace(/escape/g, "esc")
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function toKeyInfo(event: Key, leader: boolean): Keybind.Info {
|
||||
return {
|
||||
name: event.name === " " ? "space" : event.name,
|
||||
ctrl: !!event.ctrl,
|
||||
meta: !!event.meta,
|
||||
shift: !!event.shift,
|
||||
super: !!event.super,
|
||||
leader,
|
||||
}
|
||||
}
|
||||
|
||||
function match(bindings: Keybind.Info[], event: Keybind.Info): boolean {
|
||||
return bindings.some((item) => Keybind.match(item, event))
|
||||
}
|
||||
|
||||
function clampRows(rows: number): number {
|
||||
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
||||
}
|
||||
|
||||
export function hintFlags(width: number) {
|
||||
return {
|
||||
send: width >= HINT_BREAKPOINTS.send,
|
||||
newline: width >= HINT_BREAKPOINTS.newline,
|
||||
history: width >= HINT_BREAKPOINTS.history,
|
||||
variant: width >= HINT_BREAKPOINTS.variant,
|
||||
}
|
||||
}
|
||||
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
const term = useTerminalDimensions()
|
||||
const leaders = createMemo(() => Keybind.parse(props.keybinds.leader))
|
||||
const cycles = createMemo(() => Keybind.parse(props.keybinds.variantCycle))
|
||||
const interrupts = createMemo(() => Keybind.parse(props.keybinds.interrupt))
|
||||
const historyPrevious = createMemo(() => Keybind.parse(props.keybinds.historyPrevious))
|
||||
const historyNext = createMemo(() => Keybind.parse(props.keybinds.historyNext))
|
||||
const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
|
||||
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
|
||||
const bindings = createMemo(() => textareaBindings(props.keybinds))
|
||||
const hints = createMemo(() => hintFlags(term().width))
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const queue = createMemo(() => props.state().queue)
|
||||
const duration = createMemo(() => props.state().duration)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const interruptKey = createMemo(() => interrupt() || "/exit")
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK.footer)
|
||||
const spin = createMemo(() => {
|
||||
const list = [theme().highlight, theme().text, theme().muted]
|
||||
return {
|
||||
frames: createFrames({
|
||||
colors: list,
|
||||
style: "blocks",
|
||||
}),
|
||||
color: createColors({
|
||||
colors: list,
|
||||
defaultColor: theme().muted,
|
||||
style: "blocks",
|
||||
enableFading: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
const placeholder = createMemo(() => {
|
||||
if (!props.state().first) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return new StyledText([bg(theme().surface)(fg(theme().muted)('Ask anything... "Fix a TODO in the codebase"'))])
|
||||
})
|
||||
|
||||
const history: History = {
|
||||
items: (props.history ?? [])
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.filter((item, index, all) => index === 0 || item !== all[index - 1])
|
||||
.slice(-200),
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
|
||||
let area: Area | undefined
|
||||
let leader = false
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
let rowsTick = false
|
||||
|
||||
const clearLeader = () => {
|
||||
leader = false
|
||||
if (!timeout) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
}
|
||||
|
||||
const armLeader = () => {
|
||||
clearLeader()
|
||||
leader = true
|
||||
timeout = setTimeout(() => {
|
||||
clearLeader()
|
||||
}, LEADER_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const syncRows = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
props.onRows(clampRows(area.virtualLineCount || 1))
|
||||
}
|
||||
|
||||
const scheduleRows = () => {
|
||||
if (rowsTick) {
|
||||
return
|
||||
}
|
||||
|
||||
rowsTick = true
|
||||
queueMicrotask(() => {
|
||||
rowsTick = false
|
||||
syncRows()
|
||||
})
|
||||
}
|
||||
|
||||
const push = (text: string) => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (history.items[history.items.length - 1] === text) {
|
||||
history.index = null
|
||||
history.draft = ""
|
||||
return
|
||||
}
|
||||
|
||||
history.items.push(text)
|
||||
if (history.items.length > 200) {
|
||||
history.items.shift()
|
||||
}
|
||||
|
||||
history.index = null
|
||||
history.draft = ""
|
||||
}
|
||||
|
||||
const move = (dir: -1 | 1, event: Key) => {
|
||||
if (!area || history.items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dir === -1 && area.cursorOffset !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dir === 1 && area.cursorOffset !== area.plainText.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (history.index === null) {
|
||||
if (dir === 1) {
|
||||
return
|
||||
}
|
||||
|
||||
history.draft = area.plainText
|
||||
history.index = history.items.length - 1
|
||||
} else {
|
||||
const next = history.index + dir
|
||||
if (next < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (next >= history.items.length) {
|
||||
history.index = null
|
||||
area.setText(history.draft)
|
||||
area.cursorOffset = area.plainText.length
|
||||
event.preventDefault()
|
||||
syncRows()
|
||||
return
|
||||
}
|
||||
|
||||
history.index = next
|
||||
}
|
||||
|
||||
const next = history.items[history.index]
|
||||
area.setText(next)
|
||||
area.cursorOffset = dir === -1 ? 0 : area.plainText.length
|
||||
event.preventDefault()
|
||||
syncRows()
|
||||
}
|
||||
|
||||
const handleCycle = (event: Key): boolean => {
|
||||
const plain = toKeyInfo(event, false)
|
||||
|
||||
if (!leader && match(leaders(), plain)) {
|
||||
armLeader()
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
if (leader) {
|
||||
const key = toKeyInfo(event, true)
|
||||
const hit = match(cycles(), key)
|
||||
clearLeader()
|
||||
event.preventDefault()
|
||||
|
||||
if (hit) {
|
||||
props.onCycle()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!match(cycles(), plain)) {
|
||||
return false
|
||||
}
|
||||
|
||||
props.onCycle()
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
const onKeyDown = (event: Key) => {
|
||||
if (event.ctrl && event.name === "c") {
|
||||
const handled = props.onExitRequest ? props.onExitRequest() : (props.onExit(), true)
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (match(interrupts(), toKeyInfo(event, false))) {
|
||||
if (props.onInterrupt()) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (handleCycle(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = toKeyInfo(event, false)
|
||||
const previous = match(historyPrevious(), key)
|
||||
const next = match(historyNext(), key)
|
||||
|
||||
if (!previous && !next) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const dir = previous ? -1 : 1
|
||||
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
|
||||
move(dir, event)
|
||||
return
|
||||
}
|
||||
|
||||
if (dir === -1 && area.visualCursor.visualRow === 0) {
|
||||
area.cursorOffset = 0
|
||||
}
|
||||
|
||||
const last =
|
||||
"height" in area && typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
|
||||
? area.height - 1
|
||||
: Math.max(0, area.virtualLineCount - 1)
|
||||
if (dir === 1 && area.visualCursor.visualRow === last) {
|
||||
area.cursorOffset = area.plainText.length
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = area.plainText.trim()
|
||||
if (!text) {
|
||||
props.onStatus(props.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
||||
return
|
||||
}
|
||||
|
||||
if (isExitCommand(text)) {
|
||||
props.onExit()
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.onSubmit(text)) {
|
||||
return
|
||||
}
|
||||
|
||||
push(text)
|
||||
area.setText("")
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
area.on("line-info-change", scheduleRows)
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearLeader()
|
||||
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
area.off("line-info-change", scheduleRows)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
term().width
|
||||
scheduleRows()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.state().phase
|
||||
if (!area || area.isDestroyed || props.state().phase !== "idle") {
|
||||
return
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
area.focus()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-shell"
|
||||
width="100%"
|
||||
height="100%"
|
||||
border={false}
|
||||
backgroundColor="transparent"
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
padding={0}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-composer-frame"
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
bottomLeft: "╹",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-composer-area"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme().surface}
|
||||
gap={0}
|
||||
>
|
||||
<textarea
|
||||
id="run-direct-footer-composer"
|
||||
width="100%"
|
||||
minHeight={TEXTAREA_MIN_ROWS}
|
||||
maxHeight={TEXTAREA_MAX_ROWS}
|
||||
wrapMode="word"
|
||||
placeholder={placeholder()}
|
||||
placeholderColor={theme().muted}
|
||||
textColor={theme().text}
|
||||
focusedTextColor={theme().text}
|
||||
backgroundColor={theme().surface}
|
||||
focusedBackgroundColor={theme().surface}
|
||||
cursorColor={theme().text}
|
||||
keyBindings={bindings()}
|
||||
onSubmit={onSubmit}
|
||||
onKeyDown={onKeyDown}
|
||||
onContentChange={scheduleRows}
|
||||
ref={(item) => {
|
||||
area = item as Area
|
||||
}}
|
||||
/>
|
||||
|
||||
<box id="run-direct-footer-meta-row" width="100%" flexDirection="row" gap={1} flexShrink={0} paddingTop={1}>
|
||||
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.agent}
|
||||
</text>
|
||||
<text id="run-direct-footer-model" fg={theme().muted} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{props.state().model}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-line-6"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "╹",
|
||||
}}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-line-6-fill"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={theme().line}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
horizontal: "▀",
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-row"
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show when={busy() || exiting()}>
|
||||
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={exiting()}>
|
||||
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
|
||||
Press Ctrl-c again to exit
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<Show when={busy() && !exiting()}>
|
||||
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
|
||||
<text
|
||||
id="run-direct-footer-hint-interrupt"
|
||||
fg={armed() ? theme().highlight : theme().text}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{interruptKey()}{" "}
|
||||
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
|
||||
{armed() ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!busy() && !exiting() && duration().length > 0}>
|
||||
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
|
||||
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
|
||||
▣
|
||||
</text>
|
||||
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
|
||||
·
|
||||
</text>
|
||||
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
|
||||
{duration()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
|
||||
<box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
|
||||
<Show when={queue() > 0}>
|
||||
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
|
||||
{queue()} queued
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={usage().length > 0}>
|
||||
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
|
||||
{usage()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={variant().length > 0 && hints().variant}>
|
||||
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
|
||||
{variant()} variant
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
651
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
651
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
import path from "path"
|
||||
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { TuiConfig } from "../../../config/tui"
|
||||
import { Global } from "../../../global"
|
||||
import { Filesystem } from "../../../util/filesystem"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { RunFooter } from "./footer"
|
||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||
import { formatUnknownError, runPromptTurn } from "./stream"
|
||||
import { resolveRunTheme } from "./theme"
|
||||
import type { FooterApi, FooterKeybinds, RunInput } from "./types"
|
||||
|
||||
const FOOTER_HEIGHT = 6
|
||||
const HISTORY_LIMIT = 200
|
||||
const MODEL_FILE = path.join(Global.Path.state, "model.json")
|
||||
|
||||
const DEFAULT_KEYBINDS: FooterKeybinds = {
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}
|
||||
|
||||
function shutdown(renderer: CliRenderer): void {
|
||||
if (renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (renderer.externalOutputMode === "capture-stdout") {
|
||||
renderer.externalOutputMode = "passthrough"
|
||||
}
|
||||
|
||||
if (renderer.screenMode === "split-footer") {
|
||||
renderer.screenMode = "main-screen"
|
||||
}
|
||||
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function formatModelLabel(model: NonNullable<RunInput["model"]>, variant: string | undefined): string {
|
||||
const variantLabel = variant ? ` · ${variant}` : ""
|
||||
return `${model.modelID} · ${model.providerID}${variantLabel}`
|
||||
}
|
||||
|
||||
function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
|
||||
if (variants.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return variants[0]
|
||||
}
|
||||
|
||||
const index = variants.indexOf(current)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return variants[index + 1]
|
||||
}
|
||||
|
||||
type ModelInfo = {
|
||||
variants: string[]
|
||||
limits: Record<string, number>
|
||||
}
|
||||
|
||||
type SessionMessages = Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]
|
||||
|
||||
type ModelState = {
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
function modelKey(provider: string, model: string): string {
|
||||
return `${provider}/${model}`
|
||||
}
|
||||
|
||||
function variantKey(model: NonNullable<RunInput["model"]>): string {
|
||||
return modelKey(model.providerID, model.modelID)
|
||||
}
|
||||
|
||||
async function resolveModelInfo(sdk: RunInput["sdk"], model: RunInput["model"]): Promise<ModelInfo> {
|
||||
try {
|
||||
const response = await sdk.provider.list()
|
||||
const providers = response.data?.all ?? []
|
||||
const limits: Record<string, number> = {}
|
||||
|
||||
for (const provider of providers) {
|
||||
for (const [modelID, info] of Object.entries(provider.models ?? {})) {
|
||||
const limit = info?.limit?.context
|
||||
if (typeof limit === "number" && limit > 0) {
|
||||
limits[modelKey(provider.id, modelID)] = limit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
variants: [],
|
||||
limits,
|
||||
}
|
||||
}
|
||||
|
||||
const provider = providers.find((item) => item.id === model.providerID)
|
||||
const modelInfo = provider?.models?.[model.modelID]
|
||||
return {
|
||||
variants: Object.keys(modelInfo?.variants ?? {}),
|
||||
limits,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
variants: [],
|
||||
limits: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFirstPrompt(sdk: RunInput["sdk"], sessionID: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await sdk.session.messages({
|
||||
sessionID,
|
||||
limit: 1,
|
||||
})
|
||||
return (response.data ?? []).length === 0
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePromptHistory(sdk: RunInput["sdk"], sessionID: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await sdk.session.messages({
|
||||
sessionID,
|
||||
limit: HISTORY_LIMIT,
|
||||
})
|
||||
const messages = response.data ?? []
|
||||
const history: string[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role !== "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.join("\n")
|
||||
|
||||
if (!text || history[history.length - 1] === text) {
|
||||
continue
|
||||
}
|
||||
|
||||
history.push(text)
|
||||
}
|
||||
|
||||
return history.slice(-HISTORY_LIMIT)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function pickVariant(model: RunInput["model"], messages: SessionMessages): string | undefined {
|
||||
if (!model || !messages || messages.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const info = messages[index]?.info
|
||||
if (!info || info.role !== "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (info.model.providerID !== model.providerID || info.model.modelID !== model.modelID) {
|
||||
continue
|
||||
}
|
||||
|
||||
return info.variant
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function fitVariant(value: string | undefined, variants: string[]): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (variants.length === 0 || variants.includes(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function resolveVariant(
|
||||
input: string | undefined,
|
||||
session: string | undefined,
|
||||
saved: string | undefined,
|
||||
variants: string[],
|
||||
): string | undefined {
|
||||
if (input !== undefined) {
|
||||
return input
|
||||
}
|
||||
|
||||
const fallback = fitVariant(saved, variants)
|
||||
const current = fitVariant(session, variants)
|
||||
if (current !== undefined) {
|
||||
return current
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function resolveStoredVariant(
|
||||
sdk: RunInput["sdk"],
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
): Promise<string | undefined> {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sdk.session.messages({
|
||||
sessionID,
|
||||
limit: HISTORY_LIMIT,
|
||||
})
|
||||
|
||||
return pickVariant(model, response.data)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await Filesystem.readJson<ModelState>(MODEL_FILE)
|
||||
return state.variant?.[variantKey(model)]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function saveVariant(model: RunInput["model"], variant: string | undefined): void {
|
||||
if (!model) {
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const state = await Filesystem.readJson<ModelState>(MODEL_FILE).catch(() => ({}) as ModelState)
|
||||
const map = {
|
||||
...(state.variant ?? {}),
|
||||
}
|
||||
const key = variantKey(model)
|
||||
if (variant) {
|
||||
map[key] = variant
|
||||
}
|
||||
|
||||
if (!variant) {
|
||||
delete map[key]
|
||||
}
|
||||
|
||||
await Filesystem.writeJson(MODEL_FILE, {
|
||||
...state,
|
||||
variant: map,
|
||||
})
|
||||
})().catch(() => {})
|
||||
}
|
||||
|
||||
async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
|
||||
try {
|
||||
const config = await TuiConfig.get()
|
||||
const configuredLeader = config.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader
|
||||
const configuredVariantCycle = config.keybinds?.variant_cycle?.trim() || "ctrl+t"
|
||||
const configuredInterrupt = config.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt
|
||||
const configuredHistoryPrevious = config.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious
|
||||
const configuredHistoryNext = config.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext
|
||||
const configuredSubmit = config.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit
|
||||
const configuredNewline = config.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline
|
||||
|
||||
const variantBindings = configuredVariantCycle
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
|
||||
if (!variantBindings.some((binding) => binding.toLowerCase() === "<leader>t")) {
|
||||
variantBindings.push("<leader>t")
|
||||
}
|
||||
|
||||
return {
|
||||
leader: configuredLeader,
|
||||
variantCycle: variantBindings.join(","),
|
||||
interrupt: configuredInterrupt,
|
||||
historyPrevious: configuredHistoryPrevious,
|
||||
historyNext: configuredHistoryNext,
|
||||
inputSubmit: configuredSubmit,
|
||||
inputNewline: configuredNewline,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_KEYBINDS
|
||||
}
|
||||
}
|
||||
|
||||
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): {
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
} {
|
||||
const agentLabel = Locale.titlecase(input.agent ?? "build")
|
||||
|
||||
if (!input.model) {
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: "Model default",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: formatModelLabel(input.model, input.variant),
|
||||
}
|
||||
}
|
||||
|
||||
type QueueInput = {
|
||||
footer: FooterApi
|
||||
initialInput?: string
|
||||
run: (prompt: string, signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
type SplashState = {
|
||||
entry: boolean
|
||||
exit: boolean
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function queueSplash(
|
||||
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
|
||||
state: SplashState,
|
||||
phase: keyof SplashState,
|
||||
write: ScrollbackWriter | undefined,
|
||||
): boolean {
|
||||
if (state[phase]) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!write) {
|
||||
return false
|
||||
}
|
||||
|
||||
state[phase] = true
|
||||
renderer.writeToScrollback(write)
|
||||
renderer.requestRender()
|
||||
return true
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const q: string[] = []
|
||||
let run = false
|
||||
let closed = input.footer.isClosed
|
||||
let ctrl: AbortController | undefined
|
||||
let stop: (() => void) | undefined
|
||||
let err: unknown
|
||||
let hasErr = false
|
||||
let done: (() => void) | undefined
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
const until = new Promise<void>((resolve) => {
|
||||
stop = resolve
|
||||
})
|
||||
|
||||
const fail = (error: unknown) => {
|
||||
err = error
|
||||
hasErr = true
|
||||
done?.()
|
||||
done = undefined
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (!closed || run) {
|
||||
return
|
||||
}
|
||||
|
||||
done?.()
|
||||
done = undefined
|
||||
}
|
||||
|
||||
const pump = async () => {
|
||||
if (run || closed) {
|
||||
return
|
||||
}
|
||||
|
||||
run = true
|
||||
|
||||
try {
|
||||
while (!closed && q.length > 0) {
|
||||
const prompt = q.shift()
|
||||
if (!prompt) {
|
||||
continue
|
||||
}
|
||||
|
||||
input.footer.patch({
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: q.length,
|
||||
})
|
||||
input.footer.append("user", prompt)
|
||||
const start = Date.now()
|
||||
const next = new AbortController()
|
||||
ctrl = next
|
||||
try {
|
||||
const task = input.run(prompt, next.signal).then(
|
||||
() => ({ type: "done" as const }),
|
||||
(error) => ({ type: "error" as const, error }),
|
||||
)
|
||||
const out = await Promise.race([task, until.then(() => ({ type: "closed" as const }))])
|
||||
if (out.type === "closed") {
|
||||
next.abort()
|
||||
break
|
||||
}
|
||||
|
||||
if (out.type === "error") {
|
||||
throw out.error
|
||||
}
|
||||
} finally {
|
||||
if (ctrl === next) {
|
||||
ctrl = undefined
|
||||
}
|
||||
input.footer.patch({
|
||||
duration: Locale.duration(Math.max(0, Date.now() - start)),
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
run = false
|
||||
input.footer.patch({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: q.length,
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
const push = (text: string) => {
|
||||
const prompt = text
|
||||
if (!prompt.trim() || closed) {
|
||||
return
|
||||
}
|
||||
|
||||
q.push(prompt)
|
||||
input.footer.patch({ queue: q.length })
|
||||
input.footer.patch({ first: false })
|
||||
void pump().catch(fail)
|
||||
}
|
||||
|
||||
const offPrompt = input.footer.onPrompt((text) => {
|
||||
push(text)
|
||||
})
|
||||
const offClose = input.footer.onClose(() => {
|
||||
closed = true
|
||||
q.length = 0
|
||||
ctrl?.abort()
|
||||
stop?.()
|
||||
finish()
|
||||
})
|
||||
|
||||
try {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
push(input.initialInput ?? "")
|
||||
await pump()
|
||||
|
||||
if (!closed) {
|
||||
await wait
|
||||
}
|
||||
|
||||
if (hasErr) {
|
||||
throw err
|
||||
}
|
||||
} finally {
|
||||
offPrompt()
|
||||
offClose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInteractiveMode(input: RunInput): Promise<void> {
|
||||
const [keybinds, info, first, history, storedVariant, savedVariant] = await Promise.all([
|
||||
resolveFooterKeybinds(),
|
||||
resolveModelInfo(input.sdk, input.model),
|
||||
resolveFirstPrompt(input.sdk, input.sessionID),
|
||||
resolvePromptHistory(input.sdk, input.sessionID),
|
||||
resolveStoredVariant(input.sdk, input.sessionID, input.model),
|
||||
resolveSavedVariant(input.model),
|
||||
])
|
||||
const meta = splashMeta({
|
||||
title: input.sessionTitle,
|
||||
session_id: input.sessionID,
|
||||
})
|
||||
const state: SplashState = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
}
|
||||
const variants = info.variants
|
||||
let activeVariant = resolveVariant(input.variant, storedVariant, savedVariant, variants)
|
||||
let aborting = false
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
targetFps: 30,
|
||||
maxFps: 60,
|
||||
useMouse: false,
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
screenMode: "split-footer",
|
||||
footerHeight: FOOTER_HEIGHT,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const theme = await resolveRunTheme(renderer)
|
||||
renderer.setBackgroundColor(theme.background)
|
||||
|
||||
const footer = new RunFooter(renderer, {
|
||||
...footerLabels({
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: activeVariant,
|
||||
}),
|
||||
first,
|
||||
history,
|
||||
theme,
|
||||
keybinds,
|
||||
onCycleVariant: () => {
|
||||
if (!input.model || variants.length === 0) {
|
||||
return {
|
||||
status: "no variants available",
|
||||
}
|
||||
}
|
||||
|
||||
activeVariant = cycleVariant(activeVariant, variants)
|
||||
saveVariant(input.model, activeVariant)
|
||||
return {
|
||||
status: activeVariant ? `variant ${activeVariant}` : "variant default",
|
||||
modelLabel: formatModelLabel(input.model, activeVariant),
|
||||
}
|
||||
},
|
||||
onInterrupt: () => {
|
||||
if (aborting) {
|
||||
return
|
||||
}
|
||||
|
||||
aborting = true
|
||||
void input.sdk.session
|
||||
.abort({
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
aborting = false
|
||||
})
|
||||
},
|
||||
})
|
||||
const sigint = () => {
|
||||
footer.requestExit()
|
||||
}
|
||||
process.on("SIGINT", sigint)
|
||||
|
||||
try {
|
||||
if (!input.resume) {
|
||||
queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"entry",
|
||||
entrySplash({
|
||||
...meta,
|
||||
theme: theme.entry,
|
||||
background: theme.background,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
}
|
||||
|
||||
let includeFiles = true
|
||||
await runPromptQueue({
|
||||
footer,
|
||||
initialInput: input.initialInput,
|
||||
run: async (prompt, signal) => {
|
||||
try {
|
||||
await runPromptTurn({
|
||||
sdk: input.sdk,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
thinking: input.thinking,
|
||||
limits: info.limits,
|
||||
footer,
|
||||
signal,
|
||||
})
|
||||
includeFiles = false
|
||||
} catch (error) {
|
||||
if (signal.aborted || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
footer.append("error", formatUnknownError(error))
|
||||
}
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.off("SIGINT", sigint)
|
||||
|
||||
if (!renderer.isDestroyed) {
|
||||
const hasMessages = !(await resolveFirstPrompt(input.sdk, input.sessionID))
|
||||
if (hasMessages) {
|
||||
queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"exit",
|
||||
exitSplash({
|
||||
...meta,
|
||||
theme: theme.entry,
|
||||
background: theme.background,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
footer.close()
|
||||
footer.destroy()
|
||||
shutdown(renderer)
|
||||
}
|
||||
}
|
||||
164
packages/opencode/src/cli/cmd/run/scrollback.ts
Normal file
164
packages/opencode/src/cli/cmd/run/scrollback.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type ColorInput,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackSnapshot,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { RUN_THEME_FALLBACK, type RunEntryTheme } from "./theme"
|
||||
import type { EntryKind } from "./types"
|
||||
|
||||
type Paint = {
|
||||
fg: ColorInput
|
||||
attributes?: number
|
||||
}
|
||||
|
||||
let id = 0
|
||||
|
||||
function look(kind: EntryKind, theme: RunEntryTheme): Paint {
|
||||
if (kind === "user") {
|
||||
return {
|
||||
fg: theme.user.body,
|
||||
attributes: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "assistant") {
|
||||
return {
|
||||
fg: theme.assistant.body,
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "reasoning") {
|
||||
return {
|
||||
fg: theme.reasoning.body,
|
||||
attributes: TextAttributes.DIM,
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "error") {
|
||||
return {
|
||||
fg: theme.error.body,
|
||||
attributes: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "tool") {
|
||||
return {
|
||||
fg: theme.tool.body,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fg: theme.system.body,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEntry(kind: EntryKind, text: string): string {
|
||||
const raw = text.replace(/\r/g, "")
|
||||
|
||||
if (kind === "user") {
|
||||
if (!raw.trim()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return `› ${raw}`
|
||||
}
|
||||
|
||||
if (kind === "assistant") {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
if (kind === "reasoning") {
|
||||
const body = raw.replace(/\[REDACTED\]/g, "").trim()
|
||||
if (!body) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (body.startsWith("Thinking:")) {
|
||||
return body
|
||||
}
|
||||
|
||||
return `Thinking: ${body}`
|
||||
}
|
||||
|
||||
if (kind === "error") {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
function build(kind: EntryKind, text: string, ctx: ScrollbackRenderContext, theme: RunEntryTheme): ScrollbackSnapshot {
|
||||
const body = normalizeEntry(kind, text)
|
||||
const width = Math.max(1, ctx.width)
|
||||
const style = look(kind, theme)
|
||||
const root = new TextRenderable(ctx.renderContext, {
|
||||
id: `run-direct-entry-${id++}`,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height: 1,
|
||||
content: `${body}\n`,
|
||||
wrapMode: "word",
|
||||
fg: style.fg,
|
||||
attributes: style.attributes,
|
||||
})
|
||||
const height = Math.max(1, root.scrollHeight)
|
||||
root.height = height
|
||||
|
||||
return {
|
||||
root,
|
||||
width,
|
||||
height,
|
||||
rowColumns: width,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBlock(text: string): string {
|
||||
return text.replace(/\r/g, "")
|
||||
}
|
||||
|
||||
function buildBlock(text: string, ctx: ScrollbackRenderContext, theme: RunEntryTheme): ScrollbackSnapshot {
|
||||
const body = normalizeBlock(text)
|
||||
const width = Math.max(1, ctx.width)
|
||||
const content = body.endsWith("\n") ? body : `${body}\n`
|
||||
const root = new TextRenderable(ctx.renderContext, {
|
||||
id: `run-direct-block-${id++}`,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height: 1,
|
||||
content,
|
||||
wrapMode: "word",
|
||||
fg: theme.system.body,
|
||||
})
|
||||
const height = Math.max(1, root.scrollHeight)
|
||||
root.height = height
|
||||
|
||||
return {
|
||||
root,
|
||||
width,
|
||||
height,
|
||||
rowColumns: width,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function entryWriter(
|
||||
kind: EntryKind,
|
||||
text: string,
|
||||
theme: RunEntryTheme = RUN_THEME_FALLBACK.entry,
|
||||
): ScrollbackWriter {
|
||||
return (ctx) => build(kind, text, ctx, theme)
|
||||
}
|
||||
|
||||
export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter {
|
||||
return (ctx) => buildBlock(text, ctx, theme)
|
||||
}
|
||||
330
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
330
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import type { EntryKind } from "./types"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
type Tokens = {
|
||||
input?: number
|
||||
output?: number
|
||||
reasoning?: number
|
||||
cache?: {
|
||||
read?: number
|
||||
write?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionCommit = {
|
||||
kind: EntryKind
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SessionData = {
|
||||
ids: Set<string>
|
||||
tools: Set<string>
|
||||
announced: boolean
|
||||
delta: Map<string, string>
|
||||
}
|
||||
|
||||
export type SessionDataInput = {
|
||||
data: SessionData
|
||||
event: Event
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}
|
||||
|
||||
export type SessionDataOutput = {
|
||||
data: SessionData
|
||||
commits: SessionCommit[]
|
||||
status?: string
|
||||
usage?: string
|
||||
}
|
||||
|
||||
export function createSessionData(): SessionData {
|
||||
return {
|
||||
ids: new Set(),
|
||||
tools: new Set(),
|
||||
announced: false,
|
||||
delta: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function modelKey(provider: string, model: string): string {
|
||||
return `${provider}/${model}`
|
||||
}
|
||||
|
||||
function formatUsage(
|
||||
tokens: Tokens | undefined,
|
||||
limit: number | undefined,
|
||||
cost: number | undefined,
|
||||
): string | undefined {
|
||||
const total =
|
||||
(tokens?.input ?? 0) +
|
||||
(tokens?.output ?? 0) +
|
||||
(tokens?.reasoning ?? 0) +
|
||||
(tokens?.cache?.read ?? 0) +
|
||||
(tokens?.cache?.write ?? 0)
|
||||
|
||||
if (total <= 0) {
|
||||
if (typeof cost === "number" && cost > 0) {
|
||||
return money.format(cost)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const text =
|
||||
limit && limit > 0 ? `${Locale.number(total)} (${Math.round((total / limit) * 100)}%)` : Locale.number(total)
|
||||
|
||||
if (typeof cost === "number" && cost > 0) {
|
||||
return `${text} · ${money.format(cost)}`
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function formatSessionError(error: {
|
||||
name: string
|
||||
data?: {
|
||||
message?: string
|
||||
}
|
||||
}): string {
|
||||
if (error.data?.message) {
|
||||
return String(error.data.message)
|
||||
}
|
||||
|
||||
return String(error.name)
|
||||
}
|
||||
|
||||
function toolStatus(part: ToolPart): string {
|
||||
if (part.tool !== "task") {
|
||||
return `running ${part.tool}`
|
||||
}
|
||||
|
||||
const state = part.state as {
|
||||
input?: {
|
||||
description?: unknown
|
||||
subagent_type?: unknown
|
||||
}
|
||||
}
|
||||
const desc = state.input?.description
|
||||
if (typeof desc === "string" && desc.trim()) {
|
||||
return `running ${desc.trim()}`
|
||||
}
|
||||
|
||||
const type = state.input?.subagent_type
|
||||
if (typeof type === "string" && type.trim()) {
|
||||
return `running ${type.trim()}`
|
||||
}
|
||||
|
||||
return "running task"
|
||||
}
|
||||
|
||||
function deltaKey(partID: string, field: string): string {
|
||||
return `${partID}:${field}`
|
||||
}
|
||||
|
||||
function mergeDelta(data: SessionData, partID: string, text: string): string {
|
||||
const key = deltaKey(partID, "text")
|
||||
const delta = data.delta.get(key)
|
||||
data.delta.delete(key)
|
||||
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
|
||||
return delta ?? text
|
||||
}
|
||||
|
||||
function out(data: SessionData, commits: SessionCommit[], status?: string, usage?: string): SessionDataOutput {
|
||||
const next: SessionDataOutput = {
|
||||
data,
|
||||
commits,
|
||||
}
|
||||
|
||||
if (typeof status === "string") {
|
||||
next.status = status
|
||||
}
|
||||
|
||||
if (typeof usage === "string") {
|
||||
next.usage = usage
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
|
||||
const commits: SessionCommit[] = []
|
||||
const data = input.data
|
||||
const event = input.event
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const info = event.properties.info
|
||||
if (info.role !== "assistant") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const status = data.announced ? undefined : "assistant responding"
|
||||
data.announced = true
|
||||
const usage = formatUsage(
|
||||
info.tokens,
|
||||
input.limits[modelKey(info.providerID, info.modelID)],
|
||||
typeof info.cost === "number" ? info.cost : undefined,
|
||||
)
|
||||
return out(data, commits, status, usage)
|
||||
}
|
||||
|
||||
if (event.type === "message.part.delta") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (
|
||||
typeof event.properties.partID !== "string" ||
|
||||
typeof event.properties.field !== "string" ||
|
||||
typeof event.properties.delta !== "string"
|
||||
) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (event.properties.field !== "text") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (data.ids.has(event.properties.partID)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const key = deltaKey(event.properties.partID, event.properties.field)
|
||||
data.delta.set(key, `${data.delta.get(key) ?? ""}${event.properties.delta}`)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (part.type === "tool" && part.state.status === "running") {
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (data.tools.has(part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.tools.add(part.id)
|
||||
return out(data, commits, toolStatus(part))
|
||||
}
|
||||
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
data.tools.delete(part.id)
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (part.type === "tool" && part.state.status === "error") {
|
||||
data.tools.delete(part.id)
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
const text = `${part.tool}: ${part.state.error}`.trim()
|
||||
if (!text) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
commits.push({
|
||||
kind: "error",
|
||||
text,
|
||||
})
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
if (!part.time?.end) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
const text = mergeDelta(data, part.id, part.text).trim()
|
||||
if (!text) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
commits.push({
|
||||
kind: "assistant",
|
||||
text,
|
||||
})
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
if (!part.time?.end) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
const text = mergeDelta(data, part.id, part.text).trim()
|
||||
if (!input.thinking || !text) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
commits.push({
|
||||
kind: "reasoning",
|
||||
text,
|
||||
})
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
return out(
|
||||
data,
|
||||
commits,
|
||||
`permission requested: ${event.properties.permission} (${event.properties.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
if (event.properties.sessionID !== input.sessionID || !event.properties.error) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
commits.push({
|
||||
kind: "error",
|
||||
text: formatSessionError(event.properties.error),
|
||||
})
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
return out(data, commits)
|
||||
}
|
||||
248
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
248
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
type ColorInput,
|
||||
RGBA,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackSnapshot,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { logo, logoCells } from "../../logo"
|
||||
import type { RunEntryTheme } from "./theme"
|
||||
|
||||
export const SPLASH_TITLE_LIMIT = 50
|
||||
export const SPLASH_TITLE_FALLBACK = "Untitled session"
|
||||
|
||||
type SplashInput = {
|
||||
title: string | undefined
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type SplashWriterInput = SplashInput & {
|
||||
theme: RunEntryTheme
|
||||
background: ColorInput
|
||||
}
|
||||
|
||||
export type SplashMeta = {
|
||||
title: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
let id = 0
|
||||
|
||||
function title(text: string | undefined): string {
|
||||
if (!text) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
return Locale.truncate(text.trim(), SPLASH_TITLE_LIMIT)
|
||||
}
|
||||
|
||||
function write(
|
||||
root: BoxRenderable,
|
||||
ctx: ScrollbackRenderContext,
|
||||
line: {
|
||||
left: number
|
||||
top: number
|
||||
text: string
|
||||
fg: ColorInput
|
||||
bg?: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
): void {
|
||||
if (line.left >= ctx.width) {
|
||||
return
|
||||
}
|
||||
|
||||
root.add(
|
||||
new TextRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-line-${id++}`,
|
||||
position: "absolute",
|
||||
left: line.left,
|
||||
top: line.top,
|
||||
width: Math.max(1, ctx.width - line.left),
|
||||
height: 1,
|
||||
wrapMode: "none",
|
||||
content: line.text,
|
||||
fg: line.fg,
|
||||
bg: line.bg,
|
||||
attributes: line.attrs,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function push(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
left: number,
|
||||
top: number,
|
||||
text: string,
|
||||
fg: ColorInput,
|
||||
bg?: ColorInput,
|
||||
attrs?: number,
|
||||
): void {
|
||||
lines.push({ left, top, text, fg, bg, attrs })
|
||||
}
|
||||
|
||||
function color(input: ColorInput, fallback: RGBA): RGBA {
|
||||
if (input instanceof RGBA) {
|
||||
return input
|
||||
}
|
||||
|
||||
if (typeof input === "string") {
|
||||
if (input === "transparent" || input === "none") {
|
||||
return RGBA.fromValues(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (input.startsWith("#")) {
|
||||
return RGBA.fromHex(input)
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function shade(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
||||
const r = base.r + (overlay.r - base.r) * alpha
|
||||
const g = base.g + (overlay.g - base.g) * alpha
|
||||
const b = base.b + (overlay.b - base.b) * alpha
|
||||
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
|
||||
}
|
||||
|
||||
function draw(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
row: string,
|
||||
input: {
|
||||
left: number
|
||||
top: number
|
||||
fg: ColorInput
|
||||
shadow: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
) {
|
||||
let x = input.left
|
||||
for (const cell of logoCells(row)) {
|
||||
if (cell.mark === "full") {
|
||||
push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.mark === "mix") {
|
||||
push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.mark === "top") {
|
||||
push(lines, x, input.top, cell.char, input.shadow, undefined, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
push(lines, x, input.top, cell.char, input.fg, undefined, input.attrs)
|
||||
x += 1
|
||||
}
|
||||
}
|
||||
|
||||
function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: ScrollbackRenderContext): ScrollbackSnapshot {
|
||||
const width = Math.max(1, ctx.width)
|
||||
const meta = splashMeta(input)
|
||||
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
|
||||
const bg = color(input.background, RGBA.fromValues(0, 0, 0, 0))
|
||||
const left = color(input.theme.system.body, RGBA.fromInts(100, 116, 139))
|
||||
const right = color(input.theme.assistant.body, RGBA.fromInts(248, 250, 252))
|
||||
const leftShadow = shade(bg, left, 0.25)
|
||||
const rightShadow = shade(bg, right, 0.25)
|
||||
let y = 0
|
||||
|
||||
for (let i = 0; i < logo.left.length; i += 1) {
|
||||
const leftText = logo.left[i] ?? ""
|
||||
const rightText = logo.right[i] ?? ""
|
||||
|
||||
draw(lines, leftText, {
|
||||
left: 2,
|
||||
top: y,
|
||||
fg: left,
|
||||
shadow: leftShadow,
|
||||
})
|
||||
draw(lines, rightText, {
|
||||
left: 2 + leftText.length + 1,
|
||||
top: y,
|
||||
fg: right,
|
||||
shadow: rightShadow,
|
||||
attrs: TextAttributes.BOLD,
|
||||
})
|
||||
y += 1
|
||||
}
|
||||
|
||||
y += 1
|
||||
|
||||
const label = "Session".padEnd(10, " ")
|
||||
push(lines, 2, y, label, input.theme.system.body, undefined, TextAttributes.DIM)
|
||||
push(lines, 2 + label.length, y, meta.title, input.theme.assistant.body, undefined, TextAttributes.BOLD)
|
||||
y += 1
|
||||
|
||||
if (kind === "entry") {
|
||||
push(lines, 2, y, "Type /exit or /quit to finish.", input.theme.system.body, undefined, undefined)
|
||||
y += 1
|
||||
}
|
||||
|
||||
if (kind === "exit") {
|
||||
const next = "Continue".padEnd(10, " ")
|
||||
push(lines, 2, y, next, input.theme.system.body, undefined, TextAttributes.DIM)
|
||||
push(
|
||||
lines,
|
||||
2 + next.length,
|
||||
y,
|
||||
`opencode -s ${meta.session_id}`,
|
||||
input.theme.assistant.body,
|
||||
undefined,
|
||||
TextAttributes.BOLD,
|
||||
)
|
||||
y += 1
|
||||
}
|
||||
|
||||
const height = Math.max(1, y + 1)
|
||||
const root = new BoxRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-${kind}-${id++}`,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
for (const line of lines) {
|
||||
write(root, ctx, line)
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
width,
|
||||
height,
|
||||
rowColumns: width,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function splashMeta(input: SplashInput): SplashMeta {
|
||||
return {
|
||||
title: title(input.title),
|
||||
session_id: input.session_id,
|
||||
}
|
||||
}
|
||||
|
||||
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "entry", ctx)
|
||||
}
|
||||
|
||||
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "exit", ctx)
|
||||
}
|
||||
173
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
173
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionData, reduceSessionData } from "./session-data"
|
||||
import type { FooterApi, RunFilePart, RunInput } from "./types"
|
||||
|
||||
type TurnInput = {
|
||||
sdk: OpencodeClient
|
||||
sessionID: string
|
||||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
prompt: string
|
||||
files: RunFilePart[]
|
||||
includeFiles: boolean
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
footer: FooterApi
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export function formatUnknownError(error: unknown): string {
|
||||
if (typeof error === "string") {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name
|
||||
}
|
||||
|
||||
if (error && typeof error === "object") {
|
||||
const candidate = error as { message?: unknown; name?: unknown }
|
||||
if (typeof candidate.message === "string" && candidate.message.trim().length > 0) {
|
||||
return candidate.message
|
||||
}
|
||||
if (typeof candidate.name === "string" && candidate.name.trim().length > 0) {
|
||||
return candidate.name
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
export async function runPromptTurn(input: TurnInput): Promise<void> {
|
||||
if (input.signal?.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const abort = new AbortController()
|
||||
const stop = () => {
|
||||
abort.abort()
|
||||
}
|
||||
|
||||
input.signal?.addEventListener("abort", stop, { once: true })
|
||||
|
||||
let events: Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>>
|
||||
try {
|
||||
events = await input.sdk.event.subscribe(undefined, {
|
||||
signal: abort.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
input.signal?.removeEventListener("abort", stop)
|
||||
throw error
|
||||
}
|
||||
const stream = events.stream as unknown as {
|
||||
return?: (value?: unknown) => Promise<unknown>
|
||||
}
|
||||
const close = () => {
|
||||
if (typeof stream.return === "function") {
|
||||
void stream.return().catch(() => {})
|
||||
}
|
||||
}
|
||||
let data = createSessionData()
|
||||
|
||||
const watch = (async () => {
|
||||
try {
|
||||
for await (const item of events.stream) {
|
||||
if (input.footer.isClosed) {
|
||||
break
|
||||
}
|
||||
|
||||
const event = item as Event
|
||||
const next = reduceSessionData({
|
||||
data,
|
||||
event,
|
||||
sessionID: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits,
|
||||
})
|
||||
data = next.data
|
||||
|
||||
for (const commit of next.commits) {
|
||||
input.footer.append(commit.kind, commit.text)
|
||||
}
|
||||
|
||||
if (next.status) {
|
||||
input.footer.patch({
|
||||
phase: "running",
|
||||
status: next.status,
|
||||
})
|
||||
}
|
||||
|
||||
if (next.usage) {
|
||||
input.footer.patch({
|
||||
usage: next.usage,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "session.status" &&
|
||||
event.properties.sessionID === input.sessionID &&
|
||||
event.properties.status.type === "idle"
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
if (permission.sessionID !== input.sessionID) continue
|
||||
await input.sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await input.sdk.session.prompt(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
parts: [...(input.includeFiles ? input.files : []), { type: "text", text: input.prompt }],
|
||||
},
|
||||
{
|
||||
signal: abort.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (abort.signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!input.footer.isClosed && !data.announced) {
|
||||
input.footer.patch({
|
||||
phase: "running",
|
||||
status: "waiting for assistant",
|
||||
})
|
||||
}
|
||||
|
||||
await watch
|
||||
} catch (error) {
|
||||
const canceled = abort.signal.aborted || input.signal?.aborted === true
|
||||
abort.abort()
|
||||
if (canceled) {
|
||||
close()
|
||||
void watch.catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
await watch.catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
close()
|
||||
input.signal?.removeEventListener("abort", stop)
|
||||
abort.abort()
|
||||
}
|
||||
}
|
||||
144
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
144
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { RGBA, type CliRenderer, type ColorInput } from "@opentui/core"
|
||||
import type { EntryKind } from "./types"
|
||||
|
||||
type Tone = {
|
||||
body: ColorInput
|
||||
}
|
||||
|
||||
export type RunEntryTheme = Record<EntryKind, Tone>
|
||||
|
||||
export type RunFooterTheme = {
|
||||
highlight: ColorInput
|
||||
muted: ColorInput
|
||||
text: ColorInput
|
||||
surface: ColorInput
|
||||
line: ColorInput
|
||||
}
|
||||
|
||||
export type RunTheme = {
|
||||
background: ColorInput
|
||||
footer: RunFooterTheme
|
||||
entry: RunEntryTheme
|
||||
}
|
||||
|
||||
type Resolved = {
|
||||
background: RGBA
|
||||
backgroundElement: RGBA
|
||||
primary: RGBA
|
||||
warning: RGBA
|
||||
error: RGBA
|
||||
text: RGBA
|
||||
textMuted: RGBA
|
||||
}
|
||||
|
||||
function alpha(color: RGBA, value: number): RGBA {
|
||||
const a = Math.max(0, Math.min(1, value))
|
||||
return RGBA.fromValues(color.r, color.g, color.b, a)
|
||||
}
|
||||
|
||||
function rgba(hex: string, value?: number): RGBA {
|
||||
const color = RGBA.fromHex(hex)
|
||||
if (value === undefined) {
|
||||
return color
|
||||
}
|
||||
|
||||
return alpha(color, value)
|
||||
}
|
||||
|
||||
function mode(bg: RGBA): "dark" | "light" {
|
||||
const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
|
||||
if (lum > 0.5) {
|
||||
return "light"
|
||||
}
|
||||
|
||||
return "dark"
|
||||
}
|
||||
|
||||
function map(theme: Resolved): RunTheme {
|
||||
const pane = theme.backgroundElement
|
||||
const surface = alpha(pane, pane.a === 0 ? 0.18 : Math.min(0.9, pane.a * 0.88))
|
||||
const line = alpha(pane, pane.a === 0 ? 0.24 : Math.min(0.98, pane.a * 0.96))
|
||||
|
||||
return {
|
||||
background: theme.background,
|
||||
footer: {
|
||||
highlight: theme.primary,
|
||||
muted: theme.textMuted,
|
||||
text: theme.text,
|
||||
surface,
|
||||
line,
|
||||
},
|
||||
entry: {
|
||||
system: {
|
||||
body: theme.textMuted,
|
||||
},
|
||||
user: {
|
||||
body: theme.primary,
|
||||
},
|
||||
assistant: {
|
||||
body: theme.text,
|
||||
},
|
||||
reasoning: {
|
||||
body: theme.textMuted,
|
||||
},
|
||||
tool: {
|
||||
body: theme.warning,
|
||||
},
|
||||
error: {
|
||||
body: theme.error,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const seed = {
|
||||
highlight: rgba("#38bdf8"),
|
||||
muted: rgba("#64748b"),
|
||||
text: rgba("#f8fafc"),
|
||||
panel: rgba("#0f172a"),
|
||||
warning: rgba("#f59e0b"),
|
||||
error: rgba("#ef4444"),
|
||||
}
|
||||
|
||||
function tone(body: ColorInput): Tone {
|
||||
return {
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
export const RUN_THEME_FALLBACK: RunTheme = {
|
||||
background: RGBA.fromValues(0, 0, 0, 0),
|
||||
footer: {
|
||||
highlight: seed.highlight,
|
||||
muted: seed.muted,
|
||||
text: seed.text,
|
||||
surface: alpha(seed.panel, 0.86),
|
||||
line: alpha(seed.panel, 0.96),
|
||||
},
|
||||
entry: {
|
||||
system: tone(seed.muted),
|
||||
user: tone(seed.highlight),
|
||||
assistant: tone(seed.text),
|
||||
reasoning: tone(seed.muted),
|
||||
tool: tone(seed.warning),
|
||||
error: tone(seed.error),
|
||||
},
|
||||
}
|
||||
|
||||
export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
|
||||
try {
|
||||
const colors = await renderer.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
const bg = colors.defaultBackground ?? colors.palette[0]
|
||||
if (!bg) {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
|
||||
const mod = await import("../tui/context/theme")
|
||||
return map(mod.resolveTheme(mod.generateSystem(colors, pick), pick) as Resolved)
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
}
|
||||
61
packages/opencode/src/cli/cmd/run/types.ts
Normal file
61
packages/opencode/src/cli/cmd/run/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
|
||||
export type RunInput = {
|
||||
sdk: OpencodeClient
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
resume?: boolean
|
||||
agent: string | undefined
|
||||
model: PromptModel | undefined
|
||||
variant: string | undefined
|
||||
files: RunFilePart[]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
}
|
||||
|
||||
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
|
||||
|
||||
export type FooterPhase = "idle" | "running"
|
||||
|
||||
export type FooterState = {
|
||||
phase: FooterPhase
|
||||
status: string
|
||||
queue: number
|
||||
model: string
|
||||
duration: string
|
||||
usage: string
|
||||
first: boolean
|
||||
interrupt: number
|
||||
exit: number
|
||||
}
|
||||
|
||||
export type FooterPatch = Partial<FooterState>
|
||||
|
||||
export type FooterKeybinds = {
|
||||
leader: string
|
||||
variantCycle: string
|
||||
interrupt: string
|
||||
historyPrevious: string
|
||||
historyNext: string
|
||||
inputSubmit: string
|
||||
inputNewline: string
|
||||
}
|
||||
|
||||
export type FooterApi = {
|
||||
readonly isClosed: boolean
|
||||
onPrompt(fn: (text: string) => void): () => void
|
||||
onClose(fn: () => void): () => void
|
||||
patch(next: FooterPatch): void
|
||||
append(kind: EntryKind, text: string): void
|
||||
close(): void
|
||||
destroy(): void
|
||||
}
|
||||
@@ -251,6 +251,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
|
||||
@@ -509,7 +509,9 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
||||
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
|
||||
}
|
||||
|
||||
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
// TODO: i exported this, just for keeping it simple for now, but this should
|
||||
// probably go into something shared if we decide to use this in opencode run
|
||||
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
|
||||
|
||||
@@ -4,3 +4,44 @@ export const logo = {
|
||||
}
|
||||
|
||||
export const marks = "_^~"
|
||||
|
||||
export type LogoCell = {
|
||||
char: string
|
||||
mark: "text" | "full" | "mix" | "top"
|
||||
}
|
||||
|
||||
export function logoCells(line: string): LogoCell[] {
|
||||
const cells: LogoCell[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
cells.push({
|
||||
char: " ",
|
||||
mark: "full",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "^") {
|
||||
cells.push({
|
||||
char: "▀",
|
||||
mark: "mix",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "~") {
|
||||
cells.push({
|
||||
char: "▀",
|
||||
mark: "top",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
cells.push({
|
||||
char,
|
||||
mark: "text",
|
||||
})
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
@@ -436,13 +436,13 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: SessionSummary.diff.schema.shape.sessionID,
|
||||
sessionID: SessionSummary.DiffInput.shape.sessionID,
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
messageID: SessionSummary.diff.schema.shape.messageID,
|
||||
messageID: SessionSummary.DiffInput.shape.messageID,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
|
||||
@@ -63,7 +63,13 @@ export namespace SessionCompaction {
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
Bus.Service | Config.Service | Session.Service | Agent.Service | Plugin.Service | SessionProcessor.Service
|
||||
| Bus.Service
|
||||
| Config.Service
|
||||
| Session.Service
|
||||
| Agent.Service
|
||||
| Plugin.Service
|
||||
| SessionProcessor.Service
|
||||
| Provider.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -73,6 +79,7 @@ export namespace SessionCompaction {
|
||||
const agents = yield* Agent.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
@@ -170,11 +177,9 @@ export namespace SessionCompaction {
|
||||
}
|
||||
|
||||
const agent = yield* agents.get("compaction")
|
||||
const model = yield* Effect.promise(() =>
|
||||
agent.model
|
||||
? Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
: Provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
|
||||
)
|
||||
const model = agent.model
|
||||
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
|
||||
// Allow plugins to inject context or replace compaction prompt.
|
||||
const compacting = yield* plugin.trigger(
|
||||
"experimental.session.compacting",
|
||||
@@ -377,6 +382,7 @@ When constructing the summary, try to stick to this template:
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
|
||||
@@ -294,12 +294,10 @@ export namespace SessionProcessor {
|
||||
}
|
||||
ctx.snapshot = undefined
|
||||
}
|
||||
yield* Effect.promise(() =>
|
||||
SessionSummary.summarize({
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.assistantMessage.parentID,
|
||||
}),
|
||||
).pipe(Effect.ignoreCause({ log: true, message: "session summary failed" }), Effect.forkDetach)
|
||||
SessionSummary.summarize({
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.assistantMessage.parentID,
|
||||
})
|
||||
if (
|
||||
!ctx.assistantMessage.summary &&
|
||||
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
|
||||
|
||||
@@ -84,6 +84,7 @@ export namespace SessionPrompt {
|
||||
const status = yield* SessionStatus.Service
|
||||
const sessions = yield* Session.Service
|
||||
const agents = yield* Agent.Service
|
||||
const provider = yield* Provider.Service
|
||||
const processor = yield* SessionProcessor.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
@@ -206,14 +207,14 @@ export namespace SessionPrompt {
|
||||
|
||||
const ag = yield* agents.get("title")
|
||||
if (!ag) return
|
||||
const mdl = ag.model
|
||||
? yield* provider.getModel(ag.model.providerID, ag.model.modelID)
|
||||
: ((yield* provider.getSmallModel(input.providerID)) ??
|
||||
(yield* provider.getModel(input.providerID, input.modelID)))
|
||||
const msgs = onlySubtasks
|
||||
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||
: yield* Effect.promise(() => MessageV2.toModelMessages(context, mdl))
|
||||
const text = yield* Effect.promise(async (signal) => {
|
||||
const mdl = ag.model
|
||||
? await Provider.getModel(ag.model.providerID, ag.model.modelID)
|
||||
: ((await Provider.getSmallModel(input.providerID)) ??
|
||||
(await Provider.getModel(input.providerID, input.modelID)))
|
||||
const msgs = onlySubtasks
|
||||
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||
: await MessageV2.toModelMessages(context, mdl)
|
||||
const result = await LLM.stream({
|
||||
agent: ag,
|
||||
user: firstInfo,
|
||||
@@ -932,21 +933,35 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return { info: msg, parts: [part] }
|
||||
})
|
||||
|
||||
const getModel = (providerID: ProviderID, modelID: ModelID, sessionID: SessionID) =>
|
||||
Effect.promise(() =>
|
||||
Provider.getModel(providerID, modelID).catch((e) => {
|
||||
if (Provider.ModelNotFoundError.isInstance(e)) {
|
||||
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({
|
||||
message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`,
|
||||
}).toObject(),
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}),
|
||||
)
|
||||
const getModel = Effect.fn("SessionPrompt.getModel")(function* (
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
sessionID: SessionID,
|
||||
) {
|
||||
const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) return exit.value
|
||||
const err = Cause.squash(exit.cause)
|
||||
if (Provider.ModelNotFoundError.isInstance(err)) {
|
||||
const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : ""
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({
|
||||
message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`,
|
||||
}).toObject(),
|
||||
})
|
||||
}
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
})
|
||||
|
||||
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const model = yield* Effect.promise(async () => {
|
||||
for await (const item of MessageV2.stream(sessionID)) {
|
||||
if (item.info.role === "user" && item.info.model) return item.info.model
|
||||
}
|
||||
})
|
||||
if (model) return model
|
||||
return yield* provider.defaultModel()
|
||||
})
|
||||
|
||||
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
|
||||
const agentName = input.agent || (yield* agents.defaultAgent())
|
||||
@@ -960,9 +975,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID))
|
||||
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
|
||||
const full =
|
||||
!input.variant && ag.variant
|
||||
? yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID).catch(() => undefined))
|
||||
!input.variant && ag.variant && same
|
||||
? yield* provider
|
||||
.getModel(model.providerID, model.modelID)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
|
||||
|
||||
@@ -1109,7 +1127,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
]
|
||||
const read = yield* Effect.promise(() => ReadTool.init()).pipe(
|
||||
Effect.flatMap((t) =>
|
||||
Effect.promise(() => Provider.getModel(info.model.providerID, info.model.modelID)).pipe(
|
||||
provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
||||
Effect.flatMap((mdl) =>
|
||||
Effect.promise(() =>
|
||||
t.execute(args, {
|
||||
@@ -1711,6 +1729,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Truncate.layer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
@@ -1856,15 +1875,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return runPromise((svc) => svc.command(CommandInput.parse(input)))
|
||||
}
|
||||
|
||||
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
return yield* Effect.promise(async () => {
|
||||
for await (const item of MessageV2.stream(sessionID)) {
|
||||
if (item.info.role === "user" && item.info.model) return item.info.model
|
||||
}
|
||||
return Provider.defaultModel()
|
||||
})
|
||||
})
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function createStructuredOutputTool(input: {
|
||||
schema: Record<string, any>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import z from "zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Session } from "."
|
||||
import { Log } from "../util/log"
|
||||
import { SyncEvent } from "../sync"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Bus } from "../bus"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { SyncEvent } from "../sync"
|
||||
import { Log } from "../util/log"
|
||||
import { Session } from "."
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { SessionPrompt } from "./prompt"
|
||||
import { SessionSummary } from "./summary"
|
||||
|
||||
@@ -20,116 +22,152 @@ export namespace SessionRevert {
|
||||
})
|
||||
export type RevertInput = z.infer<typeof RevertInput>
|
||||
|
||||
export async function revert(input: RevertInput) {
|
||||
await SessionPrompt.assertNotBusy(input.sessionID)
|
||||
const all = await Session.messages({ sessionID: input.sessionID })
|
||||
let lastUser: MessageV2.User | undefined
|
||||
const session = await Session.get(input.sessionID)
|
||||
export interface Interface {
|
||||
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
|
||||
readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
|
||||
readonly cleanup: (session: Session.Info) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
let revert: Session.Info["revert"]
|
||||
const patches: Snapshot.Patch[] = []
|
||||
for (const msg of all) {
|
||||
if (msg.info.role === "user") lastUser = msg.info
|
||||
const remaining = []
|
||||
for (const part of msg.parts) {
|
||||
if (revert) {
|
||||
if (part.type === "patch") {
|
||||
patches.push(part)
|
||||
}
|
||||
continue
|
||||
}
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionRevert") {}
|
||||
|
||||
if (!revert) {
|
||||
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
||||
// if no useful parts left in message, same as reverting whole message
|
||||
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
||||
revert = {
|
||||
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
||||
partID,
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const snap = yield* Snapshot.Service
|
||||
const storage = yield* Storage.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
||||
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
|
||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||
let lastUser: MessageV2.User | undefined
|
||||
const session = yield* sessions.get(input.sessionID)
|
||||
|
||||
let rev: Session.Info["revert"]
|
||||
const patches: Snapshot.Patch[] = []
|
||||
for (const msg of all) {
|
||||
if (msg.info.role === "user") lastUser = msg.info
|
||||
const remaining = []
|
||||
for (const part of msg.parts) {
|
||||
if (rev) {
|
||||
if (part.type === "patch") patches.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!rev) {
|
||||
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
||||
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
||||
rev = {
|
||||
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
||||
partID,
|
||||
}
|
||||
}
|
||||
remaining.push(part)
|
||||
}
|
||||
}
|
||||
remaining.push(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (revert) {
|
||||
const session = await Session.get(input.sessionID)
|
||||
revert.snapshot = session.revert?.snapshot ?? (await Snapshot.track())
|
||||
await Snapshot.revert(patches)
|
||||
if (revert.snapshot) revert.diff = await Snapshot.diff(revert.snapshot)
|
||||
const rangeMessages = all.filter((msg) => msg.info.id >= revert!.messageID)
|
||||
const diffs = await SessionSummary.computeDiff({ messages: rangeMessages })
|
||||
await Storage.write(["session_diff", input.sessionID], diffs)
|
||||
Bus.publish(Session.Event.Diff, {
|
||||
sessionID: input.sessionID,
|
||||
diff: diffs,
|
||||
if (!rev) return session
|
||||
|
||||
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
|
||||
yield* snap.revert(patches)
|
||||
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
|
||||
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
|
||||
const diffs = yield* Effect.promise(() => SessionSummary.computeDiff({ messages: range }))
|
||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||
yield* sessions.setRevert({
|
||||
sessionID: input.sessionID,
|
||||
revert: rev,
|
||||
summary: {
|
||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||
files: diffs.length,
|
||||
},
|
||||
})
|
||||
return yield* sessions.get(input.sessionID)
|
||||
})
|
||||
return Session.setRevert({
|
||||
sessionID: input.sessionID,
|
||||
revert,
|
||||
summary: {
|
||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||
files: diffs.length,
|
||||
},
|
||||
|
||||
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
||||
log.info("unreverting", input)
|
||||
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
|
||||
const session = yield* sessions.get(input.sessionID)
|
||||
if (!session.revert) return session
|
||||
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
|
||||
yield* sessions.clearRevert(input.sessionID)
|
||||
return yield* sessions.get(input.sessionID)
|
||||
})
|
||||
}
|
||||
return session
|
||||
|
||||
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
|
||||
if (!session.revert) return
|
||||
const sessionID = session.id
|
||||
const msgs = yield* sessions.messages({ sessionID })
|
||||
const messageID = session.revert.messageID
|
||||
const remove = [] as MessageV2.WithParts[]
|
||||
let target: MessageV2.WithParts | undefined
|
||||
for (const msg of msgs) {
|
||||
if (msg.info.id < messageID) continue
|
||||
if (msg.info.id > messageID) {
|
||||
remove.push(msg)
|
||||
continue
|
||||
}
|
||||
if (session.revert.partID) {
|
||||
target = msg
|
||||
continue
|
||||
}
|
||||
remove.push(msg)
|
||||
}
|
||||
for (const msg of remove) {
|
||||
SyncEvent.run(MessageV2.Event.Removed, {
|
||||
sessionID,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
}
|
||||
if (session.revert.partID && target) {
|
||||
const partID = session.revert.partID
|
||||
const idx = target.parts.findIndex((part) => part.id === partID)
|
||||
if (idx >= 0) {
|
||||
const removeParts = target.parts.slice(idx)
|
||||
target.parts = target.parts.slice(0, idx)
|
||||
for (const part of removeParts) {
|
||||
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||
sessionID,
|
||||
messageID: target.info.id,
|
||||
partID: part.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* sessions.clearRevert(sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ revert, unrevert, cleanup })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function revert(input: RevertInput) {
|
||||
return runPromise((svc) => svc.revert(input))
|
||||
}
|
||||
|
||||
export async function unrevert(input: { sessionID: SessionID }) {
|
||||
log.info("unreverting", input)
|
||||
await SessionPrompt.assertNotBusy(input.sessionID)
|
||||
const session = await Session.get(input.sessionID)
|
||||
if (!session.revert) return session
|
||||
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
|
||||
return Session.clearRevert(input.sessionID)
|
||||
return runPromise((svc) => svc.unrevert(input))
|
||||
}
|
||||
|
||||
export async function cleanup(session: Session.Info) {
|
||||
if (!session.revert) return
|
||||
const sessionID = session.id
|
||||
const msgs = await Session.messages({ sessionID })
|
||||
const messageID = session.revert.messageID
|
||||
const remove = [] as MessageV2.WithParts[]
|
||||
let target: MessageV2.WithParts | undefined
|
||||
for (const msg of msgs) {
|
||||
if (msg.info.id < messageID) {
|
||||
continue
|
||||
}
|
||||
if (msg.info.id > messageID) {
|
||||
remove.push(msg)
|
||||
continue
|
||||
}
|
||||
if (session.revert.partID) {
|
||||
target = msg
|
||||
continue
|
||||
}
|
||||
remove.push(msg)
|
||||
}
|
||||
for (const msg of remove) {
|
||||
SyncEvent.run(MessageV2.Event.Removed, {
|
||||
sessionID: sessionID,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
}
|
||||
if (session.revert.partID && target) {
|
||||
const partID = session.revert.partID
|
||||
const removeStart = target.parts.findIndex((part) => part.id === partID)
|
||||
if (removeStart >= 0) {
|
||||
const preserveParts = target.parts.slice(0, removeStart)
|
||||
const removeParts = target.parts.slice(removeStart)
|
||||
target.parts = preserveParts
|
||||
for (const part of removeParts) {
|
||||
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||
sessionID: sessionID,
|
||||
messageID: target.info.id,
|
||||
partID: part.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
await Session.clearRevert(sessionID)
|
||||
return runPromise((svc) => svc.cleanup(session))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { fn } from "@/util/fn"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Bus } from "@/bus"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Session } from "."
|
||||
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionID, MessageID } from "./schema"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Bus } from "@/bus"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
|
||||
export namespace SessionSummary {
|
||||
function unquoteGitPath(input: string) {
|
||||
@@ -67,103 +65,117 @@ export namespace SessionSummary {
|
||||
return Buffer.from(bytes).toString()
|
||||
}
|
||||
|
||||
export const summarize = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
async (input) => {
|
||||
await Session.messages({ sessionID: input.sessionID })
|
||||
.then((all) =>
|
||||
Promise.all([
|
||||
summarizeSession({ sessionID: input.sessionID, messages: all }),
|
||||
summarizeMessage({ messageID: input.messageID, messages: all }),
|
||||
]),
|
||||
)
|
||||
.catch((err) => {
|
||||
if (NotFoundError.isInstance(err)) return
|
||||
throw err
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
async function summarizeSession(input: { sessionID: SessionID; messages: MessageV2.WithParts[] }) {
|
||||
const diffs = await computeDiff({ messages: input.messages })
|
||||
await Session.setSummary({
|
||||
sessionID: input.sessionID,
|
||||
summary: {
|
||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||
files: diffs.length,
|
||||
},
|
||||
})
|
||||
await Storage.write(["session_diff", input.sessionID], diffs)
|
||||
Bus.publish(Session.Event.Diff, {
|
||||
sessionID: input.sessionID,
|
||||
diff: diffs,
|
||||
})
|
||||
export interface Interface {
|
||||
readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void>
|
||||
readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
}
|
||||
|
||||
async function summarizeMessage(input: { messageID: string; messages: MessageV2.WithParts[] }) {
|
||||
const messages = input.messages.filter(
|
||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
||||
)
|
||||
const msgWithParts = messages.find((m) => m.info.id === input.messageID)
|
||||
if (!msgWithParts || msgWithParts.info.role !== "user") return
|
||||
const userMsg = msgWithParts.info
|
||||
const diffs = await computeDiff({ messages })
|
||||
userMsg.summary = {
|
||||
...userMsg.summary,
|
||||
diffs,
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
}
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionSummary") {}
|
||||
|
||||
export const diff = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
|
||||
const next = diffs.map((item) => {
|
||||
const file = unquoteGitPath(item.file)
|
||||
if (file === item.file) return item
|
||||
return {
|
||||
...item,
|
||||
file,
|
||||
}
|
||||
})
|
||||
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
|
||||
if (changed) Storage.write(["session_diff", input.sessionID], next).catch(() => {})
|
||||
return next
|
||||
},
|
||||
)
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const storage = yield* Storage.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
export async function computeDiff(input: { messages: MessageV2.WithParts[] }) {
|
||||
let from: string | undefined
|
||||
let to: string | undefined
|
||||
|
||||
// scan assistant messages to find earliest from and latest to
|
||||
// snapshot
|
||||
for (const item of input.messages) {
|
||||
if (!from) {
|
||||
for (const part of item.parts) {
|
||||
if (part.type === "step-start" && part.snapshot) {
|
||||
from = part.snapshot
|
||||
break
|
||||
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
|
||||
messages: MessageV2.WithParts[]
|
||||
}) {
|
||||
let from: string | undefined
|
||||
let to: string | undefined
|
||||
for (const item of input.messages) {
|
||||
if (!from) {
|
||||
for (const part of item.parts) {
|
||||
if (part.type === "step-start" && part.snapshot) {
|
||||
from = part.snapshot
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const part of item.parts) {
|
||||
if (part.type === "step-finish" && part.snapshot) to = part.snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
if (from && to) return yield* snapshot.diffFull(from, to)
|
||||
return []
|
||||
})
|
||||
|
||||
for (const part of item.parts) {
|
||||
if (part.type === "step-finish" && part.snapshot) {
|
||||
to = part.snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
}) {
|
||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||
if (!all.length) return
|
||||
|
||||
if (from && to) return Snapshot.diffFull(from, to)
|
||||
return []
|
||||
const diffs = yield* computeDiff({ messages: all })
|
||||
yield* sessions.setSummary({
|
||||
sessionID: input.sessionID,
|
||||
summary: {
|
||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||
files: diffs.length,
|
||||
},
|
||||
})
|
||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||
|
||||
const messages = all.filter(
|
||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
||||
)
|
||||
const target = messages.find((m) => m.info.id === input.messageID)
|
||||
if (!target || target.info.role !== "user") return
|
||||
const msgDiffs = yield* computeDiff({ messages })
|
||||
target.info.summary = { ...target.info.summary, diffs: msgDiffs }
|
||||
yield* sessions.updateMessage(target.info)
|
||||
})
|
||||
|
||||
const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
||||
const diffs = yield* storage
|
||||
.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
|
||||
const next = diffs.map((item) => {
|
||||
const file = unquoteGitPath(item.file)
|
||||
if (file === item.file) return item
|
||||
return { ...item, file }
|
||||
})
|
||||
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
|
||||
if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore)
|
||||
return next
|
||||
})
|
||||
|
||||
return Service.of({ summarize, diff, computeDiff })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const summarize = (input: { sessionID: SessionID; messageID: MessageID }) =>
|
||||
void runPromise((svc) => svc.summarize(input)).catch(() => {})
|
||||
|
||||
export const DiffInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
})
|
||||
|
||||
export async function diff(input: z.infer<typeof DiffInput>) {
|
||||
return runPromise((svc) => svc.diff(input))
|
||||
}
|
||||
|
||||
export async function computeDiff(input: { messages: MessageV2.WithParts[] }) {
|
||||
return runPromise((svc) => svc.computeDiff(input))
|
||||
}
|
||||
}
|
||||
|
||||
121
packages/opencode/test/cli/run/direct-footer.test.ts
Normal file
121
packages/opencode/test/cli/run/direct-footer.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { RunFooter } from "../../../src/cli/cmd/run/footer"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
|
||||
async function create() {
|
||||
const setup = await testRender(() => null, {
|
||||
width: 100,
|
||||
height: 20,
|
||||
})
|
||||
|
||||
setup.renderer.screenMode = "split-footer"
|
||||
setup.renderer.footerHeight = 6
|
||||
|
||||
let interrupts = 0
|
||||
let exits = 0
|
||||
|
||||
const footer = new RunFooter(setup.renderer as any, {
|
||||
agentLabel: "Build",
|
||||
modelLabel: "Model default",
|
||||
first: false,
|
||||
theme: RUN_THEME_FALLBACK,
|
||||
keybinds: {
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
},
|
||||
onInterrupt: () => {
|
||||
interrupts += 1
|
||||
},
|
||||
onExit: () => {
|
||||
exits += 1
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
setup,
|
||||
footer,
|
||||
interrupts: () => interrupts,
|
||||
exits: () => exits,
|
||||
destroy() {
|
||||
footer.destroy()
|
||||
setup.renderer.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("run footer", () => {
|
||||
test("interrupt requires running phase", async () => {
|
||||
const ctx = await create()
|
||||
|
||||
try {
|
||||
expect((ctx.footer as any).handleInterrupt()).toBe(false)
|
||||
expect(ctx.interrupts()).toBe(0)
|
||||
} finally {
|
||||
ctx.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("double interrupt triggers callback once", async () => {
|
||||
const ctx = await create()
|
||||
|
||||
try {
|
||||
ctx.footer.patch({ phase: "running" })
|
||||
|
||||
expect((ctx.footer as any).handleInterrupt()).toBe(true)
|
||||
expect((ctx.footer as any).state().interrupt).toBe(1)
|
||||
expect((ctx.footer as any).state().status).toBe("esc again to interrupt")
|
||||
expect(ctx.interrupts()).toBe(0)
|
||||
|
||||
expect((ctx.footer as any).handleInterrupt()).toBe(true)
|
||||
expect((ctx.footer as any).state().interrupt).toBe(0)
|
||||
expect((ctx.footer as any).state().status).toBe("interrupting")
|
||||
expect(ctx.interrupts()).toBe(1)
|
||||
} finally {
|
||||
ctx.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("double exit closes and calls onExit once", async () => {
|
||||
const ctx = await create()
|
||||
|
||||
try {
|
||||
expect(ctx.footer.requestExit()).toBe(true)
|
||||
expect(ctx.footer.isClosed).toBe(false)
|
||||
expect((ctx.footer as any).state().exit).toBe(1)
|
||||
expect((ctx.footer as any).state().status).toBe("Press Ctrl-c again to exit")
|
||||
expect(ctx.exits()).toBe(0)
|
||||
|
||||
expect(ctx.footer.requestExit()).toBe(true)
|
||||
expect(ctx.footer.isClosed).toBe(true)
|
||||
expect((ctx.footer as any).state().exit).toBe(0)
|
||||
expect((ctx.footer as any).state().status).toBe("exiting")
|
||||
expect(ctx.exits()).toBe(1)
|
||||
|
||||
expect(ctx.footer.requestExit()).toBe(true)
|
||||
expect(ctx.exits()).toBe(1)
|
||||
} finally {
|
||||
ctx.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("row sync clamps footer resize range", async () => {
|
||||
const ctx = await create()
|
||||
|
||||
try {
|
||||
const sync = (ctx.footer as any).syncRows as (rows: number) => void
|
||||
expect(ctx.setup.renderer.footerHeight).toBe(6)
|
||||
sync(99)
|
||||
expect(ctx.setup.renderer.footerHeight).toBe(11)
|
||||
sync(-3)
|
||||
expect(ctx.setup.renderer.footerHeight).toBe(6)
|
||||
} finally {
|
||||
ctx.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
436
packages/opencode/test/cli/run/direct-runtime.test.ts
Normal file
436
packages/opencode/test/cli/run/direct-runtime.test.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { pickVariant, queueSplash, resolveVariant, runPromptQueue } from "../../../src/cli/cmd/run/runtime"
|
||||
import type { EntryKind, FooterApi, FooterPatch } from "../../../src/cli/cmd/run/types"
|
||||
|
||||
function createFooter() {
|
||||
const prompts = new Set<(text: string) => void>()
|
||||
const closes = new Set<() => void>()
|
||||
const patched: FooterPatch[] = []
|
||||
const appended: Array<{ kind: EntryKind; text: string }> = []
|
||||
let closed = false
|
||||
|
||||
const close = () => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
for (const fn of [...closes]) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
const footer: FooterApi = {
|
||||
get isClosed() {
|
||||
return closed
|
||||
},
|
||||
onPrompt(fn) {
|
||||
prompts.add(fn)
|
||||
return () => {
|
||||
prompts.delete(fn)
|
||||
}
|
||||
},
|
||||
onClose(fn) {
|
||||
if (closed) {
|
||||
fn()
|
||||
return () => {}
|
||||
}
|
||||
|
||||
closes.add(fn)
|
||||
return () => {
|
||||
closes.delete(fn)
|
||||
}
|
||||
},
|
||||
patch(next) {
|
||||
patched.push(next)
|
||||
},
|
||||
append(kind, text) {
|
||||
appended.push({ kind, text })
|
||||
},
|
||||
close,
|
||||
destroy() {
|
||||
close()
|
||||
prompts.clear()
|
||||
closes.clear()
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
footer,
|
||||
patched,
|
||||
appended,
|
||||
listeners() {
|
||||
return {
|
||||
prompts: prompts.size,
|
||||
closes: closes.size,
|
||||
}
|
||||
},
|
||||
submit(text: string) {
|
||||
for (const fn of [...prompts]) {
|
||||
fn(text)
|
||||
}
|
||||
},
|
||||
close,
|
||||
}
|
||||
}
|
||||
|
||||
describe("run runtime", () => {
|
||||
test("restores variant from latest matching user message", () => {
|
||||
expect(
|
||||
pickVariant(
|
||||
{
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
variant: "high",
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
model: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-3",
|
||||
},
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
variant: "minimal",
|
||||
},
|
||||
},
|
||||
] as unknown as Parameters<typeof pickVariant>[1],
|
||||
),
|
||||
).toBe("minimal")
|
||||
})
|
||||
|
||||
test("respects default variant from latest matching user message", () => {
|
||||
expect(
|
||||
pickVariant(
|
||||
{
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
variant: "high",
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
model: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as Parameters<typeof pickVariant>[1],
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps saved variant when session variant is default", () => {
|
||||
expect(resolveVariant(undefined, undefined, "high", ["high", "minimal"])).toBe("high")
|
||||
})
|
||||
|
||||
test("session variant overrides saved variant", () => {
|
||||
expect(resolveVariant(undefined, "minimal", "high", ["high", "minimal"])).toBe("minimal")
|
||||
})
|
||||
|
||||
test("cli variant overrides session and saved variant", () => {
|
||||
expect(resolveVariant("custom", "minimal", "high", ["high", "minimal"])).toBe("custom")
|
||||
})
|
||||
|
||||
test("queues entry and exit splash only once", () => {
|
||||
const writes: unknown[] = []
|
||||
let renders = 0
|
||||
const renderer = {
|
||||
writeToScrollback(write: unknown) {
|
||||
writes.push(write)
|
||||
},
|
||||
requestRender() {
|
||||
renders += 1
|
||||
},
|
||||
} as any
|
||||
|
||||
const state = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
}
|
||||
|
||||
const write = () => ({}) as any
|
||||
|
||||
expect(queueSplash(renderer, state, "entry", write)).toBe(true)
|
||||
expect(queueSplash(renderer, state, "entry", write)).toBe(false)
|
||||
expect(queueSplash(renderer, state, "exit", write)).toBe(true)
|
||||
expect(queueSplash(renderer, state, "exit", write)).toBe(false)
|
||||
|
||||
expect(writes).toHaveLength(2)
|
||||
expect(renders).toBe(2)
|
||||
})
|
||||
|
||||
test("returns immediately when footer is already closed", async () => {
|
||||
const ui = createFooter()
|
||||
let calls = 0
|
||||
ui.close()
|
||||
|
||||
await runPromptQueue({
|
||||
footer: ui.footer,
|
||||
run: async () => {
|
||||
calls += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toBe(0)
|
||||
expect(ui.listeners()).toEqual({ prompts: 0, closes: 0 })
|
||||
})
|
||||
|
||||
test("close resolves queue and unsubscribes listeners", async () => {
|
||||
const ui = createFooter()
|
||||
|
||||
const queue = runPromptQueue({
|
||||
footer: ui.footer,
|
||||
run: async () => {},
|
||||
})
|
||||
|
||||
expect(ui.listeners()).toEqual({ prompts: 1, closes: 1 })
|
||||
|
||||
ui.close()
|
||||
await queue
|
||||
|
||||
expect(ui.listeners()).toEqual({ prompts: 0, closes: 0 })
|
||||
})
|
||||
|
||||
test("submit while running is queued", async () => {
|
||||
const ui = createFooter()
|
||||
const prompts: string[] = []
|
||||
let resume: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
resume = resolve
|
||||
})
|
||||
|
||||
const queue = runPromptQueue({
|
||||
footer: ui.footer,
|
||||
run: async (prompt) => {
|
||||
prompts.push(prompt)
|
||||
if (prompts.length === 1) {
|
||||
await gate
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.submit("two")
|
||||
|
||||
expect(prompts).toEqual(["one"])
|
||||
expect(ui.patched).toContainEqual({ queue: 1 })
|
||||
|
||||
ui.close()
|
||||
resume?.()
|
||||
await queue
|
||||
})
|
||||
|
||||
test("queued prompts run in order", async () => {
|
||||
const ui = createFooter()
|
||||
const prompts: string[] = []
|
||||
let resume: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
resume = resolve
|
||||
})
|
||||
let done: (() => void) | undefined
|
||||
const seen = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
const queue = runPromptQueue({
|
||||
footer: ui.footer,
|
||||
run: async (prompt) => {
|
||||
prompts.push(prompt)
|
||||
if (prompts.length === 1) {
|
||||
await gate
|
||||
return
|
||||
}
|
||||
|
||||
done?.()
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.submit("two")
|
||||
|
||||
resume?.()
|
||||
await seen
|
||||
|
||||
ui.close()
|
||||
await queue
|
||||
|
||||
expect(prompts).toEqual(["one", "two"])
|
||||
expect(ui.appended).toEqual([
|
||||
{ kind: "user", text: "one" },
|
||||
{ kind: "user", text: "two" },
|
||||
])
|
||||
})
|
||||
|
||||
test("close stops pending queued work", async () => {
|
||||
const ui = createFooter()
|
||||
const prompts: string[] = []
|
||||
let resume: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
resume = resolve
|
||||
})
|
||||
|
||||
const queue = runPromptQueue({
|
||||
footer: ui.footer,
|
||||
run: async (prompt) => {
|
||||
prompts.push(prompt)
|
||||
if (prompts.length === 1) {
|
||||
await gate
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.submit("two")
|
||||
|
||||
ui.close()
|
||||
resume?.()
|
||||
await queue
|
||||
|
||||
expect(prompts).toEqual(["one"])
|
||||
expect(ui.appended).toEqual([{ kind: "user", text: "one" }])
|
||||
expect(ui.patched).toContainEqual({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test("close aborts active run signal", async () => {
|
||||
const ui = createFooter()
|
||||
let hit = false
|
||||
|
||||
const queue = runPromptQueue({
|
||||
footer: ui.footer,
|
||||
run: async (_, signal) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
hit = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
hit = true
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.close()
|
||||
await queue
|
||||
|
||||
expect(hit).toBe(true)
|
||||
})
|
||||
|
||||
test("close resolves even when run ignores abort", async () => {
|
||||
const ui = createFooter()
|
||||
|
||||
const queue = runPromptQueue({
|
||||
footer: ui.footer,
|
||||
run: async () => {
|
||||
await new Promise<void>(() => {})
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.close()
|
||||
|
||||
const result = await Promise.race([
|
||||
queue.then(() => "done" as const),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)),
|
||||
])
|
||||
|
||||
expect(result).toBe("done")
|
||||
})
|
||||
|
||||
test("keeps initial input whitespace", async () => {
|
||||
const ui = createFooter()
|
||||
const prompts: string[] = []
|
||||
|
||||
await runPromptQueue({
|
||||
footer: ui.footer,
|
||||
initialInput: " hello ",
|
||||
run: async (prompt) => {
|
||||
prompts.push(prompt)
|
||||
ui.close()
|
||||
},
|
||||
})
|
||||
|
||||
expect(prompts).toEqual([" hello "])
|
||||
expect(ui.appended).toEqual([{ kind: "user", text: " hello " }])
|
||||
})
|
||||
|
||||
test("records last turn duration", async () => {
|
||||
const ui = createFooter()
|
||||
|
||||
await runPromptQueue({
|
||||
footer: ui.footer,
|
||||
initialInput: "one",
|
||||
run: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
ui.close()
|
||||
},
|
||||
})
|
||||
|
||||
const duration = ui.patched.find((item) => typeof item.duration === "string")?.duration
|
||||
expect(typeof duration).toBe("string")
|
||||
expect(duration?.length ?? 0).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("propagates errors from prompt callbacks", async () => {
|
||||
const ui = createFooter()
|
||||
const queue = runPromptQueue({
|
||||
footer: ui.footer,
|
||||
run: async () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
await expect(queue).rejects.toThrow("boom")
|
||||
})
|
||||
})
|
||||
707
packages/opencode/test/cli/run/direct-stream.test.ts
Normal file
707
packages/opencode/test/cli/run/direct-stream.test.ts
Normal file
@@ -0,0 +1,707 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { runPromptTurn } from "../../../src/cli/cmd/run/stream"
|
||||
|
||||
function eventStream(events: unknown[]) {
|
||||
return {
|
||||
stream: (async function* () {
|
||||
for (const event of events) {
|
||||
yield event
|
||||
}
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("run stream", () => {
|
||||
test("keeps event order and ignores other sessions", async () => {
|
||||
const appended: Array<{ kind: string; text: string }> = []
|
||||
const patched: unknown[] = []
|
||||
const promptCalls: Array<{ payload: unknown; options: unknown }> = []
|
||||
|
||||
const sdk = {
|
||||
event: {
|
||||
subscribe: async () =>
|
||||
eventStream([
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "other",
|
||||
info: {
|
||||
role: "assistant",
|
||||
agent: "other-agent",
|
||||
modelID: "other-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
info: {
|
||||
role: "assistant",
|
||||
agent: "main-agent",
|
||||
modelID: "main-model",
|
||||
providerID: "openai",
|
||||
cost: 2.31,
|
||||
tokens: {
|
||||
input: 42,
|
||||
output: 58,
|
||||
reasoning: 10,
|
||||
cache: {
|
||||
read: 15,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "txt-1",
|
||||
sessionID: "session-1",
|
||||
type: "text",
|
||||
text: "assistant reply",
|
||||
time: { end: Date.now() },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "txt-1",
|
||||
sessionID: "session-1",
|
||||
type: "text",
|
||||
text: "assistant reply",
|
||||
time: { end: Date.now() },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "task-1",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
description: "investigate",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
command: "ls",
|
||||
},
|
||||
output: "file-a\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: {
|
||||
type: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
session: {
|
||||
prompt: async (payload: unknown, options: unknown) => {
|
||||
promptCalls.push({ payload, options })
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
reply: async () => {},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
await runPromptTurn({
|
||||
sdk,
|
||||
sessionID: "session-1",
|
||||
agent: "agent",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: "hello",
|
||||
files: [
|
||||
{
|
||||
type: "file",
|
||||
url: "file:///tmp/a.txt",
|
||||
filename: "a.txt",
|
||||
mime: "text/plain",
|
||||
},
|
||||
],
|
||||
includeFiles: true,
|
||||
thinking: false,
|
||||
limits: {
|
||||
"openai/main-model": 1000,
|
||||
},
|
||||
footer: {
|
||||
isClosed: false,
|
||||
onPrompt() {
|
||||
return () => {}
|
||||
},
|
||||
onClose() {
|
||||
return () => {}
|
||||
},
|
||||
patch(next) {
|
||||
patched.push(next)
|
||||
},
|
||||
append(kind, text) {
|
||||
appended.push({ kind, text })
|
||||
},
|
||||
close() {},
|
||||
destroy() {},
|
||||
},
|
||||
})
|
||||
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect((promptCalls[0]?.payload as { parts: unknown[] }).parts).toHaveLength(2)
|
||||
expect((promptCalls[0]?.payload as { parts: Array<{ type: string }> }).parts[0]?.type).toBe("file")
|
||||
expect((promptCalls[0]?.options as { signal?: AbortSignal }).signal).toBeInstanceOf(AbortSignal)
|
||||
|
||||
expect(patched).toContainEqual({
|
||||
phase: "running",
|
||||
status: "assistant responding",
|
||||
})
|
||||
expect(patched).toContainEqual({
|
||||
phase: "running",
|
||||
status: "running investigate",
|
||||
})
|
||||
expect(patched).toContainEqual({
|
||||
usage: "125 (13%) · $2.31",
|
||||
})
|
||||
expect(appended).toEqual([{ kind: "assistant", text: "assistant reply" }])
|
||||
})
|
||||
|
||||
test("auto rejects permissions and emits session errors", async () => {
|
||||
const appended: Array<{ kind: string; text: string }> = []
|
||||
const patched: unknown[] = []
|
||||
const permissionReplies: unknown[] = []
|
||||
|
||||
const sdk = {
|
||||
event: {
|
||||
subscribe: async () =>
|
||||
eventStream([
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "session-1",
|
||||
permission: "read",
|
||||
patterns: ["/tmp/file.txt"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: "permission denied",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: {
|
||||
type: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
session: {
|
||||
prompt: async () => {},
|
||||
},
|
||||
permission: {
|
||||
reply: async (payload: unknown) => {
|
||||
permissionReplies.push(payload)
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
await runPromptTurn({
|
||||
sdk,
|
||||
sessionID: "session-1",
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: "hello",
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
thinking: false,
|
||||
limits: {},
|
||||
footer: {
|
||||
isClosed: false,
|
||||
onPrompt() {
|
||||
return () => {}
|
||||
},
|
||||
onClose() {
|
||||
return () => {}
|
||||
},
|
||||
patch(next) {
|
||||
patched.push(next)
|
||||
},
|
||||
append(kind, text) {
|
||||
appended.push({ kind, text })
|
||||
},
|
||||
close() {},
|
||||
destroy() {},
|
||||
},
|
||||
})
|
||||
|
||||
expect(permissionReplies).toEqual([
|
||||
{
|
||||
requestID: "perm-1",
|
||||
reply: "reject",
|
||||
},
|
||||
])
|
||||
|
||||
expect(patched).toContainEqual({
|
||||
phase: "running",
|
||||
status: "permission requested: read (/tmp/file.txt); auto-rejecting",
|
||||
})
|
||||
|
||||
expect(appended).toEqual([
|
||||
{
|
||||
kind: "error",
|
||||
text: "permission denied",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps status-only events out of transcript commits", async () => {
|
||||
const appended: Array<{ kind: string; text: string }> = []
|
||||
const patched: unknown[] = []
|
||||
const replies: unknown[] = []
|
||||
|
||||
const sdk = {
|
||||
event: {
|
||||
subscribe: async () =>
|
||||
eventStream([
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
info: {
|
||||
role: "assistant",
|
||||
agent: "main-agent",
|
||||
modelID: "main-model",
|
||||
providerID: "openai",
|
||||
tokens: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "session-1",
|
||||
permission: "read",
|
||||
patterns: ["/tmp/file.txt"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
description: "investigate",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
output: "ok",
|
||||
title: "done",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: {
|
||||
type: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
session: {
|
||||
prompt: async () => {},
|
||||
},
|
||||
permission: {
|
||||
reply: async (payload: unknown) => {
|
||||
replies.push(payload)
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
await runPromptTurn({
|
||||
sdk,
|
||||
sessionID: "session-1",
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: "hello",
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
thinking: false,
|
||||
limits: {},
|
||||
footer: {
|
||||
isClosed: false,
|
||||
onPrompt() {
|
||||
return () => {}
|
||||
},
|
||||
onClose() {
|
||||
return () => {}
|
||||
},
|
||||
patch(next) {
|
||||
patched.push(next)
|
||||
},
|
||||
append(kind, text) {
|
||||
appended.push({ kind, text })
|
||||
},
|
||||
close() {},
|
||||
destroy() {},
|
||||
},
|
||||
})
|
||||
|
||||
expect(replies).toEqual([
|
||||
{
|
||||
requestID: "perm-1",
|
||||
reply: "reject",
|
||||
},
|
||||
])
|
||||
|
||||
expect(patched).toContainEqual({
|
||||
phase: "running",
|
||||
status: "assistant responding",
|
||||
})
|
||||
expect(patched).toContainEqual({
|
||||
phase: "running",
|
||||
status: "permission requested: read (/tmp/file.txt); auto-rejecting",
|
||||
})
|
||||
expect(patched).toContainEqual({
|
||||
phase: "running",
|
||||
status: "running investigate",
|
||||
})
|
||||
expect(appended).toEqual([])
|
||||
})
|
||||
|
||||
test("shows waiting status when assistant never announces", async () => {
|
||||
const patched: unknown[] = []
|
||||
const appended: Array<{ kind: string; text: string }> = []
|
||||
|
||||
const sdk = {
|
||||
event: {
|
||||
subscribe: async () =>
|
||||
eventStream([
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: {
|
||||
type: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
session: {
|
||||
prompt: async () => {},
|
||||
},
|
||||
permission: {
|
||||
reply: async () => {},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
await runPromptTurn({
|
||||
sdk,
|
||||
sessionID: "session-1",
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: "hello",
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
thinking: false,
|
||||
limits: {},
|
||||
footer: {
|
||||
isClosed: false,
|
||||
onPrompt() {
|
||||
return () => {}
|
||||
},
|
||||
onClose() {
|
||||
return () => {}
|
||||
},
|
||||
patch(next) {
|
||||
patched.push(next)
|
||||
},
|
||||
append(kind, text) {
|
||||
appended.push({ kind, text })
|
||||
},
|
||||
close() {},
|
||||
destroy() {},
|
||||
},
|
||||
})
|
||||
|
||||
expect(patched).toContainEqual({
|
||||
phase: "running",
|
||||
status: "waiting for assistant",
|
||||
})
|
||||
expect(appended).toEqual([])
|
||||
})
|
||||
|
||||
test("returns immediately when close signal is already aborted", async () => {
|
||||
let subscribed = 0
|
||||
let prompted = 0
|
||||
|
||||
const sdk = {
|
||||
event: {
|
||||
subscribe: async () => {
|
||||
subscribed += 1
|
||||
return eventStream([])
|
||||
},
|
||||
},
|
||||
session: {
|
||||
prompt: async () => {
|
||||
prompted += 1
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
reply: async () => {},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const ctrl = new AbortController()
|
||||
ctrl.abort()
|
||||
|
||||
await runPromptTurn({
|
||||
sdk,
|
||||
sessionID: "session-1",
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: "hello",
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
thinking: false,
|
||||
limits: {},
|
||||
signal: ctrl.signal,
|
||||
footer: {
|
||||
isClosed: false,
|
||||
onPrompt() {
|
||||
return () => {}
|
||||
},
|
||||
onClose() {
|
||||
return () => {}
|
||||
},
|
||||
patch() {},
|
||||
append() {},
|
||||
close() {},
|
||||
destroy() {},
|
||||
},
|
||||
})
|
||||
|
||||
expect(subscribed).toBe(0)
|
||||
expect(prompted).toBe(0)
|
||||
})
|
||||
|
||||
test("aborts in-flight prompt when close signal fires", async () => {
|
||||
let aborted = false
|
||||
|
||||
const sdk = {
|
||||
event: {
|
||||
subscribe: async (_: unknown, options?: { signal?: AbortSignal }) => ({
|
||||
stream: (async function* () {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (options?.signal?.aborted) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
options?.signal?.addEventListener("abort", () => resolve(), { once: true })
|
||||
})
|
||||
})(),
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
prompt: async (_: unknown, options?: { signal?: AbortSignal }) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (options?.signal?.aborted) {
|
||||
aborted = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
options?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted = true
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
reply: async () => {},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const run = runPromptTurn({
|
||||
sdk,
|
||||
sessionID: "session-1",
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: "hello",
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
thinking: false,
|
||||
limits: {},
|
||||
signal: ctrl.signal,
|
||||
footer: {
|
||||
isClosed: false,
|
||||
onPrompt() {
|
||||
return () => {}
|
||||
},
|
||||
onClose() {
|
||||
return () => {}
|
||||
},
|
||||
patch() {},
|
||||
append() {},
|
||||
close() {},
|
||||
destroy() {},
|
||||
},
|
||||
})
|
||||
|
||||
ctrl.abort()
|
||||
await run
|
||||
|
||||
expect(aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("canceled turn does not wait for stuck event stream", async () => {
|
||||
const sdk = {
|
||||
event: {
|
||||
subscribe: async () => ({
|
||||
stream: (async function* () {
|
||||
await new Promise<void>(() => {})
|
||||
})(),
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
prompt: async (_: unknown, options?: { signal?: AbortSignal }) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (options?.signal?.aborted) {
|
||||
reject(new Error("aborted"))
|
||||
return
|
||||
}
|
||||
|
||||
options?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
reject(new Error("aborted"))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
reply: async () => {},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const run = runPromptTurn({
|
||||
sdk,
|
||||
sessionID: "session-1",
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: "hello",
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
thinking: false,
|
||||
limits: {},
|
||||
signal: ctrl.signal,
|
||||
footer: {
|
||||
isClosed: false,
|
||||
onPrompt() {
|
||||
return () => {}
|
||||
},
|
||||
onClose() {
|
||||
return () => {}
|
||||
},
|
||||
patch() {},
|
||||
append() {},
|
||||
close() {},
|
||||
destroy() {},
|
||||
},
|
||||
})
|
||||
|
||||
ctrl.abort()
|
||||
|
||||
const result = await Promise.race([
|
||||
run.then(() => "done" as const),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)),
|
||||
])
|
||||
|
||||
expect(result).toBe("done")
|
||||
})
|
||||
})
|
||||
662
packages/opencode/test/cli/run/footer-view.test.tsx
Normal file
662
packages/opencode/test/cli/run/footer-view.test.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { RunFooterView, hintFlags } from "../../../src/cli/cmd/run/footer.view"
|
||||
import type { FooterState } from "../../../src/cli/cmd/run/types"
|
||||
|
||||
function get(node: any, id: string): any {
|
||||
if (node.id === id) {
|
||||
return node
|
||||
}
|
||||
|
||||
if (typeof node.getChildren !== "function") {
|
||||
return
|
||||
}
|
||||
|
||||
for (const child of node.getChildren()) {
|
||||
const found = get(child, id)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function composer(setup: Awaited<ReturnType<typeof testRender>>) {
|
||||
const node = get(setup.renderer.root, "run-direct-footer-composer")
|
||||
if (!node) {
|
||||
throw new Error("composer not found")
|
||||
}
|
||||
|
||||
return node as {
|
||||
plainText: string
|
||||
cursorOffset: number
|
||||
}
|
||||
}
|
||||
|
||||
let setup: Awaited<ReturnType<typeof testRender>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (!setup) {
|
||||
return
|
||||
}
|
||||
|
||||
setup.renderer.destroy()
|
||||
setup = undefined
|
||||
})
|
||||
|
||||
describe("run footer view", () => {
|
||||
test("submit key path emits prompts", async () => {
|
||||
const sent: string[] = []
|
||||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "model",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: true,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
agent="Build"
|
||||
onSubmit={(text) => {
|
||||
sent.push(text)
|
||||
return true
|
||||
}}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={(text) => {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
status: text,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 110,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
await setup.mockInput.typeText("hello")
|
||||
setup.mockInput.pressEnter()
|
||||
|
||||
expect(sent).toEqual(["hello"])
|
||||
})
|
||||
|
||||
test("history up down keeps edge behavior", async () => {
|
||||
const sent: string[] = []
|
||||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "model",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: true,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
agent="Build"
|
||||
onSubmit={(text) => {
|
||||
sent.push(text)
|
||||
return true
|
||||
}}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={(text) => {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
status: text,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 110,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
await setup.mockInput.typeText("one")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.mockInput.typeText("two")
|
||||
setup.mockInput.pressEnter()
|
||||
|
||||
const area = composer(setup)
|
||||
|
||||
setup.mockInput.pressArrow("up")
|
||||
expect(area.plainText).toBe("two")
|
||||
expect(area.cursorOffset).toBe(0)
|
||||
|
||||
setup.mockInput.pressArrow("up")
|
||||
expect(area.plainText).toBe("one")
|
||||
expect(area.cursorOffset).toBe(0)
|
||||
|
||||
setup.mockInput.pressArrow("up")
|
||||
expect(area.plainText).toBe("one")
|
||||
expect(area.cursorOffset).toBe(0)
|
||||
|
||||
setup.mockInput.pressArrow("down")
|
||||
expect(area.plainText).toBe("one")
|
||||
expect(area.cursorOffset).toBe(area.plainText.length)
|
||||
|
||||
setup.mockInput.pressArrow("down")
|
||||
expect(area.plainText).toBe("two")
|
||||
expect(area.cursorOffset).toBe(area.plainText.length)
|
||||
|
||||
setup.mockInput.pressArrow("down")
|
||||
expect(area.plainText).toBe("")
|
||||
expect(area.cursorOffset).toBe(0)
|
||||
|
||||
setup.mockInput.pressArrow("down")
|
||||
expect(area.plainText).toBe("")
|
||||
expect(area.cursorOffset).toBe(0)
|
||||
|
||||
expect(sent).toEqual(["one", "two"])
|
||||
})
|
||||
|
||||
test("history includes prior session prompts", async () => {
|
||||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "model",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: false,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
history={["first", "second"]}
|
||||
agent="Build"
|
||||
onSubmit={() => true}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={(text) => {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
status: text,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 110,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
const area = composer(setup)
|
||||
|
||||
setup.mockInput.pressArrow("up")
|
||||
expect(area.plainText).toBe("second")
|
||||
|
||||
setup.mockInput.pressArrow("up")
|
||||
expect(area.plainText).toBe("first")
|
||||
|
||||
setup.mockInput.pressArrow("down")
|
||||
expect(area.plainText).toBe("first")
|
||||
expect(area.cursorOffset).toBe(area.plainText.length)
|
||||
|
||||
setup.mockInput.pressArrow("down")
|
||||
expect(area.plainText).toBe("second")
|
||||
expect(area.cursorOffset).toBe(area.plainText.length)
|
||||
|
||||
setup.mockInput.pressArrow("down")
|
||||
expect(area.plainText).toBe("")
|
||||
expect(area.cursorOffset).toBe(0)
|
||||
})
|
||||
|
||||
test("hint visibility matches width breakpoints", () => {
|
||||
expect(hintFlags(49)).toEqual({
|
||||
send: false,
|
||||
newline: false,
|
||||
history: false,
|
||||
variant: false,
|
||||
})
|
||||
|
||||
expect(hintFlags(50)).toEqual({
|
||||
send: true,
|
||||
newline: false,
|
||||
history: false,
|
||||
variant: false,
|
||||
})
|
||||
|
||||
expect(hintFlags(66)).toEqual({
|
||||
send: true,
|
||||
newline: true,
|
||||
history: false,
|
||||
variant: false,
|
||||
})
|
||||
|
||||
expect(hintFlags(80)).toEqual({
|
||||
send: true,
|
||||
newline: true,
|
||||
history: true,
|
||||
variant: false,
|
||||
})
|
||||
|
||||
expect(hintFlags(95)).toEqual({
|
||||
send: true,
|
||||
newline: true,
|
||||
history: true,
|
||||
variant: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("placeholder switches after first prompt", async () => {
|
||||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "model",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: true,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
agent="Build"
|
||||
onSubmit={() => true}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={() => {}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 120,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain('Ask anything... "Fix a TODO in the codebase"')
|
||||
|
||||
setState((state) => ({
|
||||
...state,
|
||||
first: false,
|
||||
}))
|
||||
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("Ask anything...")
|
||||
expect(setup.captureCharFrame()).not.toContain("Fix a TODO in the codebase")
|
||||
})
|
||||
|
||||
test("baseline scaffold follows 6-line layout", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "gpt-5.3-codex · openai",
|
||||
duration: "1m 18s",
|
||||
usage: "167.8K (42%)",
|
||||
first: true,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
agent="Build"
|
||||
onSubmit={() => true}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={() => {}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 120,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
await setup.renderOnce()
|
||||
const lines = setup.captureCharFrame().split("\n")
|
||||
|
||||
expect(lines[0]).toMatch(/^┃\s*$/)
|
||||
expect(lines[1]?.startsWith("┃")).toBe(true)
|
||||
expect(lines[1]).toContain('Ask anything... "Fix a TODO in the codebase"')
|
||||
expect(lines[2]).toMatch(/^┃\s*$/)
|
||||
expect(lines[3]?.startsWith("┃")).toBe(true)
|
||||
expect(lines[3]).toContain("Build")
|
||||
expect(lines[4]).toMatch(/^╹▀+$/)
|
||||
expect(lines[5]).not.toContain("interrupt")
|
||||
expect(lines[5]).toContain("▣ · 1m 18s")
|
||||
expect(lines[5]).toContain("167.8K (42%)")
|
||||
expect(lines[5]).toContain("ctrl+t variant")
|
||||
})
|
||||
|
||||
test("renders usage and duration fields", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "model",
|
||||
duration: "1m 18s",
|
||||
usage: "167.8K (42%)",
|
||||
first: false,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
agent="Build"
|
||||
onSubmit={() => true}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={() => {}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 120,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
await setup.renderOnce()
|
||||
const frame = setup.captureCharFrame()
|
||||
expect(frame).toContain("▣ · 1m 18s")
|
||||
expect(frame).toContain("167.8K (42%)")
|
||||
})
|
||||
|
||||
test("interrupt hint reflects running escape state", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "running",
|
||||
status: "assistant responding",
|
||||
queue: 0,
|
||||
model: "model",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: false,
|
||||
interrupt: 1,
|
||||
exit: 0,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
agent="Build"
|
||||
onSubmit={() => true}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={() => {}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 120,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("esc again to interrupt")
|
||||
})
|
||||
|
||||
test("duration marker hides when interrupt or exit hints are active", async () => {
|
||||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "model",
|
||||
duration: "1m 18s",
|
||||
usage: "",
|
||||
first: false,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
agent="Build"
|
||||
onSubmit={() => true}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={() => {}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 120,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("▣ · 1m 18s")
|
||||
|
||||
setState((state) => ({
|
||||
...state,
|
||||
phase: "running",
|
||||
}))
|
||||
await setup.renderOnce()
|
||||
const running = setup.captureCharFrame()
|
||||
expect(running).toContain("interrupt")
|
||||
expect(running).not.toContain("▣ · 1m 18s")
|
||||
|
||||
setState((state) => ({
|
||||
...state,
|
||||
phase: "idle",
|
||||
exit: 1,
|
||||
}))
|
||||
await setup.renderOnce()
|
||||
const exiting = setup.captureCharFrame()
|
||||
expect(exiting).toContain("Press Ctrl-c again to exit")
|
||||
expect(exiting).not.toContain("▣ · 1m 18s")
|
||||
})
|
||||
|
||||
test("ctrl-c exit hint appears when armed", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "model",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: false,
|
||||
interrupt: 0,
|
||||
exit: 1,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
agent="Build"
|
||||
onSubmit={() => true}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={() => {}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 120,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("Press Ctrl-c again to exit")
|
||||
})
|
||||
|
||||
test("queued indicator appears when queue is nonzero", async () => {
|
||||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "model",
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: true,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
|
||||
setup = await testRender(
|
||||
() => (
|
||||
<RunFooterView
|
||||
state={state}
|
||||
keybinds={{
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}}
|
||||
agent="Build"
|
||||
onSubmit={() => true}
|
||||
onCycle={() => {}}
|
||||
onInterrupt={() => false}
|
||||
onExit={() => {}}
|
||||
onRows={() => {}}
|
||||
onStatus={() => {}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 110,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).not.toContain("queued")
|
||||
|
||||
setState((state) => ({
|
||||
...state,
|
||||
queue: 2,
|
||||
}))
|
||||
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("2 queued")
|
||||
})
|
||||
})
|
||||
158
packages/opencode/test/cli/run/scrollback.test.ts
Normal file
158
packages/opencode/test/cli/run/scrollback.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { blockWriter, entryWriter, normalizeEntry } from "../../../src/cli/cmd/run/scrollback"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
import type { EntryKind } from "../../../src/cli/cmd/run/types"
|
||||
|
||||
async function draw(kind: EntryKind, text: string) {
|
||||
const setup = await testRender(() => null, {
|
||||
width: 80,
|
||||
height: 12,
|
||||
})
|
||||
|
||||
try {
|
||||
const snap = entryWriter(
|
||||
kind,
|
||||
text,
|
||||
RUN_THEME_FALLBACK.entry,
|
||||
)({
|
||||
width: 80,
|
||||
widthMethod: setup.renderer.widthMethod,
|
||||
renderContext: (setup.renderer.root as any)._ctx,
|
||||
})
|
||||
const root = snap.root as any
|
||||
return {
|
||||
snap,
|
||||
root,
|
||||
text: root.plainText as string,
|
||||
fg: root.fg,
|
||||
attrs: root.attributes ?? 0,
|
||||
}
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
async function drawBlock(text: string) {
|
||||
const setup = await testRender(() => null, {
|
||||
width: 80,
|
||||
height: 12,
|
||||
})
|
||||
|
||||
try {
|
||||
const snap = blockWriter(
|
||||
text,
|
||||
RUN_THEME_FALLBACK.entry,
|
||||
)({
|
||||
width: 80,
|
||||
widthMethod: setup.renderer.widthMethod,
|
||||
renderContext: (setup.renderer.root as any)._ctx,
|
||||
})
|
||||
const root = snap.root as any
|
||||
return {
|
||||
snap,
|
||||
root,
|
||||
text: root.plainText as string,
|
||||
fg: root.fg,
|
||||
attrs: root.attributes ?? 0,
|
||||
}
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
if (a && typeof a === "object" && "equals" in a && typeof (a as any).equals === "function") {
|
||||
return (a as any).equals(b)
|
||||
}
|
||||
|
||||
return a === b
|
||||
}
|
||||
|
||||
describe("run scrollback", () => {
|
||||
test("renders plain entries with one blank separator", async () => {
|
||||
const out = await draw("assistant", "assistant reply")
|
||||
|
||||
expect(out.root.constructor.name).toBe("TextRenderable")
|
||||
expect(out.text).toBe("assistant reply\n")
|
||||
expect(out.text).not.toContain("ASSISTANT")
|
||||
expect(out.text).not.toMatch(/\b\d{2}:\d{2}:\d{2}\b/)
|
||||
expect(out.text).not.toMatch(/[│┃┆┇┊┋╹╻╺╸]/)
|
||||
expect(out.text.split("\n")[0]).toBe("assistant reply")
|
||||
expect(out.snap.width).toBe(80)
|
||||
expect(out.snap.rowColumns).toBe(80)
|
||||
expect(out.snap.startOnNewLine).toBe(true)
|
||||
expect(out.snap.trailingNewline).toBe(false)
|
||||
})
|
||||
|
||||
test("adds user marker and keeps whitespace", async () => {
|
||||
const out = await draw("user", " one \r\n\t two\t\r\n")
|
||||
expect(out.text).toBe("› one \n\t two\t\n\n")
|
||||
})
|
||||
|
||||
test("normalizes blank user input to empty", () => {
|
||||
expect(normalizeEntry("user", " \r\n\t")).toBe("")
|
||||
})
|
||||
|
||||
test("preserves assistant and error multiline content", async () => {
|
||||
const assistant = await draw("assistant", "\nfirst\nsecond\n")
|
||||
expect(assistant.text).toBe("first\nsecond\n")
|
||||
|
||||
const error = await draw("error", " failed\nwith detail ")
|
||||
expect(error.text).toBe("failed\nwith detail\n")
|
||||
})
|
||||
|
||||
test("formats reasoning text with redaction cleanup", async () => {
|
||||
const out = await draw("reasoning", " [REDACTED]step\nnext ")
|
||||
expect(out.text).toBe("Thinking: step\nnext\n")
|
||||
|
||||
const prefixed = await draw("reasoning", "Thinking: keep\ngoing")
|
||||
expect(prefixed.text).toBe("Thinking: keep\ngoing\n")
|
||||
})
|
||||
|
||||
test("wraps long assistant lines without clipping content", async () => {
|
||||
const text =
|
||||
"The sky was a deep shade of indigo as the stars began to emerge. A gentle breeze rustled through the trees, carrying whispers of rain."
|
||||
const out = await draw("assistant", text)
|
||||
|
||||
expect(out.text).toBe(`${text}\n`)
|
||||
expect(out.snap.height).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
test("applies style mapping by entry kind", async () => {
|
||||
const user = await draw("user", "u")
|
||||
const assistant = await draw("assistant", "a")
|
||||
const reasoning = await draw("reasoning", "r")
|
||||
const error = await draw("error", "e")
|
||||
|
||||
expect(same(user.fg, RUN_THEME_FALLBACK.entry.user.body)).toBe(true)
|
||||
expect(Boolean(user.attrs & TextAttributes.BOLD)).toBe(true)
|
||||
|
||||
expect(same(assistant.fg, RUN_THEME_FALLBACK.entry.assistant.body)).toBe(true)
|
||||
expect(Boolean(assistant.attrs & TextAttributes.BOLD)).toBe(false)
|
||||
|
||||
expect(same(reasoning.fg, RUN_THEME_FALLBACK.entry.reasoning.body)).toBe(true)
|
||||
expect(Boolean(reasoning.attrs & TextAttributes.DIM)).toBe(true)
|
||||
|
||||
expect(same(error.fg, RUN_THEME_FALLBACK.entry.error.body)).toBe(true)
|
||||
expect(Boolean(error.attrs & TextAttributes.BOLD)).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves multiline blocks with intentional spacing", async () => {
|
||||
const text = "+-------+\n| splash |\n+-------+\n\nSession Demo"
|
||||
const out = await drawBlock(text)
|
||||
|
||||
expect(out.text).toBe(`${text}\n`)
|
||||
expect(out.snap.width).toBe(80)
|
||||
expect(out.snap.rowColumns).toBe(80)
|
||||
expect(out.snap.startOnNewLine).toBe(true)
|
||||
expect(out.snap.trailingNewline).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps interior whitespace in preformatted blocks", async () => {
|
||||
const out = await drawBlock("Session title\nContinue opencode -s abc")
|
||||
expect(out.text).toContain("Session title")
|
||||
expect(out.text).toContain("Continue opencode -s abc")
|
||||
})
|
||||
})
|
||||
393
packages/opencode/test/cli/run/session-data.test.ts
Normal file
393
packages/opencode/test/cli/run/session-data.test.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionData, reduceSessionData } from "../../../src/cli/cmd/run/session-data"
|
||||
|
||||
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = false) {
|
||||
return reduceSessionData({
|
||||
data,
|
||||
event: event as Event,
|
||||
sessionID: "session-1",
|
||||
thinking,
|
||||
limits: {},
|
||||
})
|
||||
}
|
||||
|
||||
describe("session data reducer", () => {
|
||||
test("repeated finalized part commits once", () => {
|
||||
const evt = {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "txt-1",
|
||||
sessionID: "session-1",
|
||||
type: "text",
|
||||
text: "assistant reply",
|
||||
time: { end: Date.now() },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
let data = createSessionData()
|
||||
const first = reduce(data, evt)
|
||||
expect(first.commits).toEqual([{ kind: "assistant", text: "assistant reply" }])
|
||||
|
||||
data = first.data
|
||||
const next = reduce(data, evt)
|
||||
expect(next.commits).toEqual([])
|
||||
})
|
||||
|
||||
test("delta then final update emits one commit", () => {
|
||||
let data = createSessionData()
|
||||
|
||||
const delta = reduce(data, {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-1",
|
||||
partID: "txt-1",
|
||||
field: "text",
|
||||
delta: "from delta",
|
||||
},
|
||||
})
|
||||
|
||||
data = delta.data
|
||||
const final = reduce(data, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "txt-1",
|
||||
sessionID: "session-1",
|
||||
type: "text",
|
||||
text: "",
|
||||
time: { end: Date.now() },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(final.commits).toEqual([{ kind: "assistant", text: "from delta" }])
|
||||
})
|
||||
|
||||
test("duplicate deltas keep finalized text", () => {
|
||||
let data = createSessionData()
|
||||
|
||||
data = reduce(data, {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-1",
|
||||
partID: "txt-1",
|
||||
field: "text",
|
||||
delta: "hello",
|
||||
},
|
||||
}).data
|
||||
|
||||
data = reduce(data, {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-1",
|
||||
partID: "txt-1",
|
||||
field: "text",
|
||||
delta: "hello",
|
||||
},
|
||||
}).data
|
||||
|
||||
const out = reduce(data, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "txt-1",
|
||||
sessionID: "session-1",
|
||||
type: "text",
|
||||
text: "hello",
|
||||
time: { end: Date.now() },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.commits).toEqual([{ kind: "assistant", text: "hello" }])
|
||||
})
|
||||
|
||||
test("ignores non-text deltas", () => {
|
||||
const out = reduce(createSessionData(), {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-1",
|
||||
partID: "txt-1",
|
||||
field: "input",
|
||||
delta: "ignored",
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.commits).toEqual([])
|
||||
expect(out.data.delta.size).toBe(0)
|
||||
})
|
||||
|
||||
test("ignores stale deltas after part finalized", () => {
|
||||
let data = createSessionData()
|
||||
|
||||
data = reduce(data, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "txt-1",
|
||||
sessionID: "session-1",
|
||||
type: "text",
|
||||
text: "done",
|
||||
time: { end: Date.now() },
|
||||
},
|
||||
},
|
||||
}).data
|
||||
|
||||
const out = reduce(data, {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-1",
|
||||
partID: "txt-1",
|
||||
field: "text",
|
||||
delta: "late",
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.commits).toEqual([])
|
||||
expect(out.data.delta.size).toBe(0)
|
||||
})
|
||||
|
||||
test("tool running then completed success stays status-only", () => {
|
||||
let data = createSessionData()
|
||||
|
||||
const running = reduce(data, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
description: "investigate",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(running.commits).toEqual([])
|
||||
expect(running.status).toBe("running investigate")
|
||||
|
||||
data = running.data
|
||||
const done = reduce(data, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
output: "ok",
|
||||
title: "task",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(done.commits).toEqual([])
|
||||
})
|
||||
|
||||
test("replayed running tool after completion is ignored", () => {
|
||||
let data = createSessionData()
|
||||
|
||||
data = reduce(data, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
description: "investigate",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).data
|
||||
|
||||
data = reduce(data, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
output: "ok",
|
||||
title: "task",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}).data
|
||||
|
||||
const out = reduce(data, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
description: "investigate",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.status).toBeUndefined()
|
||||
expect(out.commits).toEqual([])
|
||||
})
|
||||
|
||||
test("tool error emits one commit", () => {
|
||||
let data = createSessionData()
|
||||
|
||||
const evt = {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-err",
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "error",
|
||||
input: {
|
||||
command: "ls",
|
||||
},
|
||||
error: "boom",
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const first = reduce(data, evt)
|
||||
expect(first.commits).toEqual([{ kind: "error", text: "bash: boom" }])
|
||||
|
||||
data = first.data
|
||||
const next = reduce(data, evt)
|
||||
expect(next.commits).toEqual([])
|
||||
})
|
||||
|
||||
test("reasoning commits as reasoning kind", () => {
|
||||
const out = reduce(
|
||||
createSessionData(),
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "reason-1",
|
||||
sessionID: "session-1",
|
||||
type: "reasoning",
|
||||
text: "step",
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
expect(out.commits).toEqual([{ kind: "reasoning", text: "step" }])
|
||||
})
|
||||
|
||||
test("thinking disabled clears finalized reasoning delta", () => {
|
||||
let data = createSessionData()
|
||||
|
||||
data = reduce(data, {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-1",
|
||||
partID: "reason-1",
|
||||
field: "text",
|
||||
delta: "hidden",
|
||||
},
|
||||
}).data
|
||||
|
||||
expect(data.delta.size).toBe(1)
|
||||
|
||||
const out = reduce(data, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "reason-1",
|
||||
sessionID: "session-1",
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.commits).toEqual([])
|
||||
expect(out.data.delta.size).toBe(0)
|
||||
})
|
||||
|
||||
test("permission asked updates status only", () => {
|
||||
const out = reduce(createSessionData(), {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "session-1",
|
||||
permission: "read",
|
||||
patterns: ["/tmp/file.txt"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.commits).toEqual([])
|
||||
expect(out.status).toBe("permission requested: read (/tmp/file.txt); auto-rejecting")
|
||||
})
|
||||
|
||||
test("other-session events are ignored", () => {
|
||||
const data = createSessionData()
|
||||
const out = reduce(data, {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "other",
|
||||
info: {
|
||||
role: "assistant",
|
||||
agent: "agent",
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
tokens: { input: 1, output: 1, reasoning: 1, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.commits).toEqual([])
|
||||
expect(out.status).toBeUndefined()
|
||||
expect(out.usage).toBeUndefined()
|
||||
expect(out.data.announced).toBe(false)
|
||||
})
|
||||
})
|
||||
116
packages/opencode/test/cli/run/splash.test.ts
Normal file
116
packages/opencode/test/cli/run/splash.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import {
|
||||
SPLASH_TITLE_FALLBACK,
|
||||
SPLASH_TITLE_LIMIT,
|
||||
entrySplash,
|
||||
exitSplash,
|
||||
splashMeta,
|
||||
} from "../../../src/cli/cmd/run/splash"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
|
||||
async function draw(write: ReturnType<typeof entrySplash>) {
|
||||
const setup = await testRender(() => null, {
|
||||
width: 100,
|
||||
height: 24,
|
||||
})
|
||||
|
||||
try {
|
||||
const snap = write({
|
||||
width: 100,
|
||||
widthMethod: setup.renderer.widthMethod,
|
||||
renderContext: (setup.renderer.root as any)._ctx,
|
||||
})
|
||||
const root = snap.root as any
|
||||
const children = root.getChildren() as any[]
|
||||
const rows = new Map<number, string[]>()
|
||||
|
||||
for (const child of children) {
|
||||
if (typeof child.left !== "number" || typeof child.top !== "number") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof child.plainText !== "string" || child.plainText.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = rows.get(child.top) ?? []
|
||||
for (let i = 0; i < child.plainText.length; i += 1) {
|
||||
row[child.left + i] = child.plainText[i]
|
||||
}
|
||||
rows.set(child.top, row)
|
||||
}
|
||||
|
||||
const lines = [...rows.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row.join("").replace(/\s+$/g, ""))
|
||||
|
||||
return {
|
||||
snap,
|
||||
lines,
|
||||
children,
|
||||
}
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
describe("run splash", () => {
|
||||
test("builds entry text with logo", () => {
|
||||
const text = entrySplash({
|
||||
title: "Demo",
|
||||
session_id: "sess-1",
|
||||
theme: RUN_THEME_FALLBACK.entry,
|
||||
background: RUN_THEME_FALLBACK.background,
|
||||
})
|
||||
|
||||
return draw(text).then((out) => {
|
||||
expect(out.lines.some((line) => line.includes("█▀▀█"))).toBe(true)
|
||||
expect(out.lines.join("\n")).toContain("Session Demo")
|
||||
expect(out.lines.join("\n")).toContain("Type /exit or /quit to finish.")
|
||||
expect(out.children.some((item) => item.plainText === " " && item.bg && item.bg.a > 0)).toBe(true)
|
||||
expect(out.snap.height).toBeGreaterThan(5)
|
||||
expect(out.snap.startOnNewLine).toBe(true)
|
||||
expect(out.snap.trailingNewline).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("builds exit text with aligned rows", () => {
|
||||
const text = exitSplash({
|
||||
title: "Demo",
|
||||
session_id: "sess-1",
|
||||
theme: RUN_THEME_FALLBACK.entry,
|
||||
background: RUN_THEME_FALLBACK.background,
|
||||
})
|
||||
|
||||
return draw(text).then((out) => {
|
||||
expect(out.lines.join("\n")).toContain("Session Demo")
|
||||
expect(out.lines.join("\n")).toContain("Continue opencode -s sess-1")
|
||||
expect(out.snap.height).toBeGreaterThan(5)
|
||||
})
|
||||
})
|
||||
|
||||
test("applies stable fallback title", () => {
|
||||
expect(
|
||||
splashMeta({
|
||||
title: undefined,
|
||||
session_id: "sess-1",
|
||||
}).title,
|
||||
).toBe(SPLASH_TITLE_FALLBACK)
|
||||
|
||||
expect(
|
||||
splashMeta({
|
||||
title: " ",
|
||||
session_id: "sess-1",
|
||||
}).title,
|
||||
).toBe(SPLASH_TITLE_FALLBACK)
|
||||
})
|
||||
|
||||
test("truncates title with tui cap", () => {
|
||||
const meta = splashMeta({
|
||||
title: "x".repeat(80),
|
||||
session_id: "sess-1",
|
||||
})
|
||||
|
||||
expect(meta.title.length).toBeLessThanOrEqual(SPLASH_TITLE_LIMIT)
|
||||
expect(meta.title.endsWith("…")).toBe(true)
|
||||
})
|
||||
})
|
||||
47
packages/opencode/test/cli/tui/slot-replace.test.tsx
Normal file
47
packages/opencode/test/cli/tui/slot-replace.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
|
||||
type Slots = {
|
||||
prompt: {}
|
||||
}
|
||||
|
||||
test("replace slot mounts plugin content once", async () => {
|
||||
let mounts = 0
|
||||
|
||||
const Probe = () => {
|
||||
onMount(() => {
|
||||
mounts += 1
|
||||
})
|
||||
|
||||
return <box />
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
const renderer = useRenderer()
|
||||
const reg = createSolidSlotRegistry<Slots>(renderer, {})
|
||||
const Slot = createSlot(reg)
|
||||
|
||||
reg.register({
|
||||
id: "plugin",
|
||||
slots: {
|
||||
prompt() {
|
||||
return <Probe />
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<box>
|
||||
<Slot name="prompt" mode="replace">
|
||||
<box />
|
||||
</Slot>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
await testRender(() => <App />)
|
||||
|
||||
expect(mounts).toBe(1)
|
||||
})
|
||||
81
packages/opencode/test/fake/provider.ts
Normal file
81
packages/opencode/test/fake/provider.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
|
||||
export namespace ProviderTest {
|
||||
export function model(override: Partial<Provider.Model> = {}): Provider.Model {
|
||||
const id = override.id ?? ModelID.make("gpt-5.2")
|
||||
const providerID = override.providerID ?? ProviderID.make("openai")
|
||||
return {
|
||||
id,
|
||||
providerID,
|
||||
name: "Test Model",
|
||||
capabilities: {
|
||||
toolcall: true,
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
interleaved: false,
|
||||
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||
output: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||
},
|
||||
api: { id, url: "https://example.com", npm: "@ai-sdk/openai" },
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 200_000, output: 10_000 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2025-01-01",
|
||||
...override,
|
||||
}
|
||||
}
|
||||
|
||||
export function info(override: Partial<Provider.Info> = {}, mdl = model()): Provider.Info {
|
||||
const id = override.id ?? mdl.providerID
|
||||
return {
|
||||
id,
|
||||
name: "Test Provider",
|
||||
source: "config",
|
||||
env: [],
|
||||
options: {},
|
||||
models: { [mdl.id]: mdl },
|
||||
...override,
|
||||
}
|
||||
}
|
||||
|
||||
export function fake(override: Partial<Provider.Interface> & { model?: Provider.Model; info?: Provider.Info } = {}) {
|
||||
const mdl = override.model ?? model()
|
||||
const row = override.info ?? info({}, mdl)
|
||||
return {
|
||||
model: mdl,
|
||||
info: row,
|
||||
layer: Layer.succeed(
|
||||
Provider.Service,
|
||||
Provider.Service.of({
|
||||
list: Effect.fn("TestProvider.list")(() => Effect.succeed({ [row.id]: row })),
|
||||
getProvider: Effect.fn("TestProvider.getProvider")((providerID) => {
|
||||
if (providerID === row.id) return Effect.succeed(row)
|
||||
return Effect.die(new Error(`Unknown test provider: ${providerID}`))
|
||||
}),
|
||||
getModel: Effect.fn("TestProvider.getModel")((providerID, modelID) => {
|
||||
if (providerID === row.id && modelID === mdl.id) return Effect.succeed(mdl)
|
||||
return Effect.die(new Error(`Unknown test model: ${providerID}/${modelID}`))
|
||||
}),
|
||||
getLanguage: Effect.fn("TestProvider.getLanguage")(() =>
|
||||
Effect.die(new Error("ProviderTest.getLanguage not configured")),
|
||||
),
|
||||
closest: Effect.fn("TestProvider.closest")((providerID) =>
|
||||
Effect.succeed(providerID === row.id ? { providerID: row.id, modelID: mdl.id } : undefined),
|
||||
),
|
||||
getSmallModel: Effect.fn("TestProvider.getSmallModel")((providerID) =>
|
||||
Effect.succeed(providerID === row.id ? mdl : undefined),
|
||||
),
|
||||
defaultModel: Effect.fn("TestProvider.defaultModel")(() =>
|
||||
Effect.succeed({ providerID: row.id, modelID: mdl.id }),
|
||||
),
|
||||
...override,
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,7 @@ describe("Format", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("service initializes without error", () =>
|
||||
provideTmpdirInstance(() => Format.Service.use(() => Effect.void)),
|
||||
)
|
||||
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
|
||||
|
||||
it.live("status() initializes formatter state per directory", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -8,6 +8,13 @@ export type Usage = { input: number; output: number }
|
||||
|
||||
type Line = Record<string, unknown>
|
||||
|
||||
type Flow =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "reason"; text: string }
|
||||
| { type: "tool-start"; id: string; name: string }
|
||||
| { type: "tool-args"; text: string }
|
||||
| { type: "usage"; usage: Usage }
|
||||
|
||||
type Hit = {
|
||||
url: URL
|
||||
body: Record<string, unknown>
|
||||
@@ -119,6 +126,276 @@ function bytes(input: Iterable<unknown>) {
|
||||
return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText)
|
||||
}
|
||||
|
||||
function responseCreated(model: string) {
|
||||
return {
|
||||
type: "response.created",
|
||||
sequence_number: 1,
|
||||
response: {
|
||||
id: "resp_test",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
service_tier: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function responseCompleted(input: { seq: number; usage?: Usage }) {
|
||||
return {
|
||||
type: "response.completed",
|
||||
sequence_number: input.seq,
|
||||
response: {
|
||||
incomplete_details: null,
|
||||
service_tier: null,
|
||||
usage: {
|
||||
input_tokens: input.usage?.input ?? 0,
|
||||
input_tokens_details: { cached_tokens: null },
|
||||
output_tokens: input.usage?.output ?? 0,
|
||||
output_tokens_details: { reasoning_tokens: null },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function responseMessage(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "message", id },
|
||||
}
|
||||
}
|
||||
|
||||
function responseText(id: string, text: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_text.delta",
|
||||
sequence_number: seq,
|
||||
item_id: id,
|
||||
delta: text,
|
||||
logprobs: null,
|
||||
}
|
||||
}
|
||||
|
||||
function responseMessageDone(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "message", id },
|
||||
}
|
||||
}
|
||||
|
||||
function responseReason(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id, encrypted_content: null },
|
||||
}
|
||||
}
|
||||
|
||||
function responseReasonPart(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.reasoning_summary_part.added",
|
||||
sequence_number: seq,
|
||||
item_id: id,
|
||||
summary_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function responseReasonText(id: string, text: string, seq: number) {
|
||||
return {
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
sequence_number: seq,
|
||||
item_id: id,
|
||||
summary_index: 0,
|
||||
delta: text,
|
||||
}
|
||||
}
|
||||
|
||||
function responseReasonDone(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id, encrypted_content: null },
|
||||
}
|
||||
}
|
||||
|
||||
function responseTool(id: string, item: string, name: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: item,
|
||||
call_id: id,
|
||||
name,
|
||||
arguments: "",
|
||||
status: "in_progress",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function responseToolArgs(id: string, text: string, seq: number) {
|
||||
return {
|
||||
type: "response.function_call_arguments.delta",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item_id: id,
|
||||
delta: text,
|
||||
}
|
||||
}
|
||||
|
||||
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: tool.item,
|
||||
call_id: tool.id,
|
||||
name: tool.name,
|
||||
arguments: tool.args,
|
||||
status: "completed",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function choices(part: unknown) {
|
||||
if (!part || typeof part !== "object") return
|
||||
if (!("choices" in part) || !Array.isArray(part.choices)) return
|
||||
const choice = part.choices[0]
|
||||
if (!choice || typeof choice !== "object") return
|
||||
return choice
|
||||
}
|
||||
|
||||
function flow(item: Sse) {
|
||||
const out: Flow[] = []
|
||||
for (const part of [...item.head, ...item.tail]) {
|
||||
const choice = choices(part)
|
||||
const delta =
|
||||
choice && "delta" in choice && choice.delta && typeof choice.delta === "object" ? choice.delta : undefined
|
||||
|
||||
if (delta && "content" in delta && typeof delta.content === "string") {
|
||||
out.push({ type: "text", text: delta.content })
|
||||
}
|
||||
|
||||
if (delta && "reasoning_content" in delta && typeof delta.reasoning_content === "string") {
|
||||
out.push({ type: "reason", text: delta.reasoning_content })
|
||||
}
|
||||
|
||||
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
|
||||
for (const tool of delta.tool_calls) {
|
||||
if (!tool || typeof tool !== "object") continue
|
||||
const fn = "function" in tool && tool.function && typeof tool.function === "object" ? tool.function : undefined
|
||||
if ("id" in tool && typeof tool.id === "string" && fn && "name" in fn && typeof fn.name === "string") {
|
||||
out.push({ type: "tool-start", id: tool.id, name: fn.name })
|
||||
}
|
||||
if (fn && "arguments" in fn && typeof fn.arguments === "string" && fn.arguments) {
|
||||
out.push({ type: "tool-args", text: fn.arguments })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (part && typeof part === "object" && "usage" in part && part.usage && typeof part.usage === "object") {
|
||||
const raw = part.usage as Record<string, unknown>
|
||||
if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") {
|
||||
out.push({
|
||||
type: "usage",
|
||||
usage: { input: raw.prompt_tokens, output: raw.completion_tokens },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function responses(item: Sse, model: string) {
|
||||
let seq = 1
|
||||
let msg: string | undefined
|
||||
let reason: string | undefined
|
||||
let hasMsg = false
|
||||
let hasReason = false
|
||||
let call:
|
||||
| {
|
||||
id: string
|
||||
item: string
|
||||
name: string
|
||||
args: string
|
||||
}
|
||||
| undefined
|
||||
let usage: Usage | undefined
|
||||
const lines: unknown[] = [responseCreated(model)]
|
||||
|
||||
for (const part of flow(item)) {
|
||||
if (part.type === "text") {
|
||||
msg ??= "msg_1"
|
||||
if (!hasMsg) {
|
||||
hasMsg = true
|
||||
seq += 1
|
||||
lines.push(responseMessage(msg, seq))
|
||||
}
|
||||
seq += 1
|
||||
lines.push(responseText(msg, part.text, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "reason") {
|
||||
reason ||= "rs_1"
|
||||
if (!hasReason) {
|
||||
hasReason = true
|
||||
seq += 1
|
||||
lines.push(responseReason(reason, seq))
|
||||
seq += 1
|
||||
lines.push(responseReasonPart(reason, seq))
|
||||
}
|
||||
seq += 1
|
||||
lines.push(responseReasonText(reason, part.text, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "tool-start") {
|
||||
call ||= { id: part.id, item: "fc_1", name: part.name, args: "" }
|
||||
seq += 1
|
||||
lines.push(responseTool(call.id, call.item, call.name, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "tool-args") {
|
||||
if (!call) continue
|
||||
call.args += part.text
|
||||
seq += 1
|
||||
lines.push(responseToolArgs(call.item, part.text, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
usage = part.usage
|
||||
}
|
||||
|
||||
if (msg) {
|
||||
seq += 1
|
||||
lines.push(responseMessageDone(msg, seq))
|
||||
}
|
||||
if (reason) {
|
||||
seq += 1
|
||||
lines.push(responseReasonDone(reason, seq))
|
||||
}
|
||||
if (call && !item.hang && !item.error) {
|
||||
seq += 1
|
||||
lines.push(responseToolDone(call, seq))
|
||||
}
|
||||
if (!item.hang && !item.error) lines.push(responseCompleted({ seq: seq + 1, usage }))
|
||||
return { ...item, head: lines, tail: [] } satisfies Sse
|
||||
}
|
||||
|
||||
function modelFrom(body: unknown) {
|
||||
if (!body || typeof body !== "object") return "test-model"
|
||||
if (!("model" in body) || typeof body.model !== "string") return "test-model"
|
||||
return body.model
|
||||
}
|
||||
|
||||
function send(item: Sse) {
|
||||
const head = bytes(item.head)
|
||||
const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])])
|
||||
@@ -293,6 +570,13 @@ function item(input: Item | Reply) {
|
||||
return input instanceof Reply ? input.item() : input
|
||||
}
|
||||
|
||||
function hit(url: string, body: unknown) {
|
||||
return {
|
||||
url: new URL(url, "http://localhost"),
|
||||
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
|
||||
} satisfies Hit
|
||||
}
|
||||
|
||||
namespace TestLLMServer {
|
||||
export interface Service {
|
||||
readonly url: string
|
||||
@@ -342,30 +626,24 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
return first
|
||||
}
|
||||
|
||||
yield* router.add(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
Effect.gen(function* () {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const next = pull()
|
||||
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||
hits = [
|
||||
...hits,
|
||||
{
|
||||
url: new URL(req.originalUrl, "http://localhost"),
|
||||
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
|
||||
},
|
||||
]
|
||||
yield* notify()
|
||||
if (next.type === "sse" && next.reset) {
|
||||
yield* reset(next)
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
if (next.type === "sse") return send(next)
|
||||
return fail(next)
|
||||
}),
|
||||
)
|
||||
const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const next = pull()
|
||||
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||
hits = [...hits, hit(req.originalUrl, body)]
|
||||
yield* notify()
|
||||
if (next.type !== "sse") return fail(next)
|
||||
if (mode === "responses") return send(responses(next, modelFrom(body)))
|
||||
if (next.reset) {
|
||||
yield* reset(next)
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
return send(next)
|
||||
})
|
||||
|
||||
yield* router.add("POST", "/v1/chat/completions", handle("chat"))
|
||||
yield* router.add("POST", "/v1/responses", handle("responses"))
|
||||
|
||||
yield* server.serve(router.asHttpEffect())
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { APICallError } from "ai"
|
||||
import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
@@ -20,9 +20,9 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import * as ProviderModule from "../../src/provider/provider"
|
||||
import * as SessionProcessorModule from "../../src/session/processor"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ProviderTest } from "../fake/provider"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
@@ -65,6 +65,8 @@ function createModel(opts: {
|
||||
} as Provider.Model
|
||||
}
|
||||
|
||||
const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
|
||||
|
||||
async function user(sessionID: SessionID, text: string) {
|
||||
const msg = await Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
@@ -162,10 +164,11 @@ function layer(result: "continue" | "compact") {
|
||||
)
|
||||
}
|
||||
|
||||
function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer) {
|
||||
function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, provider = ProviderTest.fake()) {
|
||||
const bus = Bus.layer
|
||||
return ManagedRuntime.make(
|
||||
Layer.mergeAll(SessionCompaction.layer, bus).pipe(
|
||||
Layer.provide(provider.layer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(layer(result)),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
@@ -198,12 +201,13 @@ function llm() {
|
||||
}
|
||||
}
|
||||
|
||||
function liveRuntime(layer: Layer.Layer<LLM.Service>) {
|
||||
function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake()) {
|
||||
const bus = Bus.layer
|
||||
const status = SessionStatus.layer.pipe(Layer.provide(bus))
|
||||
const processor = SessionProcessorModule.SessionProcessor.layer
|
||||
return ManagedRuntime.make(
|
||||
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
|
||||
Layer.provide(provider.layer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(layer),
|
||||
@@ -544,14 +548,12 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const done = defer()
|
||||
let seen = false
|
||||
const rt = runtime("continue")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
let unsub: (() => void) | undefined
|
||||
try {
|
||||
unsub = await rt.runPromise(
|
||||
@@ -596,11 +598,9 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = runtime("compact")
|
||||
const rt = runtime("compact", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
@@ -636,11 +636,9 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = runtime("continue")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
@@ -678,8 +676,6 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
await user(session.id, "root")
|
||||
const replay = await user(session.id, "image")
|
||||
@@ -693,7 +689,7 @@ describe("session.compaction.process", () => {
|
||||
url: "https://example.com/cat.png",
|
||||
})
|
||||
const msg = await user(session.id, "current")
|
||||
const rt = runtime("continue")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
@@ -728,13 +724,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
await user(session.id, "earlier")
|
||||
const msg = await user(session.id, "current")
|
||||
|
||||
const rt = runtime("continue")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
@@ -790,13 +784,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const abort = new AbortController()
|
||||
const rt = liveRuntime(stub.layer)
|
||||
const rt = liveRuntime(stub.layer, wide())
|
||||
let off: (() => void) | undefined
|
||||
let run: Promise<"continue" | "stop"> | undefined
|
||||
try {
|
||||
@@ -866,13 +858,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const abort = new AbortController()
|
||||
const rt = runtime("continue", plugin(ready))
|
||||
const rt = runtime("continue", plugin(ready), wide())
|
||||
let run: Promise<"continue" | "stop"> | undefined
|
||||
try {
|
||||
run = rt
|
||||
@@ -970,11 +960,9 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = liveRuntime(stub.layer)
|
||||
const rt = liveRuntime(stub.layer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
await rt.runPromise(
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
// Helper: seed a session with a user message + finished assistant message
|
||||
// so loop() exits immediately without calling any LLM
|
||||
async function seed(sessionID: SessionID) {
|
||||
const userMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "hello",
|
||||
})
|
||||
|
||||
const assistantMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: userMsg.id,
|
||||
sessionID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: "gpt-5.2" as any,
|
||||
providerID: "openai" as any,
|
||||
time: { created: Date.now(), completed: Date.now() },
|
||||
finish: "stop",
|
||||
}
|
||||
await Session.updateMessage(assistantMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "hi there",
|
||||
})
|
||||
|
||||
return { userMsg, assistantMsg }
|
||||
}
|
||||
|
||||
describe("session.prompt concurrency", () => {
|
||||
test("loop returns assistant message and sets status to idle", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
await seed(session.id)
|
||||
|
||||
const result = await SessionPrompt.loop({ sessionID: session.id })
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
|
||||
|
||||
const status = await SessionStatus.get(session.id)
|
||||
expect(status.type).toBe("idle")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("concurrent loop callers get the same result", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
await seed(session.id)
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
SessionPrompt.loop({ sessionID: session.id }),
|
||||
SessionPrompt.loop({ sessionID: session.id }),
|
||||
])
|
||||
|
||||
expect(a.info.id).toBe(b.info.id)
|
||||
expect(a.info.role).toBe("assistant")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("assertNotBusy throws when loop is running", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const userMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: session.id,
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: "hello",
|
||||
})
|
||||
|
||||
const ready = deferred()
|
||||
const gate = deferred()
|
||||
const getModel = spyOn(Provider, "getModel").mockImplementation(async () => {
|
||||
ready.resolve()
|
||||
await gate.promise
|
||||
throw new Error("test stop")
|
||||
})
|
||||
|
||||
try {
|
||||
const loopPromise = SessionPrompt.loop({ sessionID: session.id }).catch(() => undefined)
|
||||
await ready.promise
|
||||
|
||||
await expect(SessionPrompt.assertNotBusy(session.id)).rejects.toBeInstanceOf(Session.BusyError)
|
||||
|
||||
gate.resolve()
|
||||
await loopPromise
|
||||
} finally {
|
||||
gate.resolve()
|
||||
getModel.mockRestore()
|
||||
}
|
||||
|
||||
// After loop completes, assertNotBusy should succeed
|
||||
await SessionPrompt.assertNotBusy(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("cancel sets status to idle", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
// Seed only a user message — loop must call getModel to proceed
|
||||
const userMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: session.id,
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: "hello",
|
||||
})
|
||||
// Also seed an assistant message so lastAssistant() fallback can find it
|
||||
const assistantMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: userMsg.id,
|
||||
sessionID: session.id,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: "gpt-5.2" as any,
|
||||
providerID: "openai" as any,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
await Session.updateMessage(assistantMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: "hi there",
|
||||
})
|
||||
|
||||
const ready = deferred()
|
||||
const gate = deferred()
|
||||
const getModel = spyOn(Provider, "getModel").mockImplementation(async () => {
|
||||
ready.resolve()
|
||||
await gate.promise
|
||||
throw new Error("test stop")
|
||||
})
|
||||
|
||||
try {
|
||||
// Start loop — it will block in getModel (assistant has no finish, so loop continues)
|
||||
const loopPromise = SessionPrompt.loop({ sessionID: session.id })
|
||||
|
||||
await ready.promise
|
||||
|
||||
await SessionPrompt.cancel(session.id)
|
||||
|
||||
const status = await SessionStatus.get(session.id)
|
||||
expect(status.type).toBe("idle")
|
||||
|
||||
// loop should resolve cleanly, not throw "All fibers interrupted"
|
||||
const result = await loopPromise
|
||||
expect(result.info.role).toBe("assistant")
|
||||
expect(result.info.id).toBe(assistantMsg.id)
|
||||
} finally {
|
||||
gate.resolve()
|
||||
getModel.mockRestore()
|
||||
}
|
||||
},
|
||||
})
|
||||
}, 10000)
|
||||
|
||||
test("cancel on idle session just sets idle", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
await SessionPrompt.cancel(session.id)
|
||||
const status = await SessionStatus.get(session.id)
|
||||
expect(status.type).toBe("idle")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { LSP } from "../../src/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Session } from "../../src/session"
|
||||
@@ -151,6 +152,7 @@ function makeHttp() {
|
||||
Permission.layer,
|
||||
Plugin.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
ProviderSvc.defaultLayer,
|
||||
filetime,
|
||||
lsp,
|
||||
mcp,
|
||||
|
||||
@@ -10,9 +10,66 @@ import { Instance } from "../../src/project/instance"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
Log.init({ print: false })
|
||||
|
||||
function user(sessionID: string, agent = "default") {
|
||||
return Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user" as const,
|
||||
sessionID: sessionID as any,
|
||||
agent,
|
||||
model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
}
|
||||
|
||||
function assistant(sessionID: string, parentID: string, dir: string) {
|
||||
return Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant" as const,
|
||||
sessionID: sessionID as any,
|
||||
mode: "default",
|
||||
agent: "default",
|
||||
path: { cwd: dir, root: dir },
|
||||
cost: 0,
|
||||
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: ModelID.make("gpt-4"),
|
||||
providerID: ProviderID.make("openai"),
|
||||
parentID: parentID as any,
|
||||
time: { created: Date.now() },
|
||||
finish: "end_turn",
|
||||
})
|
||||
}
|
||||
|
||||
function text(sessionID: string, messageID: string, content: string) {
|
||||
return Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: messageID as any,
|
||||
sessionID: sessionID as any,
|
||||
type: "text" as const,
|
||||
text: content,
|
||||
})
|
||||
}
|
||||
|
||||
function tool(sessionID: string, messageID: string) {
|
||||
return Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: messageID as any,
|
||||
sessionID: sessionID as any,
|
||||
type: "tool" as const,
|
||||
tool: "bash",
|
||||
callID: "call-1",
|
||||
state: {
|
||||
status: "completed" as const,
|
||||
input: {},
|
||||
output: "done",
|
||||
title: "",
|
||||
metadata: {},
|
||||
time: { start: 0, end: 1 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("revert + compact workflow", () => {
|
||||
test("should properly handle compact command after revert", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
@@ -283,4 +340,98 @@ describe("revert + compact workflow", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("cleanup with partID removes parts from the revert point onward", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const sid = session.id
|
||||
|
||||
const u1 = await user(sid)
|
||||
const p1 = await text(sid, u1.id, "first part")
|
||||
const p2 = await tool(sid, u1.id)
|
||||
const p3 = await text(sid, u1.id, "third part")
|
||||
|
||||
// Set revert state pointing at a specific part
|
||||
await Session.setRevert({
|
||||
sessionID: sid,
|
||||
revert: { messageID: u1.id, partID: p2.id },
|
||||
summary: { additions: 0, deletions: 0, files: 0 },
|
||||
})
|
||||
|
||||
const info = await Session.get(sid)
|
||||
await SessionRevert.cleanup(info)
|
||||
|
||||
const msgs = await Session.messages({ sessionID: sid })
|
||||
expect(msgs.length).toBe(1)
|
||||
// Only the first part should remain (before the revert partID)
|
||||
expect(msgs[0].parts.length).toBe(1)
|
||||
expect(msgs[0].parts[0].id).toBe(p1.id)
|
||||
|
||||
const cleared = await Session.get(sid)
|
||||
expect(cleared.revert).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("cleanup removes messages after revert point but keeps earlier ones", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const sid = session.id
|
||||
|
||||
const u1 = await user(sid)
|
||||
await text(sid, u1.id, "hello")
|
||||
const a1 = await assistant(sid, u1.id, tmp.path)
|
||||
await text(sid, a1.id, "hi back")
|
||||
|
||||
const u2 = await user(sid)
|
||||
await text(sid, u2.id, "second question")
|
||||
const a2 = await assistant(sid, u2.id, tmp.path)
|
||||
await text(sid, a2.id, "second answer")
|
||||
|
||||
// Revert from u2 onward
|
||||
await Session.setRevert({
|
||||
sessionID: sid,
|
||||
revert: { messageID: u2.id },
|
||||
summary: { additions: 0, deletions: 0, files: 0 },
|
||||
})
|
||||
|
||||
const info = await Session.get(sid)
|
||||
await SessionRevert.cleanup(info)
|
||||
|
||||
const msgs = await Session.messages({ sessionID: sid })
|
||||
const ids = msgs.map((m) => m.info.id)
|
||||
expect(ids).toContain(u1.id)
|
||||
expect(ids).toContain(a1.id)
|
||||
expect(ids).not.toContain(u2.id)
|
||||
expect(ids).not.toContain(a2.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("cleanup is a no-op when session has no revert state", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const sid = session.id
|
||||
|
||||
const u1 = await user(sid)
|
||||
await text(sid, u1.id, "hello")
|
||||
|
||||
const info = await Session.get(sid)
|
||||
expect(info.revert).toBeUndefined()
|
||||
await SessionRevert.cleanup(info)
|
||||
|
||||
const msgs = await Session.messages({ sessionID: sid })
|
||||
expect(msgs.length).toBe(1)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -21,8 +21,8 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.93",
|
||||
"@opentui/solid": ">=0.1.93"
|
||||
"@opentui/core": ">=0.1.95",
|
||||
"@opentui/solid": ">=0.1.95"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
@@ -33,8 +33,8 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentui/core": "0.1.93",
|
||||
"@opentui/solid": "0.1.93",
|
||||
"@opentui/core": "0.1.95",
|
||||
"@opentui/solid": "0.1.95",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -7040,44 +7040,6 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Event.installation.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "installation.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["version"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.installation.update-available": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "installation.update-available"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["version"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7154,6 +7116,44 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.installation.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "installation.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["version"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.installation.update-available": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "installation.update-available"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["version"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.instance.disposed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9733,15 +9733,15 @@
|
||||
},
|
||||
"Event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.project.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.installation.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.installation.update-available"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.project.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -7,11 +7,12 @@ export function DockPrompt(props: {
|
||||
children: JSX.Element
|
||||
footer: JSX.Element
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
}) {
|
||||
const slot = (name: string) => `${props.kind}-${name}`
|
||||
|
||||
return (
|
||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
|
||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
|
||||
<DockShell data-slot={slot("body")}>
|
||||
<div data-slot={slot("header")}>{props.header}</div>
|
||||
<div data-slot={slot("content")}>{props.children}</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render as renderSolid } from "solid-js/web"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
@@ -55,6 +55,7 @@ type LineCommentControllerProps<T extends LineCommentShape> = {
|
||||
comments: Accessor<T[]>
|
||||
draftKey: Accessor<string>
|
||||
label: string
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
state: LineCommentStateProps<string>
|
||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||
@@ -85,6 +86,7 @@ type CommentProps = {
|
||||
type DraftProps = {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
@@ -148,6 +150,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
||||
cancelLabel={view().editor!.cancelLabel}
|
||||
submitLabel={view().editor!.submitLabel}
|
||||
mention={view().editor!.mention}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
@@ -167,6 +170,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
onCancel={view().onCancel}
|
||||
onSubmit={view().onSubmit}
|
||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||
mention={view().mention}
|
||||
/>
|
||||
)
|
||||
}, host)
|
||||
@@ -389,6 +393,7 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (value: string) => {
|
||||
@@ -415,6 +420,7 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(range, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (comment) => {
|
||||
|
||||
@@ -178,6 +178,58 @@ export const lineCommentStyles = `
|
||||
box-shadow: var(--shadow-xs-border-select);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-path"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-dir"] {
|
||||
min-width: 0;
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-file"] {
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { Button } from "./button"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { installLineCommentStyles } from "./line-comment-styles"
|
||||
import { useI18n } from "../context/i18n"
|
||||
@@ -183,6 +186,9 @@ export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "
|
||||
autofocus?: boolean
|
||||
cancelLabel?: string
|
||||
submitLabel?: string
|
||||
mention?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
@@ -198,12 +204,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
"autofocus",
|
||||
"cancelLabel",
|
||||
"submitLabel",
|
||||
"mention",
|
||||
])
|
||||
|
||||
const refs = {
|
||||
textarea: undefined as HTMLTextAreaElement | undefined,
|
||||
}
|
||||
const [text, setText] = createSignal(split.value)
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
function selectMention(item: { path: string } | undefined) {
|
||||
if (!item) return
|
||||
|
||||
const textarea = refs.textarea
|
||||
const query = currentMention()
|
||||
if (!textarea || !query) return
|
||||
|
||||
const value = `${text().slice(0, query.start)}@${item.path} ${text().slice(query.end)}`
|
||||
const cursor = query.start + item.path.length + 2
|
||||
|
||||
setText(value)
|
||||
split.onInput(value)
|
||||
closeMention()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
const mention = useFilteredList<{ path: string }>({
|
||||
items: async (query) => {
|
||||
if (!split.mention) return []
|
||||
if (!query.trim()) return []
|
||||
const paths = await split.mention.items(query)
|
||||
return paths.map((path) => ({ path }))
|
||||
},
|
||||
key: (item) => item.path,
|
||||
filterKeys: ["path"],
|
||||
onSelect: selectMention,
|
||||
})
|
||||
|
||||
const focus = () => refs.textarea?.focus()
|
||||
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
|
||||
@@ -221,6 +261,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
setText(split.value)
|
||||
})
|
||||
|
||||
const closeMention = () => {
|
||||
setOpen(false)
|
||||
mention.clear()
|
||||
}
|
||||
|
||||
const currentMention = () => {
|
||||
const textarea = refs.textarea
|
||||
if (!textarea) return
|
||||
if (!split.mention) return
|
||||
if (textarea.selectionStart !== textarea.selectionEnd) return
|
||||
|
||||
const end = textarea.selectionStart
|
||||
const match = textarea.value.slice(0, end).match(/@(\S*)$/)
|
||||
if (!match) return
|
||||
|
||||
return {
|
||||
query: match[1] ?? "",
|
||||
start: end - match[0].length,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
const syncMention = () => {
|
||||
const item = currentMention()
|
||||
if (!item) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
mention.onInput(item.query)
|
||||
}
|
||||
|
||||
const selectActiveMention = () => {
|
||||
const items = mention.flat()
|
||||
if (items.length === 0) return
|
||||
const active = mention.active()
|
||||
selectMention(items.find((item) => item.path === active) ?? items[0])
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const value = text().trim()
|
||||
if (!value) return
|
||||
@@ -247,11 +327,38 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||
setText(value)
|
||||
split.onInput(value)
|
||||
syncMention()
|
||||
}}
|
||||
on:click={() => syncMention()}
|
||||
on:select={() => syncMention()}
|
||||
on:keydown={(e) => {
|
||||
const event = e as KeyboardEvent
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
event.stopPropagation()
|
||||
if (open()) {
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "Tab") {
|
||||
if (mention.flat().length === 0) return
|
||||
event.preventDefault()
|
||||
selectActiveMention()
|
||||
return
|
||||
}
|
||||
|
||||
const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
|
||||
const ctrlNav =
|
||||
event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p")
|
||||
if ((nav || ctrlNav) && mention.flat().length > 0) {
|
||||
mention.onKeyDown(event)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
@@ -264,6 +371,34 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
submit()
|
||||
}}
|
||||
/>
|
||||
<Show when={open() && mention.flat().length > 0}>
|
||||
<div data-slot="line-comment-mention-list">
|
||||
<For each={mention.flat().slice(0, 10)}>
|
||||
{(item) => {
|
||||
const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
|
||||
const name = item.path.endsWith("/") ? "" : getFilename(item.path)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-mention-item"
|
||||
data-active={mention.active() === item.path ? "" : undefined}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => mention.setActive(item.path)}
|
||||
onClick={() => selectMention(item)}
|
||||
>
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||
<div data-slot="line-comment-mention-path">
|
||||
<span data-slot="line-comment-mention-dir">{directory}</span>
|
||||
<Show when={name}>
|
||||
<span data-slot="line-comment-mention-file">{name}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="line-comment-actions">
|
||||
<div data-slot="line-comment-editor-label">
|
||||
{i18n.t("ui.lineComment.editorLabel.prefix")}
|
||||
|
||||
@@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
@@ -23,8 +22,10 @@ import { Dynamic } from "solid-js/web"
|
||||
import { mediaKindFromPath } from "../pierre/media"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
|
||||
import { createLineCommentController } from "./line-comment-annotations"
|
||||
import type { LineCommentEditorProps } from "./line-comment"
|
||||
|
||||
const MAX_DIFF_CHANGED_LINES = 500
|
||||
const REVIEW_MOUNT_MARGIN = 300
|
||||
|
||||
export type SessionReviewDiffStyle = "unified" | "split"
|
||||
|
||||
@@ -68,7 +69,7 @@ export interface SessionReviewProps {
|
||||
split?: boolean
|
||||
diffStyle?: SessionReviewDiffStyle
|
||||
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
||||
onDiffRendered?: () => void
|
||||
onDiffRendered?: VoidFunction
|
||||
onLineComment?: (comment: SessionReviewLineComment) => void
|
||||
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
||||
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
||||
@@ -88,6 +89,7 @@ export interface SessionReviewProps {
|
||||
diffs: ReviewDiff[]
|
||||
onViewFile?: (file: string) => void
|
||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||
lineCommentMention?: LineCommentEditorProps["mention"]
|
||||
}
|
||||
|
||||
function ReviewCommentMenu(props: {
|
||||
@@ -135,11 +137,14 @@ type SessionReviewSelection = {
|
||||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let focusToken = 0
|
||||
let frame: number | undefined
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const anchors = new Map<string, HTMLElement>()
|
||||
const nodes = new Map<string, HTMLDivElement>()
|
||||
const [store, setStore] = createStore({
|
||||
open: [] as string[],
|
||||
visible: {} as Record<string, boolean>,
|
||||
force: {} as Record<string, boolean>,
|
||||
selection: null as SessionReviewSelection | null,
|
||||
commenting: null as SessionReviewSelection | null,
|
||||
@@ -152,13 +157,84 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const open = () => props.open ?? store.open
|
||||
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
||||
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
|
||||
const grouped = createMemo(() => {
|
||||
const next = new Map<string, SessionReviewComment[]>()
|
||||
for (const comment of props.comments ?? []) {
|
||||
const list = next.get(comment.file)
|
||||
if (list) {
|
||||
list.push(comment)
|
||||
continue
|
||||
}
|
||||
next.set(comment.file, [comment])
|
||||
}
|
||||
return next
|
||||
})
|
||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||
const hasDiffs = () => files().length > 0
|
||||
|
||||
const handleChange = (open: string[]) => {
|
||||
props.onOpenChange?.(open)
|
||||
if (props.open !== undefined) return
|
||||
setStore("open", open)
|
||||
const syncVisible = () => {
|
||||
frame = undefined
|
||||
if (!scroll) return
|
||||
|
||||
const root = scroll.getBoundingClientRect()
|
||||
const top = root.top - REVIEW_MOUNT_MARGIN
|
||||
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
|
||||
const openSet = new Set(open())
|
||||
const next: Record<string, boolean> = {}
|
||||
|
||||
for (const [file, el] of nodes) {
|
||||
if (!openSet.has(file)) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.bottom < top || rect.top > bottom) continue
|
||||
next[file] = true
|
||||
}
|
||||
|
||||
const prev = untrack(() => store.visible)
|
||||
const prevKeys = Object.keys(prev)
|
||||
const nextKeys = Object.keys(next)
|
||||
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
|
||||
setStore("visible", next)
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(syncVisible)
|
||||
}
|
||||
|
||||
const pinned = (file: string) =>
|
||||
props.focusedComment?.file === file ||
|
||||
props.focusedFile === file ||
|
||||
selection()?.file === file ||
|
||||
commenting()?.file === file ||
|
||||
opened()?.file === file
|
||||
|
||||
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
||||
queue()
|
||||
const next = props.onScroll
|
||||
if (!next) return
|
||||
if (Array.isArray(next)) {
|
||||
const [fn, data] = next as [(data: unknown, event: Event) => void, unknown]
|
||||
fn(data, event)
|
||||
return
|
||||
}
|
||||
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.open
|
||||
files()
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
props.onOpenChange?.(next)
|
||||
if (props.open === undefined) setStore("open", next)
|
||||
queue()
|
||||
}
|
||||
|
||||
const handleExpandOrCollapseAll = () => {
|
||||
@@ -272,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
props.scrollRef?.(el)
|
||||
queue()
|
||||
}}
|
||||
onScroll={props.onScroll as any}
|
||||
onScroll={handleScroll}
|
||||
classList={{
|
||||
[props.classes?.root ?? ""]: !!props.classes?.root,
|
||||
}}
|
||||
@@ -289,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const item = createMemo(() => diffs().get(file)!)
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||
const force = () => !!store.force[file]
|
||||
|
||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
|
||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
|
||||
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
||||
@@ -327,6 +405,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
comments,
|
||||
label: i18n.t("ui.lineComment.submit"),
|
||||
draftKey: () => file,
|
||||
mention: props.lineCommentMention,
|
||||
state: {
|
||||
opened: () => {
|
||||
const current = opened()
|
||||
@@ -378,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
onCleanup(() => {
|
||||
anchors.delete(file)
|
||||
nodes.delete(file)
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
@@ -462,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
ref={(el) => {
|
||||
wrapper = el
|
||||
anchors.set(file, el)
|
||||
nodes.set(file, el)
|
||||
queue()
|
||||
}}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
<Switch>
|
||||
<Match when={!mounted() && !tooLarge()}>
|
||||
<div
|
||||
data-slot="session-review-diff-placeholder"
|
||||
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
|
||||
style={{ height: "160px" }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={tooLarge()}>
|
||||
<div data-slot="session-review-large-diff">
|
||||
<div data-slot="session-review-large-diff-title">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.3.11",
|
||||
"version": "1.3.13",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user