mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-01 19:45:05 +00:00
Compare commits
14 Commits
oc-run
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7481f4593 | ||
|
|
f3f728ec27 | ||
|
|
c619caefdd | ||
|
|
c559af51ce | ||
|
|
d1e0a4640c | ||
|
|
f9e71ec515 | ||
|
|
ef538c9707 | ||
|
|
2f405daa98 | ||
|
|
a9c85b7c27 | ||
|
|
897d83c589 | ||
|
|
0a125e5d4d | ||
|
|
38d2276592 | ||
|
|
d58004a864 | ||
|
|
5fd833aa18 |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -11,6 +11,7 @@ adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-atharvau AI review spamming literally every PR
|
||||
-borealbytes
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
edemaine
|
||||
|
||||
10
bun.lock
10
bun.lock
@@ -612,7 +612,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/platform-node": "4.0.0-beta.42",
|
||||
"@effect/platform-node": "4.0.0-beta.43",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -636,7 +636,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.42",
|
||||
"effect": "4.0.0-beta.43",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -995,9 +995,9 @@
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.42", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.42", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }, "sha512-kbdRML2FBa4q8U8rZQcnmLKZ5zN/z1bAA7t5D1/UsBHZqJgnfRgu1CP6kaEfb1Nie6YyaWshxTktZQryjvW/Yg=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.42", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-PC+lxLsrwob3+nBChAPrQq32olCeyApgXBvs1NrRsoArLViNT76T/68CttuCAksCZj5e1bZ1ZibLPel3vUmx2g=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -2771,7 +2771,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="],
|
||||
"effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-C7y5FMI1pGEgMw/vcPoBhK9tw5uGg1bk0gPXPUUVhgU=",
|
||||
"aarch64-linux": "sha256-cUlQ9jp4WIaJkd4GRoHMWc+REG/OnnGCmsQUNmvg4is=",
|
||||
"aarch64-darwin": "sha256-3GXmqG7yihJ91wS/jlW19qxGI62b1bFJnpGB4LcMlpY=",
|
||||
"x86_64-darwin": "sha256-cUF0TfYg2nXnU80kWFpr9kNHlu9txiatIgrHTltgx4g="
|
||||
"x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=",
|
||||
"aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=",
|
||||
"aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=",
|
||||
"x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.42",
|
||||
"@effect/platform-node": "4.0.0-beta.43",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
@@ -45,7 +45,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.42",
|
||||
"effect": "4.0.0-beta.43",
|
||||
"ai": "6.0.138",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -312,10 +312,11 @@ export async function openSettings(page: Page) {
|
||||
return dialog
|
||||
}
|
||||
|
||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
|
||||
await page.addInitScript(
|
||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||
const key = "opencode.global.dat:server"
|
||||
const defaultKey = "opencode.settings.dat:defaultServerUrl"
|
||||
const raw = localStorage.getItem(key)
|
||||
const parsed = (() => {
|
||||
if (!raw) return undefined
|
||||
@@ -331,6 +332,7 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
||||
|
||||
const add = (origin: string, directory: string) => {
|
||||
const current = nextProjects[origin]
|
||||
@@ -356,17 +358,18 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
list,
|
||||
list: nextList,
|
||||
projects: nextProjects,
|
||||
lastProject,
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(defaultKey, args.serverUrl)
|
||||
},
|
||||
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
||||
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
|
||||
)
|
||||
}
|
||||
|
||||
export async function createTestProject() {
|
||||
export async function createTestProject(input?: { serverUrl?: string }) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||
const id = `e2e-${path.basename(root)}`
|
||||
|
||||
@@ -381,7 +384,7 @@ export async function createTestProject() {
|
||||
stdio: "ignore",
|
||||
})
|
||||
|
||||
return resolveDirectory(root)
|
||||
return resolveDirectory(root, input?.serverUrl)
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
@@ -430,22 +433,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
return next
|
||||
}
|
||||
|
||||
export async function resolveSlug(slug: string) {
|
||||
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
const resolved = await resolveDirectory(directory)
|
||||
const resolved = await resolveDirectory(directory, input?.serverUrl)
|
||||
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||
}
|
||||
|
||||
export async function waitDir(page: Page, directory: string) {
|
||||
const target = await resolveDirectory(directory)
|
||||
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
|
||||
const target = await resolveDirectory(directory, input?.serverUrl)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitDir")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
return resolveSlug(slug)
|
||||
return resolveSlug(slug, input)
|
||||
.then((item) => item.directory)
|
||||
.catch(() => "")
|
||||
},
|
||||
@@ -455,15 +458,15 @@ export async function waitDir(page: Page, directory: string) {
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||
const target = await resolveDirectory(input.directory)
|
||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
|
||||
const target = await resolveDirectory(input.directory, input.serverUrl)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSession")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return false
|
||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
|
||||
if (!resolved || resolved.directory !== target) return false
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (input.sessionID && current !== input.sessionID) return false
|
||||
@@ -473,7 +476,7 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||
if (!input.sessionID && state?.sessionID) return false
|
||||
if (state?.dir) {
|
||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
||||
if (dir !== target) return false
|
||||
}
|
||||
|
||||
@@ -489,9 +492,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||
const sdk = createSdk(directory)
|
||||
const target = await resolveDirectory(directory)
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
|
||||
const sdk = createSdk(directory, serverUrl)
|
||||
const target = await resolveDirectory(directory, serverUrl)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -501,7 +504,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!data?.directory) return ""
|
||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
@@ -666,8 +669,9 @@ export async function cleanupSession(input: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
sdk?: ReturnType<typeof createSdk>
|
||||
serverUrl?: string
|
||||
}) {
|
||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
|
||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||
@@ -1019,3 +1023,13 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||
await expect(menu).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
|
||||
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||
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")
|
||||
}
|
||||
|
||||
136
packages/app/e2e/backend.ts
Normal file
136
packages/app/e2e/backend.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import fs from "node:fs/promises"
|
||||
import net from "node:net"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
type Handle = {
|
||||
url: string
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once("error", reject)
|
||||
server.listen(0, () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire a free port")))
|
||||
return
|
||||
}
|
||||
server.close((err) => {
|
||||
if (err) reject(err)
|
||||
else resolve(address.port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string, probe = "/global/health") {
|
||||
const end = Date.now() + 120_000
|
||||
let last = ""
|
||||
while (Date.now() < end) {
|
||||
try {
|
||||
const res = await fetch(`${url}${probe}`)
|
||||
if (res.ok) return
|
||||
last = `status ${res.status}`
|
||||
} catch (err) {
|
||||
last = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
||||
}
|
||||
|
||||
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
|
||||
if (proc.exitCode !== null) return
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
|
||||
])
|
||||
}
|
||||
|
||||
const LOG_CAP = 100
|
||||
|
||||
function cap(input: string[]) {
|
||||
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
|
||||
}
|
||||
|
||||
function tail(input: string[]) {
|
||||
return input.slice(-40).join("")
|
||||
}
|
||||
|
||||
export async function startBackend(label: string): Promise<Handle> {
|
||||
const port = await freePort()
|
||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const repoDir = path.resolve(appDir, "../..")
|
||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
||||
XDG_DATA_HOME: path.join(sandbox, "share"),
|
||||
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||
OPENCODE_CLIENT: "app",
|
||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||
} satisfies Record<string, string | undefined>
|
||||
const out: string[] = []
|
||||
const err: string[] = []
|
||||
const proc = spawn(
|
||||
"bun",
|
||||
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
|
||||
{
|
||||
cwd: opencodeDir,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
)
|
||||
proc.stdout?.on("data", (chunk) => {
|
||||
out.push(String(chunk))
|
||||
cap(out)
|
||||
})
|
||||
proc.stderr?.on("data", (chunk) => {
|
||||
err.push(String(chunk))
|
||||
cap(err)
|
||||
})
|
||||
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
try {
|
||||
await waitForHealth(url)
|
||||
} catch (error) {
|
||||
proc.kill("SIGTERM")
|
||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to start isolated e2e backend for ${label}`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
tail(out),
|
||||
tail(err),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
async stop() {
|
||||
if (proc.exitCode === null) {
|
||||
proc.kill("SIGTERM")
|
||||
await waitExit(proc)
|
||||
}
|
||||
if (proc.exitCode === null) {
|
||||
proc.kill("SIGKILL")
|
||||
await waitExit(proc)
|
||||
}
|
||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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 { startBackend } from "./backend"
|
||||
import {
|
||||
healthPhase,
|
||||
cleanupSession,
|
||||
@@ -19,6 +20,20 @@ import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
type LLMFixture = {
|
||||
url: string
|
||||
push: (...input: (Item | Reply)[]) => Promise<void>
|
||||
pushMatch: (
|
||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||
...input: (Item | Reply)[]
|
||||
) => Promise<void>
|
||||
textMatch: (
|
||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||
value: string,
|
||||
opts?: { usage?: Usage },
|
||||
) => Promise<void>
|
||||
toolMatch: (
|
||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||
name: string,
|
||||
input: unknown,
|
||||
) => Promise<void>
|
||||
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
||||
tool: (name: string, input: unknown) => Promise<void>
|
||||
toolHang: (name: string, input: unknown) => Promise<void>
|
||||
@@ -46,32 +61,54 @@ const seedModel = (() => {
|
||||
}
|
||||
})()
|
||||
|
||||
type ProjectHandle = {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
}
|
||||
|
||||
type ProjectOptions = {
|
||||
extra?: string[]
|
||||
model?: { providerID: string; modelID: string }
|
||||
setup?: (directory: string) => Promise<void>
|
||||
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
||||
}
|
||||
|
||||
type TestFixtures = {
|
||||
llm: LLMFixture
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
withProject: <T>(
|
||||
callback: (project: {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
}) => Promise<T>,
|
||||
options?: {
|
||||
extra?: string[]
|
||||
model?: { providerID: string; modelID: string }
|
||||
setup?: (directory: string) => Promise<void>
|
||||
},
|
||||
) => Promise<T>
|
||||
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
||||
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
||||
}
|
||||
|
||||
type WorkerFixtures = {
|
||||
backend: {
|
||||
url: string
|
||||
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||
}
|
||||
directory: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
backend: [
|
||||
async ({}, use, workerInfo) => {
|
||||
const handle = await startBackend(`w${workerInfo.workerIndex}`)
|
||||
try {
|
||||
await use({
|
||||
url: handle.url,
|
||||
sdk: (directory?: string) => createSdk(directory, handle.url),
|
||||
})
|
||||
} finally {
|
||||
await handle.stop()
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
llm: async ({}, use) => {
|
||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||
try {
|
||||
@@ -79,6 +116,9 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use({
|
||||
url: svc.url,
|
||||
push: (...input) => rt.runPromise(svc.push(...input)),
|
||||
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
||||
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
||||
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, 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)),
|
||||
@@ -146,51 +186,74 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use(gotoSession)
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const root = await createTestProject()
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
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))
|
||||
await waitSession(page, { directory: root, sessionID })
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const trackSession = (sessionID: string, directory?: string) => {
|
||||
sessions.set(sessionID, directory ?? root)
|
||||
}
|
||||
|
||||
const trackDirectory = (directory: string) => {
|
||||
if (directory !== root) dirs.add(directory)
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
const slug = await waitSlug(page)
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
} finally {
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
})
|
||||
await use((callback, options) => runProject(page, callback, options))
|
||||
},
|
||||
withBackendProject: async ({ page, backend }, use) => {
|
||||
await use((callback, options) =>
|
||||
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
async function runProject<T>(
|
||||
page: Page,
|
||||
callback: (project: ProjectHandle) => Promise<T>,
|
||||
options?: ProjectOptions & {
|
||||
serverUrl?: string
|
||||
sdk?: (directory?: string) => ReturnType<typeof createSdk>
|
||||
},
|
||||
) {
|
||||
const url = options?.serverUrl
|
||||
const root = await createTestProject(url ? { serverUrl: url } : undefined)
|
||||
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await options?.setup?.(root)
|
||||
await seedStorage(page, {
|
||||
directory: root,
|
||||
extra: options?.extra,
|
||||
model: options?.model,
|
||||
serverUrl: url,
|
||||
})
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await waitSession(page, { directory: root, sessionID, serverUrl: url })
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const trackSession = (sessionID: string, directory?: string) => {
|
||||
sessions.set(sessionID, directory ?? root)
|
||||
}
|
||||
|
||||
const trackDirectory = (directory: string) => {
|
||||
if (directory !== root) dirs.add(directory)
|
||||
}
|
||||
|
||||
try {
|
||||
await options?.beforeGoto?.({ directory: root, sdk })
|
||||
await gotoSession()
|
||||
const slug = await waitSlug(page)
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
|
||||
} finally {
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
}
|
||||
|
||||
async function seedStorage(
|
||||
page: Page,
|
||||
input: {
|
||||
directory: string
|
||||
extra?: string[]
|
||||
model?: { providerID: string; modelID: string }
|
||||
serverUrl?: string
|
||||
},
|
||||
) {
|
||||
await seedProjects(page, input)
|
||||
|
||||
46
packages/app/e2e/prompt/mock.ts
Normal file
46
packages/app/e2e/prompt/mock.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
|
||||
|
||||
type Hit = { body: Record<string, unknown> }
|
||||
|
||||
export function bodyText(hit: Hit) {
|
||||
return JSON.stringify(hit.body)
|
||||
}
|
||||
|
||||
export function titleMatch(hit: Hit) {
|
||||
return bodyText(hit).includes("Generate a title for this conversation")
|
||||
}
|
||||
|
||||
export function promptMatch(token: string) {
|
||||
return (hit: Hit) => bodyText(hit).includes(token)
|
||||
}
|
||||
|
||||
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
|
||||
const sdk = createSdk(undefined, input.serverUrl)
|
||||
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
|
||||
|
||||
try {
|
||||
await sdk.global.config.update({
|
||||
config: {
|
||||
...prev,
|
||||
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
|
||||
enabled_providers: ["openai"],
|
||||
provider: {
|
||||
...prev.provider,
|
||||
openai: {
|
||||
...prev.provider?.openai,
|
||||
options: {
|
||||
...prev.provider?.openai?.options,
|
||||
apiKey: "test-key",
|
||||
baseURL: input.llmUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return await input.fn()
|
||||
} finally {
|
||||
await sdk.global.config.update({ config: prev })
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,52 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { assistantText, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
|
||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||
// the connection open while the agent works, causing "Failed to fetch" over
|
||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
||||
test("prompt succeeds when sync message endpoint is unreachable", async ({
|
||||
page,
|
||||
llm,
|
||||
backend,
|
||||
withBackendProject,
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||
|
||||
await gotoSession()
|
||||
await withMockOpenAI({
|
||||
serverUrl: backend.url,
|
||||
llmUrl: llm.url,
|
||||
fn: async () => {
|
||||
const token = `E2E_ASYNC_${Date.now()}`
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(token), token)
|
||||
|
||||
const token = `E2E_ASYNC_${Date.now()}`
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
project.trackSession(sessionID)
|
||||
|
||||
try {
|
||||
// Agent response arrives via SSE despite sync endpoint being dead
|
||||
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")
|
||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { withSession } from "../actions"
|
||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
type Sdk = ReturnType<typeof createSdk>
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
@@ -13,54 +16,15 @@ const isBash = (part: unknown): part is ToolPart => {
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
async function edge(page: Page, pos: "start" | "end") {
|
||||
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||
const nodes: Text[] = []
|
||||
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
||||
nodes.push(node as Text)
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
const node = document.createTextNode("")
|
||||
el.appendChild(node)
|
||||
nodes.push(node)
|
||||
}
|
||||
|
||||
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
||||
const range = document.createRange()
|
||||
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}, pos)
|
||||
}
|
||||
|
||||
async function wait(page: Page, value: string) {
|
||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||
}
|
||||
|
||||
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((item) => item.info.role === "assistant")
|
||||
.flatMap((item) => item.parts)
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
async function reply(sdk: Sdk, sessionID: string, token: string) {
|
||||
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||
}
|
||||
|
||||
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
||||
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -79,106 +43,133 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({
|
||||
page,
|
||||
llm,
|
||||
backend,
|
||||
withBackendProject,
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await withMockOpenAI({
|
||||
serverUrl: backend.url,
|
||||
llmUrl: llm.url,
|
||||
fn: async () => {
|
||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||
const first = `Reply with exactly: ${firstToken}`
|
||||
const second = `Reply with exactly: ${secondToken}`
|
||||
const draft = `draft ${Date.now()}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||
const first = `Reply with exactly: ${firstToken}`
|
||||
const second = `Reply with exactly: ${secondToken}`
|
||||
const draft = `draft ${Date.now()}`
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(firstToken), firstToken)
|
||||
await llm.textMatch(promptMatch(secondToken), secondToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, firstToken)
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, secondToken)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
project.trackSession(sessionID)
|
||||
await reply(project.sdk, sessionID, firstToken)
|
||||
|
||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
await prompt.click()
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(project.sdk, sessionID, secondToken)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||
const first = `echo ${firstToken}`
|
||||
const second = `echo ${secondToken}`
|
||||
const normal = `Reply with exactly: ${normalToken}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||
const first = `echo ${firstToken}`
|
||||
const second = `echo ${secondToken}`
|
||||
const normal = `Reply with exactly: ${normalToken}`
|
||||
await gotoSession()
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, first, firstToken)
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, second, secondToken)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
await shell(sdk, sessionID, first, firstToken)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, sessionID, second, secondToken)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(normal)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, normalToken)
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, normal)
|
||||
})
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(normal)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, sessionID, normalToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, normal)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
@@ -11,13 +10,12 @@ const isBash = (part: unknown): part is ToolPart => {
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||
test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
const sdk = createSdk(directory)
|
||||
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
||||
|
||||
await gotoSession()
|
||||
await prompt.click()
|
||||
|
||||
@@ -22,43 +22,46 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||
test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await seed(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await seed(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/share")
|
||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/share")
|
||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/unshare")
|
||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/unshare")
|
||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,44 +1,9 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { createSdk } from "../utils"
|
||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||
|
||||
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("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const pageErrors: string[] = []
|
||||
@@ -48,48 +13,41 @@ test("can send a prompt and receive a reply", async ({ page, llm, withProject })
|
||||
page.on("pageerror", onPageError)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await withMockOpenAI({
|
||||
serverUrl: backend.url,
|
||||
llmUrl: llm.url,
|
||||
fn: async () => {
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
|
||||
await llm.text(token)
|
||||
await project.gotoSession()
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(token), token)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
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 })
|
||||
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)
|
||||
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)
|
||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
)
|
||||
},
|
||||
{
|
||||
model: { providerID: "e2e-llm", modelID: "test-model" },
|
||||
setup: (dir) => config(dir, llm.url),
|
||||
},
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { seedSessionTask, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
||||
test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const errs: string[] = []
|
||||
@@ -10,28 +11,31 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
||||
}
|
||||
page.on("pageerror", onError)
|
||||
|
||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||
const child = await seedSessionTask(sdk, {
|
||||
sessionID: session.id,
|
||||
description: "Open child session",
|
||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||
try {
|
||||
await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
|
||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||
const child = await seedSessionTask(sdk, {
|
||||
sessionID: session.id,
|
||||
description: "Open child session",
|
||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||
})
|
||||
trackSession(child.sessionID)
|
||||
|
||||
await gotoSession(session.id)
|
||||
|
||||
const link = page
|
||||
.locator("a.subagent-link")
|
||||
.filter({ hasText: /open child session/i })
|
||||
.first()
|
||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
|
||||
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const link = page
|
||||
.locator("a.subagent-link")
|
||||
.filter({ hasText: /open child session/i })
|
||||
.first()
|
||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(errs).toEqual([])
|
||||
} finally {
|
||||
page.off("pageerror", onError)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
page.off("pageerror", onError)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,12 +22,13 @@ async function withDockSession<T>(
|
||||
sdk: Sdk,
|
||||
title: string,
|
||||
fn: (session: { id: string; title: string }) => Promise<T>,
|
||||
opts?: { permission?: PermissionRule[] },
|
||||
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
|
||||
) {
|
||||
const session = await sdk.session
|
||||
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
||||
.then((r) => r.data)
|
||||
if (!session?.id) throw new Error("Session create did not return an id")
|
||||
opts?.trackSession?.(session.id)
|
||||
try {
|
||||
return await fn(session)
|
||||
} finally {
|
||||
@@ -256,350 +257,429 @@ async function withMockPermission<T>(
|
||||
}
|
||||
}
|
||||
|
||||
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock default",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await expect(page.locator(promptSelector)).toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||
|
||||
await setAutoAccept(page, true)
|
||||
await setAutoAccept(page, false)
|
||||
})
|
||||
|
||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock question", 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)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_once",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
await page.locator(promptSelector).click()
|
||||
await expect(page.locator(promptSelector)).toBeFocused()
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_reject",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async ({ gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await clearPermissionDock(page, /deny/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||
|
||||
await setAutoAccept(page, true)
|
||||
await setAutoAccept(page, false)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock question",
|
||||
async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(project.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)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_always",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock question keyboard",
|
||||
async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await clearPermissionDock(page, /allow always/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
await seedSessionQuestion(project.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)
|
||||
})
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock question escape",
|
||||
async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(project.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)
|
||||
})
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock permission once",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_once",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock permission reject",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_reject",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /deny/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock permission always",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_always",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow always/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
withBackendProject,
|
||||
}) => {
|
||||
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock child question parent",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child question",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
const child = await project.sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child question",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
project.trackSession(child.id)
|
||||
|
||||
try {
|
||||
await withDockSeed(sdk, child.id, async () => {
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: child.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Child input",
|
||||
question: "Pick one child option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue child" },
|
||||
{ label: "Stop", description: "Stop child" },
|
||||
try {
|
||||
await withDockSeed(project.sdk, child.id, async () => {
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: child.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Child input",
|
||||
question: "Pick one child option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue child" },
|
||||
{ label: "Stop", description: "Stop child" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expectQuestionBlocked(page)
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
} finally {
|
||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||
}
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
withBackendProject,
|
||||
}) => {
|
||||
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock child permission parent",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child permission",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
const child = await project.sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child permission",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
project.trackSession(child.id)
|
||||
|
||||
try {
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_child",
|
||||
sessionID: child.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||
metadata: { description: "Need child permission" },
|
||||
},
|
||||
{ child },
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
try {
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_child",
|
||||
sessionID: child.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||
metadata: { description: "Need child permission" },
|
||||
},
|
||||
{ child },
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||
}
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||
const dock = await todoDock(page, session.id)
|
||||
await gotoSession(session.id)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock todo",
|
||||
async (session) => {
|
||||
const dock = await todoDock(page, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
|
||||
try {
|
||||
await dock.open([
|
||||
{ content: "first task", status: "pending", priority: "high" },
|
||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||
])
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
try {
|
||||
await dock.open([
|
||||
{ content: "first task", status: "pending", priority: "high" },
|
||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||
])
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.collapse()
|
||||
await dock.expectCollapsed(["pending", "in_progress"])
|
||||
await dock.collapse()
|
||||
await dock.expectCollapsed(["pending", "in_progress"])
|
||||
|
||||
await dock.expand()
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
await dock.expand()
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.finish([
|
||||
{ content: "first task", status: "completed", priority: "high" },
|
||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||
])
|
||||
await dock.expectClosed()
|
||||
} finally {
|
||||
await dock.clear()
|
||||
}
|
||||
await dock.finish([
|
||||
{ content: "first task", status: "completed", priority: "high" },
|
||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||
])
|
||||
await dock.expectClosed()
|
||||
} finally {
|
||||
await dock.clear()
|
||||
}
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock keyboard",
|
||||
async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [{ label: "Continue", description: "Continue now" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [{ label: "Continue", description: "Continue now" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.type("abc")
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
})
|
||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.type("abc")
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
})
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,15 +49,16 @@ async function seedConversation(input: {
|
||||
return { prompt, userMessageID }
|
||||
}
|
||||
|
||||
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
||||
test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `undo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await withBackendProject(async (project) => {
|
||||
const sdk = project.sdk
|
||||
|
||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||
@@ -81,15 +82,16 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withProj
|
||||
})
|
||||
})
|
||||
|
||||
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
||||
test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `redo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await withBackendProject(async (project) => {
|
||||
const sdk = project.sdk
|
||||
|
||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||
@@ -128,16 +130,17 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
|
||||
})
|
||||
})
|
||||
|
||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
|
||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const firstToken = `undo_redo_first_${Date.now()}`
|
||||
const secondToken = `undo_redo_second_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await withBackendProject(async (project) => {
|
||||
const sdk = project.sdk
|
||||
|
||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const first = await seedConversation({
|
||||
|
||||
@@ -31,144 +31,156 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
|
||||
const stamp = Date.now()
|
||||
const originalTitle = `e2e rename test ${stamp}`
|
||||
const renamedTitle = `e2e renamed ${stamp}`
|
||||
|
||||
await withSession(sdk, originalTitle, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, originalTitle, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(renamedTitle)
|
||||
await expect(input).toHaveValue(renamedTitle)
|
||||
await input.press("Enter")
|
||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(renamedTitle)
|
||||
await expect(input).toHaveValue(renamedTitle)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.title
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(renamedTitle)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.title
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(renamedTitle)
|
||||
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be archived via header menu", async ({ page, withBackendProject }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e archive test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, title, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.time?.archived
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.time?.archived
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e delete test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, title, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session
|
||||
.get({ sessionID: session.id })
|
||||
.then((r) => r.data)
|
||||
.catch(() => undefined)
|
||||
return data?.id
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session
|
||||
.get({ sessionID: session.id })
|
||||
.then((r) => r.data)
|
||||
.catch(() => undefined)
|
||||
return data?.id
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
const stamp = Date.now()
|
||||
const title = `e2e share test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, title, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const shared = await openSharePopover(page)
|
||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||
await publish.click()
|
||||
const shared = await openSharePopover(page)
|
||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||
await publish.click()
|
||||
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||
await unpublish.click()
|
||||
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||
await unpublish.click()
|
||||
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
const unshared = await openSharePopover(page)
|
||||
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
const unshared = await openSharePopover(page)
|
||||
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
|
||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||
export const terminalToggleKey = "Control+Backquote"
|
||||
|
||||
export function createSdk(directory?: string) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
export function createSdk(directory?: string, baseUrl = serverUrl) {
|
||||
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
export async function resolveDirectory(directory: string) {
|
||||
return createSdk(directory)
|
||||
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
|
||||
return createSdk(directory, baseUrl)
|
||||
.path.get()
|
||||
.then((x) => x.data?.directory ?? directory)
|
||||
}
|
||||
|
||||
export async function getWorktree() {
|
||||
const sdk = createSdk()
|
||||
export async function getWorktree(baseUrl = serverUrl) {
|
||||
const sdk = createSdk(undefined, baseUrl)
|
||||
const result = await sdk.path.get()
|
||||
const data = result.data
|
||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
|
||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
|
||||
return data.worktree
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function handler(
|
||||
session: sessionId,
|
||||
request: requestId,
|
||||
client: ocClient,
|
||||
...(model === "mimo-v2-pro-free" && JSON.stringify(body).length < 1000 ? { payload: JSON.stringify(body) } : {}),
|
||||
})
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
const modelInfo = validateModel(zenData, model)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
@@ -175,9 +175,8 @@ export namespace Account {
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
@@ -208,6 +207,30 @@ export namespace Account {
|
||||
return parsed.access_token
|
||||
})
|
||||
|
||||
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
timeToLive: Duration.zero,
|
||||
lookup: Effect.fnUntraced(function* (accountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) {
|
||||
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
|
||||
}
|
||||
|
||||
const account = maybeAccount.value
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (account.token_expiry && account.token_expiry > now) return account.access_token
|
||||
|
||||
return yield* refreshToken(account)
|
||||
}),
|
||||
})
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
return yield* Cache.get(refreshTokenCache, row.id)
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace Bus {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Bus.state")(function* (ctx) {
|
||||
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
@@ -82,13 +82,13 @@ export namespace Bus {
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = state.typed.get(def.type)
|
||||
const ps = s.typed.get(def.type)
|
||||
if (ps) yield* PubSub.publish(ps, payload)
|
||||
yield* PubSub.publish(state.wildcard, payload)
|
||||
yield* PubSub.publish(s.wildcard, payload)
|
||||
|
||||
const dir = yield* InstanceState.directory
|
||||
GlobalBus.emit("event", {
|
||||
@@ -102,8 +102,8 @@ export namespace Bus {
|
||||
log.info("subscribing", { type: def.type })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const ps = yield* getOrCreate(state, def)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const ps = yield* getOrCreate(s, def)
|
||||
return Stream.fromPubSub(ps)
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||
@@ -113,8 +113,8 @@ export namespace Bus {
|
||||
log.info("subscribing", { type: "*" })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return Stream.fromPubSub(state.wildcard)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Stream.fromPubSub(s.wildcard)
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
||||
}
|
||||
@@ -150,14 +150,14 @@ export namespace Bus {
|
||||
def: D,
|
||||
callback: (event: Payload<D>) => unknown,
|
||||
) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const ps = yield* getOrCreate(state, def)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const ps = yield* getOrCreate(s, def)
|
||||
return yield* on(ps, def.type, callback)
|
||||
})
|
||||
|
||||
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return yield* on(state.wildcard, "*", callback)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* on(s.wildcard, "*", callback)
|
||||
})
|
||||
|
||||
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
||||
|
||||
@@ -27,7 +27,6 @@ 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>
|
||||
@@ -35,13 +34,6 @@ 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 {
|
||||
@@ -57,11 +49,6 @@ 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)
|
||||
@@ -315,40 +302,12 @@ 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
|
||||
@@ -361,7 +320,7 @@ export const RunCommand = cmd({
|
||||
}
|
||||
})()
|
||||
|
||||
const files: FilePart[] = []
|
||||
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
|
||||
if (args.file) {
|
||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||
|
||||
@@ -385,7 +344,7 @@ export const RunCommand = cmd({
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
if (message.trim().length === 0 && !args.command && !args.interactive) {
|
||||
if (message.trim().length === 0 && !args.command) {
|
||||
UI.error("You must provide a message or a command")
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -419,78 +378,19 @@ export const RunCommand = cmd({
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
||||
if (args.session) {
|
||||
const current = await sdk.session
|
||||
.get({
|
||||
sessionID: args.session,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
async function session(sdk: OpencodeClient) {
|
||||
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
|
||||
|
||||
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 && args.fork) {
|
||||
const forked = await sdk.session.fork({ sessionID: baseID })
|
||||
return forked.data?.id
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
if (baseID) return baseID
|
||||
|
||||
const name = title()
|
||||
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,
|
||||
}
|
||||
const result = await sdk.session.create({ title: name, permission: rules })
|
||||
return result.data?.id
|
||||
}
|
||||
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
@@ -532,27 +432,21 @@ 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
|
||||
}
|
||||
|
||||
async function loop(events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
|
||||
const events = await sdk.event.subscribe()
|
||||
let error: string | undefined
|
||||
|
||||
async function loop() {
|
||||
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
|
||||
@@ -725,33 +619,28 @@ export const RunCommand = cmd({
|
||||
return args.agent
|
||||
})()
|
||||
|
||||
const sess = await session(sdk)
|
||||
if (!sess?.id) {
|
||||
const sessionID = await session(sdk)
|
||||
if (!sessionID) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
const sessionID = sess.id
|
||||
await share(sdk, sessionID)
|
||||
|
||||
if (!args.interactive) {
|
||||
const events = await sdk.event.subscribe()
|
||||
loop(events).catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
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.command) {
|
||||
await sdk.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
} else {
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
await sdk.session.prompt({
|
||||
sessionID,
|
||||
@@ -760,23 +649,7 @@ 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) {
|
||||
@@ -787,11 +660,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -800,10 +669,7 @@ 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)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
/** @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>
|
||||
)
|
||||
}
|
||||
@@ -1,651 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
|
||||
export type RunInput = {
|
||||
sdk: OpencodeClient
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
resume?: boolean
|
||||
agent: string | undefined
|
||||
model: PromptModel | undefined
|
||||
variant: string | undefined
|
||||
files: RunFilePart[]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
}
|
||||
|
||||
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
|
||||
|
||||
export type FooterPhase = "idle" | "running"
|
||||
|
||||
export type FooterState = {
|
||||
phase: FooterPhase
|
||||
status: string
|
||||
queue: number
|
||||
model: string
|
||||
duration: string
|
||||
usage: string
|
||||
first: boolean
|
||||
interrupt: number
|
||||
exit: number
|
||||
}
|
||||
|
||||
export type FooterPatch = Partial<FooterState>
|
||||
|
||||
export type FooterKeybinds = {
|
||||
leader: string
|
||||
variantCycle: string
|
||||
interrupt: string
|
||||
historyPrevious: string
|
||||
historyNext: string
|
||||
inputSubmit: string
|
||||
inputNewline: string
|
||||
}
|
||||
|
||||
export type FooterApi = {
|
||||
readonly isClosed: boolean
|
||||
onPrompt(fn: (text: string) => void): () => void
|
||||
onClose(fn: () => void): () => void
|
||||
patch(next: FooterPatch): void
|
||||
append(kind: EntryKind, text: string): void
|
||||
close(): void
|
||||
destroy(): void
|
||||
}
|
||||
@@ -251,7 +251,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
|
||||
@@ -57,7 +57,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return agents()
|
||||
},
|
||||
current() {
|
||||
return agents().find((x) => x.name === agentStore.current)!
|
||||
return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
|
||||
},
|
||||
set(name: string) {
|
||||
if (!agents().some((x) => x.name === name))
|
||||
|
||||
@@ -509,9 +509,7 @@ 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))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
|
||||
|
||||
@@ -4,44 +4,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export namespace Command {
|
||||
|
||||
commands[Default.INIT] = {
|
||||
name: Default.INIT,
|
||||
description: "create/update AGENTS.md",
|
||||
description: "guided AGENTS.md setup",
|
||||
source: "command",
|
||||
get template() {
|
||||
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
||||
@@ -161,16 +161,16 @@ export namespace Command {
|
||||
}
|
||||
})
|
||||
|
||||
const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
|
||||
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
|
||||
|
||||
const get = Effect.fn("Command.get")(function* (name: string) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return state.commands[name]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return s.commands[name]
|
||||
})
|
||||
|
||||
const list = Effect.fn("Command.list")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return Object.values(state.commands)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Object.values(s.commands)
|
||||
})
|
||||
|
||||
return Service.of({ get, list })
|
||||
|
||||
@@ -1,10 +1,66 @@
|
||||
Please analyze this codebase and create an AGENTS.md file containing:
|
||||
1. Build/lint/test commands - especially for running a single test
|
||||
2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
|
||||
Create or update `AGENTS.md` for this repository.
|
||||
|
||||
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 150 lines long.
|
||||
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
|
||||
|
||||
If there's already an AGENTS.md, improve it if it's located in ${path}
|
||||
The goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out.
|
||||
|
||||
User-provided focus or constraints (honor these):
|
||||
$ARGUMENTS
|
||||
|
||||
## How to investigate
|
||||
|
||||
Read the highest-value sources first:
|
||||
- `README*`, root manifests, workspace config, lockfiles
|
||||
- build, test, lint, formatter, typecheck, and codegen config
|
||||
- CI workflows and pre-commit / task runner config
|
||||
- existing instruction files (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`)
|
||||
- repo-local OpenCode config such as `opencode.json`
|
||||
|
||||
If architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files.
|
||||
|
||||
Prefer executable sources of truth over prose. If docs conflict with config or scripts, trust the executable source and only keep what you can verify.
|
||||
|
||||
## What to extract
|
||||
|
||||
Look for the highest-signal facts for an agent working in this repo:
|
||||
- exact developer commands, especially non-obvious ones
|
||||
- how to run a single test, a single package, or a focused verification step
|
||||
- required command order when it matters, such as `lint -> typecheck -> test`
|
||||
- monorepo or multi-package boundaries, ownership of major directories, and the real app/library entrypoints
|
||||
- framework or toolchain quirks: generated code, migrations, codegen, build artifacts, special env loading, dev servers, infra deploy flow
|
||||
- repo-specific style or workflow conventions that differ from defaults
|
||||
- testing quirks: fixtures, integration test prerequisites, snapshot workflows, required services, flaky or expensive suites
|
||||
- important constraints from existing instruction files worth preserving
|
||||
|
||||
Good `AGENTS.md` content is usually hard-earned context that took reading multiple files to infer.
|
||||
|
||||
## Questions
|
||||
|
||||
Only ask the user questions if the repo cannot answer something important. Use the `question` tool for one short batch at most.
|
||||
|
||||
Good questions:
|
||||
- undocumented team conventions
|
||||
- branch / PR / release expectations
|
||||
- missing setup or test prerequisites that are known but not written down
|
||||
|
||||
Do not ask about anything the repo already makes clear.
|
||||
|
||||
## Writing rules
|
||||
|
||||
Include only high-signal, repo-specific guidance such as:
|
||||
- exact commands and shortcuts the agent would otherwise guess wrong
|
||||
- architecture notes that are not obvious from filenames
|
||||
- conventions that differ from language or framework defaults
|
||||
- setup requirements, environment quirks, and operational gotchas
|
||||
- references to existing instruction sources that matter
|
||||
|
||||
Exclude:
|
||||
- generic software advice
|
||||
- long tutorials or exhaustive file trees
|
||||
- obvious language conventions
|
||||
- speculative claims or anything you could not verify
|
||||
- content better stored in another file referenced via `opencode.json` `instructions`
|
||||
|
||||
When in doubt, omit.
|
||||
|
||||
Prefer short sections and bullets. If the repo is simple, keep the file simple. If the repo is large, summarize the few structural facts that actually change how an agent should work.
|
||||
|
||||
If `AGENTS.md` already exists at `${path}`, improve it in place rather than rewriting blindly. Preserve verified useful guidance, delete fluff or stale claims, and reconcile it with the current codebase.
|
||||
|
||||
@@ -386,9 +386,17 @@ export const make = Effect.gen(function* () {
|
||||
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
|
||||
return yield* Effect.void
|
||||
}
|
||||
return yield* kill((command, proc, signal) =>
|
||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
|
||||
const send = (s: NodeJS.Signals) =>
|
||||
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
|
||||
const sig = command.options.killSignal ?? "SIGTERM"
|
||||
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
|
||||
const escalated = command.options.forceKillAfter
|
||||
? Effect.timeoutOrElse(attempt, {
|
||||
duration: command.options.forceKillAfter,
|
||||
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
})
|
||||
: attempt
|
||||
return yield* Effect.ignore(escalated)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -413,14 +421,17 @@ export const make = Effect.gen(function* () {
|
||||
),
|
||||
)
|
||||
}),
|
||||
kill: (opts?: ChildProcess.KillOptions) =>
|
||||
timeout(
|
||||
proc,
|
||||
command,
|
||||
opts,
|
||||
)((command, proc, signal) =>
|
||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
kill: (opts?: ChildProcess.KillOptions) => {
|
||||
const sig = opts?.killSignal ?? "SIGTERM"
|
||||
const send = (s: NodeJS.Signals) =>
|
||||
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
|
||||
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
|
||||
if (!opts?.forceKillAfter) return attempt
|
||||
return Effect.timeoutOrElse(attempt, {
|
||||
duration: opts.forceKillAfter,
|
||||
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
case "PipedCommand": {
|
||||
|
||||
@@ -24,9 +24,9 @@ export namespace InstanceState {
|
||||
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
|
||||
}
|
||||
|
||||
export const context = Effect.fnUntraced(function* () {
|
||||
export const context = Effect.gen(function* () {
|
||||
return (yield* InstanceRef) ?? Instance.current
|
||||
})()
|
||||
})
|
||||
|
||||
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
||||
|
||||
@@ -37,9 +37,9 @@ export namespace InstanceState {
|
||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
lookup: () =>
|
||||
Effect.fnUntraced(function* () {
|
||||
Effect.gen(function* () {
|
||||
return yield* init(yield* context)
|
||||
})(),
|
||||
}),
|
||||
})
|
||||
|
||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AppFileSystem } from "@/filesystem"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fs from "fs"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
@@ -359,49 +358,46 @@ export namespace File {
|
||||
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = await fs.promises
|
||||
.readdir(Instance.directory, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
|
||||
|
||||
for (const entry of top) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
for (const entry of top) {
|
||||
if (entry.type !== "directory") continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
const base = path.join(Instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const seen = new Set<string>()
|
||||
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
const base = path.join(Instance.directory, entry.name)
|
||||
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
|
||||
for (const child of children) {
|
||||
if (child.type !== "directory") continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const files = yield* Effect.promise(() => Array.fromAsync(Ripgrep.files({ cwd: Instance.directory })))
|
||||
const seen = new Set<string>()
|
||||
for (const file of files) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.cache = next
|
||||
@@ -636,30 +632,27 @@ export namespace File {
|
||||
yield* ensure()
|
||||
const { cache } = yield* InstanceState.get(state)
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const query = input.query.trim()
|
||||
const limit = input.limit ?? 100
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
const query = input.query.trim()
|
||||
const limit = input.limit ?? 100
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
|
||||
const result = cache
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
|
||||
if (!query) {
|
||||
if (kind === "file") return result.files.slice(0, limit)
|
||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||
}
|
||||
if (!query) {
|
||||
if (kind === "file") return cache.files.slice(0, limit)
|
||||
return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||
}
|
||||
|
||||
const items =
|
||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||
const items =
|
||||
kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
|
||||
|
||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||
|
||||
log.info("search", { query, kind, results: output.length })
|
||||
return output
|
||||
})
|
||||
log.info("search", { query, kind, results: output.length })
|
||||
return output
|
||||
})
|
||||
|
||||
log.info("init")
|
||||
|
||||
@@ -477,7 +477,7 @@ export namespace MCP {
|
||||
})
|
||||
}
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("MCP.state")(function* () {
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
@@ -549,7 +549,7 @@ export namespace MCP {
|
||||
}
|
||||
|
||||
const status = Effect.fn("MCP.status")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
@@ -564,12 +564,12 @@ export namespace MCP {
|
||||
})
|
||||
|
||||
const clients = Effect.fn("MCP.clients")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return s.clients
|
||||
})
|
||||
|
||||
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const result = yield* create(name, mcp)
|
||||
|
||||
s.status[name] = result.status
|
||||
@@ -588,7 +588,7 @@ export namespace MCP {
|
||||
|
||||
const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
|
||||
yield* createAndStore(name, mcp)
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return { status: s.status }
|
||||
})
|
||||
|
||||
@@ -602,7 +602,7 @@ export namespace MCP {
|
||||
})
|
||||
|
||||
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
yield* closeClient(s, name)
|
||||
delete s.clients[name]
|
||||
s.status[name] = { status: "disabled" }
|
||||
@@ -610,7 +610,7 @@ export namespace MCP {
|
||||
|
||||
const tools = Effect.fn("MCP.tools")(function* () {
|
||||
const result: Record<string, Tool> = {}
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
@@ -657,12 +657,12 @@ export namespace MCP {
|
||||
}
|
||||
|
||||
const prompts = Effect.fn("MCP.prompts")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
|
||||
})
|
||||
|
||||
const resources = Effect.fn("MCP.resources")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
|
||||
})
|
||||
|
||||
@@ -672,7 +672,7 @@ export namespace MCP {
|
||||
label: string,
|
||||
meta?: Record<string, unknown>,
|
||||
) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const client = s.clients[clientName]
|
||||
if (!client) {
|
||||
log.warn(`client not found for ${label}`, { clientName })
|
||||
|
||||
@@ -103,7 +103,7 @@ export namespace Plugin {
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Plugin.state")(function* (ctx) {
|
||||
const hooks: Hooks[] = []
|
||||
|
||||
@@ -279,8 +279,8 @@ export namespace Plugin {
|
||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||
>(name: Name, input: Input, output: Output) {
|
||||
if (!name) return output
|
||||
const state = yield* InstanceState.get(cache)
|
||||
for (const hook of state.hooks) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
for (const hook of s.hooks) {
|
||||
const fn = hook[name] as any
|
||||
if (!fn) continue
|
||||
yield* Effect.promise(async () => fn(input, output))
|
||||
@@ -289,12 +289,12 @@ export namespace Plugin {
|
||||
})
|
||||
|
||||
const list = Effect.fn("Plugin.list")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return state.hooks
|
||||
const s = yield* InstanceState.get(state)
|
||||
return s.hooks
|
||||
})
|
||||
|
||||
const init = Effect.fn("Plugin.init")(function* () {
|
||||
yield* InstanceState.get(cache)
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
return Service.of({ trigger, list, init })
|
||||
|
||||
@@ -111,26 +111,25 @@ export namespace ProviderAuth {
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ProviderAuth.state")(() =>
|
||||
Effect.promise(async () => {
|
||||
const plugins = await Plugin.list()
|
||||
return {
|
||||
hooks: Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
Effect.fn("ProviderAuth.state")(function* () {
|
||||
const plugins = yield* plugin.list()
|
||||
return {
|
||||
hooks: Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
pending: new Map<ProviderID, AuthOAuthResult>(),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
pending: new Map<ProviderID, AuthOAuthResult>(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||
@@ -230,7 +229,9 @@ export namespace ProviderAuth {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.defaultLayer))
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -961,13 +961,14 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service> = Layer.effect(
|
||||
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(() =>
|
||||
const state = yield* InstanceState.make<State>(() =>
|
||||
Effect.gen(function* () {
|
||||
using _ = log.time("state")
|
||||
const cfg = yield* config.get()
|
||||
@@ -1128,7 +1129,7 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = yield* Effect.promise(() => Plugin.list())
|
||||
const plugins = yield* plugin.list()
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.auth) continue
|
||||
const providerID = ProviderID.make(plugin.auth.provider)
|
||||
@@ -1247,7 +1248,7 @@ export namespace Provider {
|
||||
}),
|
||||
)
|
||||
|
||||
const list = Effect.fn("Provider.list")(() => InstanceState.use(cache, (s) => s.providers))
|
||||
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
|
||||
|
||||
async function resolveSDK(model: Model, s: State) {
|
||||
try {
|
||||
@@ -1385,11 +1386,11 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
|
||||
InstanceState.use(cache, (s) => s.providers[providerID]),
|
||||
InstanceState.use(state, (s) => s.providers[providerID]),
|
||||
)
|
||||
|
||||
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) {
|
||||
const available = Object.keys(s.providers)
|
||||
@@ -1407,7 +1408,7 @@ export namespace Provider {
|
||||
})
|
||||
|
||||
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const key = `${model.providerID}/${model.id}`
|
||||
if (s.models.has(key)) return s.models.get(key)!
|
||||
|
||||
@@ -1439,7 +1440,7 @@ export namespace Provider {
|
||||
})
|
||||
|
||||
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) return undefined
|
||||
for (const item of query) {
|
||||
@@ -1458,7 +1459,7 @@ export namespace Provider {
|
||||
return yield* getModel(parsed.providerID, parsed.modelID)
|
||||
}
|
||||
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) return undefined
|
||||
|
||||
@@ -1510,7 +1511,7 @@ export namespace Provider {
|
||||
const cfg = yield* config.get()
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const recent = yield* Effect.promise(() =>
|
||||
Filesystem.readJson<{
|
||||
recent?: { providerID: ProviderID; modelID: ModelID }[]
|
||||
@@ -1541,7 +1542,13 @@ export namespace Provider {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer))
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export namespace Pty {
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Pty.state")(function* (ctx) {
|
||||
const state = {
|
||||
dir: ctx.directory,
|
||||
@@ -151,27 +151,27 @@ export namespace Pty {
|
||||
)
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (!session) return
|
||||
state.sessions.delete(id)
|
||||
s.sessions.delete(id)
|
||||
log.info("removing session", { id })
|
||||
teardown(session)
|
||||
void Bus.publish(Event.Deleted, { id: session.info.id })
|
||||
})
|
||||
|
||||
const list = Effect.fn("Pty.list")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return Array.from(state.sessions.values()).map((session) => session.info)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Array.from(s.sessions.values()).map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return state.sessions.get(id)?.info
|
||||
const s = yield* InstanceState.get(state)
|
||||
return s.sessions.get(id)?.info
|
||||
})
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || Shell.preferred()
|
||||
@@ -180,7 +180,7 @@ export namespace Pty {
|
||||
args.push("-l")
|
||||
}
|
||||
|
||||
const cwd = input.cwd || state.dir
|
||||
const cwd = input.cwd || s.dir
|
||||
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -221,7 +221,7 @@ export namespace Pty {
|
||||
cursor: 0,
|
||||
subscribers: new Map(),
|
||||
}
|
||||
state.sessions.set(id, session)
|
||||
s.sessions.set(id, session)
|
||||
proc.onData(
|
||||
Instance.bind((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
@@ -264,8 +264,8 @@ export namespace Pty {
|
||||
})
|
||||
|
||||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (!session) return
|
||||
if (input.title) {
|
||||
session.info.title = input.title
|
||||
@@ -278,24 +278,24 @@ export namespace Pty {
|
||||
})
|
||||
|
||||
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (session && session.info.status === "running") {
|
||||
session.process.resize(cols, rows)
|
||||
}
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (session && session.info.status === "running") {
|
||||
session.process.write(data)
|
||||
}
|
||||
})
|
||||
|
||||
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (!session) {
|
||||
ws.close()
|
||||
return
|
||||
|
||||
@@ -28,7 +28,9 @@ import { ReadTool } from "../tool/read"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Command } from "../command"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
@@ -96,9 +98,10 @@ export namespace SessionPrompt {
|
||||
const filetime = yield* FileTime.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const cache = yield* InstanceState.make(
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("SessionPrompt.state")(function* () {
|
||||
const runners = new Map<string, Runner<MessageV2.WithParts>>()
|
||||
yield* Effect.addFinalizer(
|
||||
@@ -132,14 +135,14 @@ export namespace SessionPrompt {
|
||||
const assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError> = Effect.fn(
|
||||
"SessionPrompt.assertNotBusy",
|
||||
)(function* (sessionID: SessionID) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = s.runners.get(sessionID)
|
||||
if (runner?.busy) throw new Session.BusyError(sessionID)
|
||||
})
|
||||
|
||||
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
||||
log.info("cancel", { sessionID })
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = s.runners.get(sessionID)
|
||||
if (!runner || !runner.busy) {
|
||||
yield* status.set(sessionID, { type: "idle" })
|
||||
@@ -809,22 +812,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
fish: { args: ["-c", input.command] },
|
||||
zsh: {
|
||||
args: [
|
||||
"-c",
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
__oc_cwd=$PWD
|
||||
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||
cd "$__oc_cwd"
|
||||
eval ${JSON.stringify(input.command)}
|
||||
`,
|
||||
],
|
||||
},
|
||||
bash: {
|
||||
args: [
|
||||
"-c",
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
__oc_cwd=$PWD
|
||||
shopt -s expand_aliases
|
||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||
cd "$__oc_cwd"
|
||||
eval ${JSON.stringify(input.command)}
|
||||
`,
|
||||
],
|
||||
@@ -832,7 +839,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
cmd: { args: ["/c", input.command] },
|
||||
powershell: { args: ["-NoProfile", "-Command", input.command] },
|
||||
pwsh: { args: ["-NoProfile", "-Command", input.command] },
|
||||
"": { args: ["-c", `${input.command}`] },
|
||||
"": { args: ["-c", input.command] },
|
||||
}
|
||||
|
||||
const args = (invocations[shellName] ?? invocations[""]).args
|
||||
@@ -842,51 +849,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
||||
{ env: {} },
|
||||
)
|
||||
const proc = yield* Effect.sync(() =>
|
||||
spawn(sh, args, {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
windowsHide: process.platform === "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
...shellEnv.env,
|
||||
TERM: "dumb",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const cmd = ChildProcess.make(sh, args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
env: { ...shellEnv.env, TERM: "dumb" },
|
||||
stdin: "ignore",
|
||||
forceKillAfter: "3 seconds",
|
||||
})
|
||||
|
||||
let output = ""
|
||||
const write = () => {
|
||||
if (part.state.status !== "running") return
|
||||
part.state.metadata = { output, description: "" }
|
||||
void Effect.runFork(sessions.updatePart(part))
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", (chunk) => {
|
||||
output += chunk.toString()
|
||||
write()
|
||||
})
|
||||
proc.stderr?.on("data", (chunk) => {
|
||||
output += chunk.toString()
|
||||
write()
|
||||
})
|
||||
|
||||
let aborted = false
|
||||
let exited = false
|
||||
let finished = false
|
||||
const kill = Effect.promise(() => Shell.killTree(proc, { exited: () => exited }))
|
||||
|
||||
const abortHandler = () => {
|
||||
if (aborted) return
|
||||
aborted = true
|
||||
void Effect.runFork(kill)
|
||||
}
|
||||
|
||||
const finish = Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
if (finished) return
|
||||
finished = true
|
||||
if (aborted) {
|
||||
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
||||
}
|
||||
@@ -908,20 +884,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}),
|
||||
)
|
||||
|
||||
const exit = yield* Effect.promise(() => {
|
||||
signal.addEventListener("abort", abortHandler, { once: true })
|
||||
if (signal.aborted) abortHandler()
|
||||
return new Promise<void>((resolve) => {
|
||||
const close = () => {
|
||||
exited = true
|
||||
proc.off("close", close)
|
||||
resolve()
|
||||
}
|
||||
proc.once("close", close)
|
||||
})
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(cmd)
|
||||
yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
|
||||
Effect.sync(() => {
|
||||
output += chunk
|
||||
if (part.state.status === "running") {
|
||||
part.state.metadata = { output, description: "" }
|
||||
void Effect.runFork(sessions.updatePart(part))
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* handle.exitCode
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() => Effect.sync(abortHandler)),
|
||||
Effect.ensuring(Effect.sync(() => signal.removeEventListener("abort", abortHandler))),
|
||||
Effect.scoped,
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.sync(() => {
|
||||
aborted = true
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
Effect.ensuring(finish),
|
||||
Effect.exit,
|
||||
)
|
||||
@@ -1575,14 +1557,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
|
||||
"SessionPrompt.loop",
|
||||
)(function* (input: z.infer<typeof LoopInput>) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = getRunner(s.runners, input.sessionID)
|
||||
return yield* runner.ensureRunning(runLoop(input.sessionID))
|
||||
})
|
||||
|
||||
const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
|
||||
function* (input: ShellInput) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = getRunner(s.runners, input.sessionID)
|
||||
return yield* runner.startShell((signal) => shellImpl(input, signal))
|
||||
},
|
||||
@@ -1735,6 +1717,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ export namespace ToolRegistry {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
const custom: Tool.Info[] = []
|
||||
|
||||
@@ -139,18 +139,18 @@ export namespace ToolRegistry {
|
||||
})
|
||||
|
||||
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const idx = state.custom.findIndex((t) => t.id === tool.id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const idx = s.custom.findIndex((t) => t.id === tool.id)
|
||||
if (idx >= 0) {
|
||||
state.custom.splice(idx, 1, tool)
|
||||
s.custom.splice(idx, 1, tool)
|
||||
return
|
||||
}
|
||||
state.custom.push(tool)
|
||||
s.custom.push(tool)
|
||||
})
|
||||
|
||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const tools = yield* all(state.custom)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const tools = yield* all(s.custom)
|
||||
return tools.map((t) => t.id)
|
||||
})
|
||||
|
||||
@@ -158,8 +158,8 @@ export namespace ToolRegistry {
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const allTools = yield* all(state.custom)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const allTools = yield* all(s.custom)
|
||||
const filtered = allTools.filter((tool) => {
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
|
||||
@@ -148,6 +148,70 @@ it.live("token refresh persists the new token", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("concurrent config and token requests coalesce token refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
url: "https://one.example.com",
|
||||
accessToken: AccessToken.make("at_old"),
|
||||
refreshToken: RefreshToken.make("rt_old"),
|
||||
expiry: Date.now() - 1_000,
|
||||
orgID: Option.some(OrgID.make("org-9")),
|
||||
}),
|
||||
)
|
||||
|
||||
let refreshCalls = 0
|
||||
const client = HttpClient.make((req) =>
|
||||
Effect.promise(async () => {
|
||||
if (req.url === "https://one.example.com/auth/device/token") {
|
||||
refreshCalls += 1
|
||||
|
||||
if (refreshCalls === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
return json(req, {
|
||||
access_token: "at_new",
|
||||
refresh_token: "rt_new",
|
||||
expires_in: 60,
|
||||
})
|
||||
}
|
||||
|
||||
return json(
|
||||
req,
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh token already used",
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
if (req.url === "https://one.example.com/api/config") {
|
||||
return json(req, { config: { theme: "light", seats: 5 } })
|
||||
}
|
||||
|
||||
return json(req, {}, 404)
|
||||
}),
|
||||
)
|
||||
|
||||
const [cfg, token] = yield* Account.Service.use((s) =>
|
||||
Effect.all([s.config(id, OrgID.make("org-9")), s.token(id)], { concurrency: 2 }),
|
||||
).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
expect(refreshCalls).toBe(1)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("config sends the selected org header", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,436 +0,0 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -1,707 +0,0 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -1,662 +0,0 @@
|
||||
/** @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")
|
||||
})
|
||||
})
|
||||
@@ -1,158 +0,0 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -1,393 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,13 @@ type Hit = {
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Match = (hit: Hit) => boolean
|
||||
|
||||
type Queue = {
|
||||
item: Item
|
||||
match?: Match
|
||||
}
|
||||
|
||||
type Wait = {
|
||||
count: number
|
||||
ready: Deferred.Deferred<void>
|
||||
@@ -420,7 +427,7 @@ const reset = Effect.fn("TestLLMServer.reset")(function* (item: Sse) {
|
||||
for (const part of item.tail) res.write(line(part))
|
||||
res.destroy(new Error("connection reset"))
|
||||
})
|
||||
yield* Effect.never
|
||||
return yield* Effect.never
|
||||
})
|
||||
|
||||
function fail(item: HttpError) {
|
||||
@@ -581,6 +588,9 @@ namespace TestLLMServer {
|
||||
export interface Service {
|
||||
readonly url: string
|
||||
readonly push: (...input: (Item | Reply)[]) => Effect.Effect<void>
|
||||
readonly pushMatch: (match: Match, ...input: (Item | Reply)[]) => Effect.Effect<void>
|
||||
readonly textMatch: (match: Match, value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
|
||||
readonly toolMatch: (match: Match, name: string, input: unknown) => Effect.Effect<void>
|
||||
readonly text: (value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
|
||||
readonly tool: (name: string, input: unknown) => Effect.Effect<void>
|
||||
readonly toolHang: (name: string, input: unknown) => Effect.Effect<void>
|
||||
@@ -605,11 +615,15 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
const router = yield* HttpRouter.HttpRouter
|
||||
|
||||
let hits: Hit[] = []
|
||||
let list: Item[] = []
|
||||
let list: Queue[] = []
|
||||
let waits: Wait[] = []
|
||||
|
||||
const queue = (...input: (Item | Reply)[]) => {
|
||||
list = [...list, ...input.map(item)]
|
||||
list = [...list, ...input.map((value) => ({ item: item(value) }))]
|
||||
}
|
||||
|
||||
const queueMatch = (match: Match, ...input: (Item | Reply)[]) => {
|
||||
list = [...list, ...input.map((value) => ({ item: item(value), match }))]
|
||||
}
|
||||
|
||||
const notify = Effect.fnUntraced(function* () {
|
||||
@@ -619,19 +633,21 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
yield* Effect.forEach(ready, (item) => Deferred.succeed(item.ready, void 0))
|
||||
})
|
||||
|
||||
const pull = () => {
|
||||
const first = list[0]
|
||||
if (!first) return
|
||||
list = list.slice(1)
|
||||
return first
|
||||
const pull = (hit: Hit) => {
|
||||
const index = list.findIndex((entry) => !entry.match || entry.match(hit))
|
||||
if (index === -1) return
|
||||
const first = list[index]
|
||||
list = [...list.slice(0, index), ...list.slice(index + 1)]
|
||||
return first.item
|
||||
}
|
||||
|
||||
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)]
|
||||
const current = hit(req.originalUrl, body)
|
||||
const next = pull(current)
|
||||
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||
hits = [...hits, current]
|
||||
yield* notify()
|
||||
if (next.type !== "sse") return fail(next)
|
||||
if (mode === "responses") return send(responses(next, modelFrom(body)))
|
||||
@@ -655,6 +671,21 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
push: Effect.fn("TestLLMServer.push")(function* (...input: (Item | Reply)[]) {
|
||||
queue(...input)
|
||||
}),
|
||||
pushMatch: Effect.fn("TestLLMServer.pushMatch")(function* (match: Match, ...input: (Item | Reply)[]) {
|
||||
queueMatch(match, ...input)
|
||||
}),
|
||||
textMatch: Effect.fn("TestLLMServer.textMatch")(function* (
|
||||
match: Match,
|
||||
value: string,
|
||||
opts?: { usage?: Usage },
|
||||
) {
|
||||
const out = reply().text(value)
|
||||
if (opts?.usage) out.usage(opts.usage)
|
||||
queueMatch(match, out.stop().item())
|
||||
}),
|
||||
toolMatch: Effect.fn("TestLLMServer.toolMatch")(function* (match: Match, name: string, input: unknown) {
|
||||
queueMatch(match, reply().tool(name, input).item())
|
||||
}),
|
||||
text: Effect.fn("TestLLMServer.text")(function* (value: string, opts?: { usage?: Usage }) {
|
||||
const out = reply().text(value)
|
||||
if (opts?.usage) out.usage(opts.usage)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { expect, spyOn } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
@@ -887,6 +888,79 @@ unix("shell captures stdout and stderr in completed tool output", () =>
|
||||
),
|
||||
)
|
||||
|
||||
unix("shell completes a fast command on the preferred shell", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { prompt, chat } = yield* boot()
|
||||
const result = yield* prompt.shell({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
command: "pwd",
|
||||
})
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
const tool = completedTool(result.parts)
|
||||
if (!tool) return
|
||||
|
||||
expect(tool.state.input.command).toBe("pwd")
|
||||
expect(tool.state.output).toContain(dir)
|
||||
expect(tool.state.metadata.output).toContain(dir)
|
||||
yield* prompt.assertNotBusy(chat.id)
|
||||
}),
|
||||
{ git: true, config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
unix("shell lists files from the project directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { prompt, chat } = yield* boot()
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n"))
|
||||
|
||||
const result = yield* prompt.shell({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
command: "command ls",
|
||||
})
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
const tool = completedTool(result.parts)
|
||||
if (!tool) return
|
||||
|
||||
expect(tool.state.input.command).toBe("command ls")
|
||||
expect(tool.state.output).toContain("README.md")
|
||||
expect(tool.state.metadata.output).toContain("README.md")
|
||||
yield* prompt.assertNotBusy(chat.id)
|
||||
}),
|
||||
{ git: true, config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
unix("shell captures stderr from a failing command", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { prompt, chat } = yield* boot()
|
||||
const result = yield* prompt.shell({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1",
|
||||
})
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
const tool = completedTool(result.parts)
|
||||
if (!tool) return
|
||||
|
||||
expect(tool.state.output).toContain("not found")
|
||||
expect(tool.state.metadata.output).toContain("not found")
|
||||
yield* prompt.assertNotBusy(chat.id)
|
||||
}),
|
||||
{ git: true, config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
unix(
|
||||
"shell updates running metadata before process exit",
|
||||
() =>
|
||||
|
||||
@@ -15,9 +15,17 @@ To create a new `AGENTS.md` file, you can run the `/init` command in opencode.
|
||||
You should commit your project's `AGENTS.md` file to Git.
|
||||
:::
|
||||
|
||||
This will scan your project and all its contents to understand what the project is about and generate an `AGENTS.md` file with it. This helps opencode to navigate the project better.
|
||||
`/init` scans the important files in your repo, may ask a couple of targeted questions when the codebase cannot answer them, and then creates or updates `AGENTS.md` with concise project-specific guidance.
|
||||
|
||||
If you have an existing `AGENTS.md` file, this will try to add to it.
|
||||
It focuses on the things future agent sessions are most likely to need:
|
||||
|
||||
- build, lint, and test commands
|
||||
- command order and focused verification steps when they matter
|
||||
- architecture and repo structure that are not obvious from filenames alone
|
||||
- project-specific conventions, setup quirks, and operational gotchas
|
||||
- references to existing instruction sources like Cursor or Copilot rules
|
||||
|
||||
If you already have an `AGENTS.md`, `/init` will improve it in place instead of blindly replacing it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ Show the help dialog.
|
||||
|
||||
### init
|
||||
|
||||
Create or update `AGENTS.md` file. [Learn more](/docs/rules).
|
||||
Guided setup for creating or updating `AGENTS.md`. [Learn more](/docs/rules).
|
||||
|
||||
```bash frame="none"
|
||||
/init
|
||||
|
||||
Reference in New Issue
Block a user