Compare commits

..

40 Commits

Author SHA1 Message Date
Simon Klee
48e30e7f26 color intent 2026-04-01 16:07:24 +02:00
Simon Klee
4e45169eec variance peristance 2026-04-01 16:07:24 +02:00
Simon Klee
1e00672517 move duration to left 2026-04-01 16:07:24 +02:00
Simon Klee
9c761ff619 splash screen 2026-04-01 16:07:24 +02:00
Simon Klee
ba82c11091 test reducer 2026-04-01 16:07:24 +02:00
Simon Klee
d179a5eeb3 simplify output 2026-04-01 16:07:24 +02:00
Simon Klee
3d6324459e add a session data reducer 2026-04-01 16:07:24 +02:00
Simon Klee
d92cf629f6 more exit handling 2026-04-01 16:07:24 +02:00
Simon Klee
857c0aa258 cleanup theme 2026-04-01 16:07:24 +02:00
Simon Klee
93acb5411f history and exit 2026-04-01 16:07:24 +02:00
Simon Klee
809e46c988 fix history 2026-04-01 16:07:24 +02:00
Simon Klee
56c9f68368 footer cleanup 2026-04-01 16:07:24 +02:00
Simon Klee
4dad8d4bcb life-cycle things 2026-04-01 16:07:23 +02:00
Simon Klee
02a958b30c fix ctrl-c/fix flickering 2026-04-01 16:07:23 +02:00
Simon Klee
7871920b56 use solid 2026-04-01 16:07:23 +02:00
Simon Klee
82075fa920 improve footer api 2026-04-01 16:07:23 +02:00
Simon Klee
a3d3bf9a71 cleanup 2026-04-01 16:07:23 +02:00
Simon Klee
3146c216ec wip 2026-04-01 16:07:23 +02:00
Simon Klee
df84677212 wip 2026-04-01 16:07:23 +02:00
Simon Klee
685e237c4c wip 2026-04-01 16:07:22 +02:00
Shoubhit Dash
44f83015cd perf(review): defer offscreen diff mounts (#20469) 2026-04-01 19:29:12 +05:30
Kit Langton
9a1c9ae15a test(app): route prompt e2e through mock llm (#20383) 2026-04-01 08:28:38 -04:00
Shoubhit Dash
a3a6cf1c07 feat(comments): support file mentions (#20447) 2026-04-01 16:11:57 +05:30
Shoubhit Dash
47a676111a fix(session): add keyboard support to question dock (#20439) 2026-04-01 15:47:15 +05:30
Brendan Allan
1df5ad470a app: try to hide autofill popups in prompt input (#20197) 2026-04-01 08:43:03 +00:00
Brendan Allan
506dd75818 electron: port mergeShellEnv logic from tauri (#20192) 2026-04-01 07:01:44 +00:00
Kit Langton
c8ecd64022 test(app): add mock llm e2e fixture (#20375) 2026-03-31 21:24:39 -04:00
opencode-agent[bot]
ca376a4cff chore: update nix node_modules hashes 2026-04-01 01:15:51 +00:00
Kit Langton
7532d99e5b test: finish HTTP mock processor coverage (#20372) 2026-04-01 00:45:42 +00:00
Kit Langton
181b5f6236 refactor(prompt): use Provider service in effect layers (#20167) 2026-04-01 00:44:15 +00:00
opencode
6314f09c14 release: v1.3.13 2026-04-01 00:44:06 +00:00
Sebastian
4b4b7832aa upgrade opentui to 0.1.95 (#20369) 2026-04-01 01:53:05 +02:00
opencode-agent[bot]
4280307013 chore: update nix node_modules hashes 2026-03-31 23:19:18 +00:00
opencode-agent[bot]
9b09a7e766 chore: generate 2026-03-31 23:15:56 +00:00
Kit Langton
3fc0367b93 refactor(session): effectify SessionRevert service (#20143) 2026-03-31 19:14:49 -04:00
Kit Langton
954a6ca88e refactor(session): effectify SessionSummary service (#20142) 2026-03-31 19:14:45 -04:00
Kit Langton
0c03a3ee10 test: migrate prompt tests to HTTP mock LLM server (#20304) 2026-03-31 19:14:32 -04:00
github-actions[bot]
53330a518f Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20333#issuecomment-4166038038
2026-03-31 22:35:10 +00:00
opencode
892bdebaac release: v1.3.12 2026-03-31 22:35:01 +00:00
Sebastian
18121300f3 upgrade opentui to 0.1.94 (#20357) 2026-03-31 23:54:13 +02:00
75 changed files with 7435 additions and 754 deletions

1
.github/VOUCHED.td vendored
View File

@@ -27,3 +27,4 @@ rekram1-node
-robinmordasiewicz
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-toastythebot

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.11",
"version": "1.3.13",
"description": "",
"type": "module",
"exports": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.11",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.11",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.11",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -509,7 +509,9 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
// TODO: i exported this, just for keeping it simple for now, but this should
// probably go into something shared if we decide to use this in opencode run
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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")
})
})

View 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")
})
})

View 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")
})
})

View 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")
})
})

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.3.11",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.3.11",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"exports": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.3.11",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",

View File

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

View File

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