fix: stabilize v2 runtime behavior
This commit is contained in:
parent
ac2a78391f
commit
655adbf46e
35 changed files with 742 additions and 425 deletions
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
|
|
@ -4,6 +4,7 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
|
- v2
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
const daemon = yield* Daemon.Service
|
const daemon = yield* Daemon.Service
|
||||||
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
|
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
|
||||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||||
yield* runTui(transport)
|
yield* runTui(
|
||||||
|
transport,
|
||||||
|
input.standalone
|
||||||
|
? undefined
|
||||||
|
: async () => {
|
||||||
|
await Effect.runPromise(daemon.stop())
|
||||||
|
return Effect.runPromise(daemon.transport())
|
||||||
|
},
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,27 @@ export default Runtime.handler(
|
||||||
if (input.register) yield* daemon.register(address)
|
if (input.register) yield* daemon.register(address)
|
||||||
const url = HttpServer.formatAddress(address)
|
const url = HttpServer.formatAddress(address)
|
||||||
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||||
return yield* Effect.never
|
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
|
||||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function waitForStdinClose() {
|
||||||
|
return Effect.callback<void>((resume) => {
|
||||||
|
const close = () => resume(Effect.void)
|
||||||
|
process.stdin.once("end", close)
|
||||||
|
process.stdin.once("close", close)
|
||||||
|
process.stdin.resume()
|
||||||
|
if (process.stdin.readableEnded || process.stdin.destroyed) close()
|
||||||
|
return Effect.sync(() => {
|
||||||
|
process.stdin.off("end", close)
|
||||||
|
process.stdin.off("close", close)
|
||||||
|
process.stdin.pause()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||||
const next = (port: number): ReturnType<typeof bind> =>
|
const next = (port: number): ReturnType<typeof bind> =>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
||||||
|
|
@ -41,7 +41,7 @@ export const layer = Layer.effect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const directory = Global.Path.state
|
const directory = Global.Path.state
|
||||||
const file = path.join(directory, "server.json")
|
const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json")
|
||||||
const configFile = path.join(Global.Path.config, "service.json")
|
const configFile = path.join(Global.Path.config, "service.json")
|
||||||
const legacyPasswordFile = path.join(directory, "password")
|
const legacyPasswordFile = path.join(directory, "password")
|
||||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@ function command(password: string) {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: { OPENCODE_SERVER_PASSWORD: password },
|
env: { OPENCODE_SERVER_PASSWORD: password },
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
stdin: "ignore",
|
// The server treats EOF on this pipe as the end of its ownership lease.
|
||||||
|
// The OS closes it even when the TUI is killed before Effect finalizers run.
|
||||||
|
stdin: "pipe",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
killSignal: "SIGKILL",
|
killSignal: "SIGTERM",
|
||||||
|
forceKillAfter: "3 seconds",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,7 +33,7 @@ export const transport = Effect.fn("cli.standalone.transport")(
|
||||||
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
|
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
|
||||||
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
|
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
|
||||||
const ready = yield* Effect.tryPromise(() => decodeReady(output))
|
const ready = yield* Effect.tryPromise(() => decodeReady(output))
|
||||||
return { url: ready.url, headers: ServerAuth.headers({ password }) }
|
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
|
||||||
},
|
},
|
||||||
Effect.provide(CrossSpawnSpawner.defaultLayer),
|
Effect.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import { Global } from "@opencode-ai/core/global"
|
||||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
type Transport = { url: string; headers: RequestInit["headers"] }
|
||||||
|
|
||||||
|
export function runTui(transport: Transport, reload?: () => Promise<Transport>) {
|
||||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||||
let disposeSlots: (() => void) | undefined
|
let disposeSlots: (() => void) | undefined
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|
@ -23,6 +25,12 @@ export function runTui(transport: { url: string; headers: RequestInit["headers"]
|
||||||
)
|
)
|
||||||
return yield* run({
|
return yield* run({
|
||||||
client: createOpencodeClient({ ...options, directory }),
|
client: createOpencodeClient({ ...options, directory }),
|
||||||
|
reload: reload
|
||||||
|
? async () => {
|
||||||
|
const next = await reload()
|
||||||
|
return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory })
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
args: {},
|
args: {},
|
||||||
config,
|
config,
|
||||||
pluginHost: {
|
pluginHost: {
|
||||||
|
|
|
||||||
15
packages/cli/test/fixture/standalone-owner.ts
Normal file
15
packages/cli/test/fixture/standalone-owner.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import path from "node:path"
|
||||||
|
import { Standalone } from "../../src/services/standalone"
|
||||||
|
|
||||||
|
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const transport = yield* Standalone.transport()
|
||||||
|
console.log(`${transport.pid} ${transport.url}`)
|
||||||
|
return yield* Effect.never
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
65
packages/cli/test/standalone.test.ts
Normal file
65
packages/cli/test/standalone.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
test("standalone server exits when its owner is killed", async () => {
|
||||||
|
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
|
||||||
|
cwd: path.join(import.meta.dir, ".."),
|
||||||
|
env: process.env,
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
|
||||||
|
const [rawPID, url] = line?.split(" ") ?? []
|
||||||
|
const pid = Number(rawPID)
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(pid).toBeGreaterThan(0)
|
||||||
|
expect(url).toStartWith("http://127.0.0.1:")
|
||||||
|
expect(running(pid)).toBe(true)
|
||||||
|
|
||||||
|
owner.kill("SIGKILL")
|
||||||
|
await owner.exited
|
||||||
|
|
||||||
|
expect(await waitForExit(pid)).toBe(true)
|
||||||
|
} finally {
|
||||||
|
owner.kill("SIGKILL")
|
||||||
|
if (running(pid)) process.kill(pid, "SIGKILL")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function readLine(stream: ReadableStream<Uint8Array>) {
|
||||||
|
const reader = stream.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const chunks: string[] = []
|
||||||
|
while (true) {
|
||||||
|
const result = await reader.read()
|
||||||
|
if (result.done) break
|
||||||
|
chunks.push(decoder.decode(result.value, { stream: true }))
|
||||||
|
const output = chunks.join("")
|
||||||
|
const newline = output.indexOf("\n")
|
||||||
|
if (newline !== -1) {
|
||||||
|
reader.releaseLock()
|
||||||
|
return output.slice(0, newline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.releaseLock()
|
||||||
|
return chunks.join("") + decoder.decode()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExit(pid: number, attempts = 100): Promise<boolean> {
|
||||||
|
if (!running(pid)) return true
|
||||||
|
if (attempts === 0) return false
|
||||||
|
await Bun.sleep(50)
|
||||||
|
return waitForExit(pid, attempts - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function running(pid: number) {
|
||||||
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import { Npm } from "../npm"
|
||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
|
import { State } from "../state"
|
||||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||||
import { AgentPlugin } from "./agent"
|
import { AgentPlugin } from "./agent"
|
||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
|
|
@ -102,20 +103,22 @@ export const locationLayer = Layer.effectDiscard(
|
||||||
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
yield* State.batch(
|
||||||
yield* add(ConfigReferencePlugin.Plugin)
|
Effect.gen(function* () {
|
||||||
yield* add(AgentPlugin.Plugin)
|
yield* add(ConfigReferencePlugin.Plugin)
|
||||||
yield* add(CommandPlugin.Plugin)
|
yield* add(AgentPlugin.Plugin)
|
||||||
yield* add(SkillPlugin.Plugin)
|
yield* add(CommandPlugin.Plugin)
|
||||||
yield* add(ModelsDevPlugin)
|
yield* add(SkillPlugin.Plugin)
|
||||||
yield* add(ConfigAgentPlugin.Plugin)
|
yield* add(ModelsDevPlugin)
|
||||||
yield* add(ConfigCommandPlugin.Plugin)
|
yield* add(ConfigAgentPlugin.Plugin)
|
||||||
yield* add(ConfigSkillPlugin.Plugin)
|
yield* add(ConfigCommandPlugin.Plugin)
|
||||||
for (const item of ProviderPlugins) yield* add(item)
|
yield* add(ConfigSkillPlugin.Plugin)
|
||||||
yield* add(ConfigExternalPlugin.Plugin)
|
for (const item of ProviderPlugins) yield* add(item)
|
||||||
yield* add(ConfigProviderPlugin.Plugin)
|
yield* add(ConfigExternalPlugin.Plugin)
|
||||||
yield* add(VariantPlugin.Plugin)
|
yield* add(ConfigProviderPlugin.Plugin)
|
||||||
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
yield* add(VariantPlugin.Plugin)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
Layer.provideMerge(PluginV2.locationLayer),
|
Layer.provideMerge(PluginV2.locationLayer),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunOptions {
|
export interface RunOptions {
|
||||||
|
readonly combineOutput?: boolean
|
||||||
readonly maxOutputBytes?: number
|
readonly maxOutputBytes?: number
|
||||||
readonly maxErrorBytes?: number
|
readonly maxErrorBytes?: number
|
||||||
readonly signal?: AbortSignal
|
readonly signal?: AbortSignal
|
||||||
|
|
@ -37,8 +38,10 @@ export interface RunStreamOptions {
|
||||||
export interface RunResult {
|
export interface RunResult {
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly exitCode: number
|
readonly exitCode: number
|
||||||
|
readonly output?: Buffer
|
||||||
readonly stdout: Buffer
|
readonly stdout: Buffer
|
||||||
readonly stderr: Buffer
|
readonly stderr: Buffer
|
||||||
|
readonly outputTruncated?: boolean
|
||||||
readonly stdoutTruncated: boolean
|
readonly stdoutTruncated: boolean
|
||||||
readonly stderrTruncated: boolean
|
readonly stderrTruncated: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -143,6 +146,22 @@ export const layer = Layer.effect(
|
||||||
const collect = Effect.scoped(
|
const collect = Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const handle = yield* spawner.spawn(command)
|
const handle = yield* spawner.spawn(command)
|
||||||
|
if (options?.combineOutput) {
|
||||||
|
const [output, exitCode] = yield* Effect.all(
|
||||||
|
[collectStream(handle.all, options.maxOutputBytes), handle.exitCode],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
command: description,
|
||||||
|
exitCode,
|
||||||
|
output: output.buffer,
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.alloc(0),
|
||||||
|
outputTruncated: output.truncated,
|
||||||
|
stdoutTruncated: false,
|
||||||
|
stderrTruncated: false,
|
||||||
|
} satisfies RunResult
|
||||||
|
}
|
||||||
const [stdout, stderr, exitCode] = yield* Effect.all(
|
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||||
[
|
[
|
||||||
collectStream(handle.stdout, options?.maxOutputBytes),
|
collectStream(handle.stdout, options?.maxOutputBytes),
|
||||||
|
|
|
||||||
|
|
@ -30,16 +30,15 @@ export const Input = Schema.Struct({
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Output = Schema.Struct({
|
const StructuredOutput = Schema.Struct({
|
||||||
command: Schema.String,
|
exit: Schema.Number.pipe(Schema.optional),
|
||||||
cwd: Schema.String,
|
|
||||||
exitCode: Schema.Number.pipe(Schema.optional),
|
|
||||||
/** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */
|
|
||||||
output: Schema.String,
|
|
||||||
truncated: Schema.Boolean,
|
truncated: Schema.Boolean,
|
||||||
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
|
timeout: Schema.Boolean.pipe(Schema.optional),
|
||||||
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
|
})
|
||||||
timedOut: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
|
const Output = Schema.Struct({
|
||||||
|
...StructuredOutput.fields,
|
||||||
|
output: Schema.String,
|
||||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -47,24 +46,12 @@ type Output = typeof Output.Type
|
||||||
|
|
||||||
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
|
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
|
||||||
|
|
||||||
const compactOutput = (stdout: string, stderr: string) => {
|
|
||||||
const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout
|
|
||||||
return output || "(no output)"
|
|
||||||
}
|
|
||||||
|
|
||||||
const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
|
|
||||||
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
|
|
||||||
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
|
|
||||||
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelOutput = (output: Output) => {
|
const modelOutput = (output: Output) => {
|
||||||
const warnings = output.warnings?.length
|
const warnings = output.warnings?.length
|
||||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||||
: ""
|
: ""
|
||||||
if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.`
|
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
||||||
return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.`
|
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTimeout = (error: AppProcess.AppProcessError) =>
|
const isTimeout = (error: AppProcess.AppProcessError) =>
|
||||||
|
|
@ -116,7 +103,16 @@ export const layer = Layer.effectDiscard(
|
||||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
|
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }],
|
structured: StructuredOutput,
|
||||||
|
toStructuredOutput: ({ output }) => ({
|
||||||
|
truncated: output.truncated,
|
||||||
|
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||||
|
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
|
||||||
|
}),
|
||||||
|
toModelOutput: ({ output }) => [
|
||||||
|
{ type: "text", text: output.output },
|
||||||
|
{ type: "text", text: modelOutput(output) },
|
||||||
|
],
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const source = {
|
const source = {
|
||||||
|
|
@ -163,9 +159,9 @@ export const layer = Layer.effectDiscard(
|
||||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||||
const result = yield* appProcess
|
const result = yield* appProcess
|
||||||
.run(command, {
|
.run(command, {
|
||||||
|
combineOutput: true,
|
||||||
timeout: Duration.millis(timeout),
|
timeout: Duration.millis(timeout),
|
||||||
maxOutputBytes: MAX_CAPTURE_BYTES,
|
maxOutputBytes: MAX_CAPTURE_BYTES,
|
||||||
maxErrorBytes: MAX_CAPTURE_BYTES,
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchTag("AppProcessError", (error) =>
|
Effect.catchTag("AppProcessError", (error) =>
|
||||||
|
|
@ -174,26 +170,22 @@ export const layer = Layer.effectDiscard(
|
||||||
)
|
)
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return {
|
return {
|
||||||
command: input.command,
|
|
||||||
cwd: target.canonical,
|
|
||||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
timedOut: true,
|
timeout: true,
|
||||||
...(warnings.length ? { warnings } : {}),
|
...(warnings.length ? { warnings } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
|
const output = result.output?.toString("utf8") || "(no output)"
|
||||||
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
|
const notice = result.outputTruncated
|
||||||
|
? "[output capture truncated at the in-memory safety limit]"
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
command: input.command,
|
exit: result.exitCode,
|
||||||
cwd: target.canonical,
|
output: notice ? `${output}\n\n${notice}` : output,
|
||||||
exitCode: result.exitCode,
|
truncated: result.outputTruncated === true,
|
||||||
output: notice ? `${compact}\n\n${notice}` : compact,
|
|
||||||
truncated: result.stdoutTruncated || result.stderrTruncated,
|
|
||||||
...(warnings.length ? { warnings } : {}),
|
...(warnings.length ? { warnings } : {}),
|
||||||
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
|
|
||||||
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
|
|
||||||
}
|
}
|
||||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,19 @@ export type Content =
|
||||||
| { readonly type: "text"; readonly text: string }
|
| { readonly type: "text"; readonly text: string }
|
||||||
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
||||||
|
|
||||||
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
|
type Config<
|
||||||
|
Input extends SchemaType<any>,
|
||||||
|
Output extends SchemaType<any>,
|
||||||
|
Structured extends SchemaType<any> = Output,
|
||||||
|
> = {
|
||||||
readonly description: string
|
readonly description: string
|
||||||
readonly input: Input
|
readonly input: Input
|
||||||
readonly output: Output
|
readonly output: Output
|
||||||
|
readonly structured?: Structured
|
||||||
|
readonly toStructuredOutput?: (input: {
|
||||||
|
readonly input: Schema.Schema.Type<Input>
|
||||||
|
readonly output: Output["Encoded"]
|
||||||
|
}) => Schema.Schema.Type<Structured>
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
input: Schema.Schema.Type<Input>,
|
input: Schema.Schema.Type<Input>,
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -59,10 +68,12 @@ type Runtime = {
|
||||||
|
|
||||||
const runtimes = new WeakMap<AnyTool, Runtime>()
|
const runtimes = new WeakMap<AnyTool, Runtime>()
|
||||||
|
|
||||||
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
export function make<
|
||||||
config: Config<Input, Output>,
|
Input extends SchemaType<any>,
|
||||||
): Definition<Input, Output> {
|
Output extends SchemaType<any>,
|
||||||
const tool = Object.freeze({}) as Definition<Input, Output>
|
Structured extends SchemaType<any> = Output,
|
||||||
|
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
|
||||||
|
const tool = Object.freeze({}) as Definition<Input, Structured>
|
||||||
const definitions = new Map<string, ToolDefinition>()
|
const definitions = new Map<string, ToolDefinition>()
|
||||||
runtimes.set(tool, {
|
runtimes.set(tool, {
|
||||||
definition: (name) => {
|
definition: (name) => {
|
||||||
|
|
@ -72,7 +83,7 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
|
||||||
name,
|
name,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
inputSchema: toJsonSchema(config.input),
|
inputSchema: toJsonSchema(config.input),
|
||||||
outputSchema: toJsonSchema(config.output),
|
outputSchema: toJsonSchema(config.structured ?? config.output),
|
||||||
})
|
})
|
||||||
definitions.set(name, definition)
|
definitions.set(name, definition)
|
||||||
return definition
|
return definition
|
||||||
|
|
@ -84,6 +95,13 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
|
||||||
config.execute(input, context).pipe(
|
config.execute(input, context).pipe(
|
||||||
Effect.flatMap((output) =>
|
Effect.flatMap((output) =>
|
||||||
Schema.encodeEffect(config.output)(output).pipe(
|
Schema.encodeEffect(config.output)(output).pipe(
|
||||||
|
Effect.flatMap((output) => {
|
||||||
|
if (!config.structured || !config.toStructuredOutput)
|
||||||
|
return Effect.succeed({ output, structured: output })
|
||||||
|
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
|
||||||
|
Effect.map((structured) => ({ output, structured })),
|
||||||
|
)
|
||||||
|
}),
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
|
|
@ -92,8 +110,8 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Effect.map((output) => ({
|
Effect.map(({ output, structured }) => ({
|
||||||
structured: output,
|
structured,
|
||||||
content:
|
content:
|
||||||
config.toModelOutput?.({ input, output }).map((part) =>
|
config.toModelOutput?.({ input, output }).map((part) =>
|
||||||
part.type === "text"
|
part.type === "text"
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,22 @@ describe("AppProcess", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect(
|
||||||
|
"captures stdout and stderr in emission order",
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* AppProcess.Service
|
||||||
|
const script = [
|
||||||
|
'process.stdout.write("out 1\\n")',
|
||||||
|
'setTimeout(() => process.stderr.write("err 1\\n"), 10)',
|
||||||
|
'setTimeout(() => process.stdout.write("out 2\\n"), 20)',
|
||||||
|
].join(";")
|
||||||
|
const result = yield* svc.run(cmd("-e", script), { combineOutput: true })
|
||||||
|
expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n")
|
||||||
|
expect(result.stdout.toString("utf8")).toBe("")
|
||||||
|
expect(result.stderr.toString("utf8")).toBe("")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect(
|
it.effect(
|
||||||
"non-zero exit returns RunResult; caller can require success",
|
"non-zero exit returns RunResult; caller can require success",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,10 @@ let denyAction: string | undefined
|
||||||
let result: AppProcess.RunResult = {
|
let result: AppProcess.RunResult = {
|
||||||
command: "mock",
|
command: "mock",
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
|
output: Buffer.from("hello\n"),
|
||||||
stdout: Buffer.from("hello\n"),
|
stdout: Buffer.from("hello\n"),
|
||||||
stderr: Buffer.alloc(0),
|
stderr: Buffer.alloc(0),
|
||||||
|
outputTruncated: false,
|
||||||
stdoutTruncated: false,
|
stdoutTruncated: false,
|
||||||
stderrTruncated: false,
|
stderrTruncated: false,
|
||||||
}
|
}
|
||||||
|
|
@ -83,8 +85,10 @@ const reset = () => {
|
||||||
result = {
|
result = {
|
||||||
command: "mock",
|
command: "mock",
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
|
output: Buffer.from("hello\n"),
|
||||||
stdout: Buffer.from("hello\n"),
|
stdout: Buffer.from("hello\n"),
|
||||||
stderr: Buffer.alloc(0),
|
stderr: Buffer.alloc(0),
|
||||||
|
outputTruncated: false,
|
||||||
stdoutTruncated: false,
|
stdoutTruncated: false,
|
||||||
stderrTruncated: false,
|
stderrTruncated: false,
|
||||||
}
|
}
|
||||||
|
|
@ -135,24 +139,33 @@ describe("BashTool", () => {
|
||||||
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
|
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
|
||||||
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
|
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
|
||||||
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
|
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
|
||||||
|
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
|
||||||
|
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command")
|
||||||
|
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd")
|
||||||
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
|
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
|
||||||
expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
|
expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
|
||||||
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [
|
||||||
|
{ type: "text", text: "hello\n" },
|
||||||
|
{ type: "text", text: "Command exited with code 0." },
|
||||||
|
],
|
||||||
|
},
|
||||||
output: {
|
output: {
|
||||||
structured: {
|
structured: {
|
||||||
command: "pwd",
|
exit: 0,
|
||||||
cwd: realpathSync(tmp.path),
|
|
||||||
exitCode: 0,
|
|
||||||
output: "hello\n",
|
|
||||||
truncated: false,
|
truncated: false,
|
||||||
},
|
},
|
||||||
content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }],
|
content: [
|
||||||
|
{ type: "text", text: "hello\n" },
|
||||||
|
{ type: "text", text: "Command exited with code 0." },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
|
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
|
||||||
expect(runs[0]?.options).toMatchObject({
|
expect(runs[0]?.options).toMatchObject({
|
||||||
|
combineOutput: true,
|
||||||
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
|
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
|
||||||
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
|
|
||||||
})
|
})
|
||||||
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
|
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
|
||||||
}),
|
}),
|
||||||
|
|
@ -222,13 +235,17 @@ describe("BashTool", () => {
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." })
|
expect(settled.result).toEqual({
|
||||||
expect(settled.output?.structured).toMatchObject({
|
type: "content",
|
||||||
command: "printf core-bash",
|
value: [
|
||||||
cwd: realpathSync(tmp.path),
|
{ type: "text", text: "core-bash" },
|
||||||
exitCode: 0,
|
{ type: "text", text: "Command exited with code 0." },
|
||||||
output: "core-bash",
|
],
|
||||||
})
|
})
|
||||||
|
expect(settled.output?.structured).toMatchObject({
|
||||||
|
exit: 0,
|
||||||
|
})
|
||||||
|
expect(settled.output?.structured).not.toHaveProperty("output")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -303,11 +320,13 @@ describe("BashTool", () => {
|
||||||
expect(assertions.map((item) => item.action)).toEqual(["bash"])
|
expect(assertions.map((item) => item.action)).toEqual(["bash"])
|
||||||
expect(runs).toHaveLength(1)
|
expect(runs).toHaveLength(1)
|
||||||
expect(settled.output?.structured).toMatchObject({
|
expect(settled.output?.structured).toMatchObject({
|
||||||
warnings: [
|
truncated: false,
|
||||||
`Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
})
|
||||||
],
|
expect(settled.output?.structured).not.toHaveProperty("warnings")
|
||||||
|
expect(settled.output?.content[1]).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
text: expect.stringContaining("Warnings:"),
|
||||||
})
|
})
|
||||||
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") })
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -324,21 +343,19 @@ describe("BashTool", () => {
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
|
result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") }
|
||||||
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
|
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(settled.result).toMatchObject({
|
expect(settled.output?.content[1]).toMatchObject({
|
||||||
type: "text",
|
type: "text",
|
||||||
value: expect.stringContaining("Command exited with code 7"),
|
text: expect.stringContaining("Command exited with code 7"),
|
||||||
})
|
})
|
||||||
expect(settled.output?.structured).toMatchObject({
|
expect(settled.output?.structured).toMatchObject({
|
||||||
command: "false",
|
exit: 7,
|
||||||
cwd: realpathSync(tmp.path),
|
|
||||||
exitCode: 7,
|
|
||||||
output: "HEAD full output TAIL",
|
|
||||||
truncated: false,
|
truncated: false,
|
||||||
})
|
})
|
||||||
|
expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" })
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -352,14 +369,14 @@ describe("BashTool", () => {
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
result = { ...result, stdoutTruncated: true }
|
result = { ...result, outputTruncated: true }
|
||||||
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
|
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
|
expect(settled.output?.structured).toMatchObject({ truncated: true })
|
||||||
expect(settled.result).toMatchObject({
|
expect(settled.output?.content[0]).toMatchObject({
|
||||||
type: "text",
|
type: "text",
|
||||||
value: expect.stringContaining("stdout capture truncated"),
|
text: expect.stringContaining("output capture truncated"),
|
||||||
})
|
})
|
||||||
expect(settled.output?.structured).not.toHaveProperty("resource")
|
expect(settled.output?.structured).not.toHaveProperty("resource")
|
||||||
}),
|
}),
|
||||||
|
|
@ -379,13 +396,12 @@ describe("BashTool", () => {
|
||||||
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
|
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(settled.result).toMatchObject({
|
expect(settled.output?.content[1]).toMatchObject({
|
||||||
type: "text",
|
type: "text",
|
||||||
value: expect.stringContaining("Command timed out"),
|
text: expect.stringContaining("Command timed out"),
|
||||||
})
|
})
|
||||||
expect(settled.output?.structured).toMatchObject({
|
expect(settled.output?.structured).toMatchObject({
|
||||||
command: "sleep 60",
|
timeout: true,
|
||||||
timedOut: true,
|
|
||||||
truncated: false,
|
truncated: false,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
AgentPart,
|
AgentPart,
|
||||||
OpencodeClient,
|
OpencodeClient,
|
||||||
Event,
|
V2Event,
|
||||||
FilePart,
|
FilePart,
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
|
|
@ -517,7 +517,10 @@ export type TuiSlots = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TuiEventBus = {
|
export type TuiEventBus = {
|
||||||
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
|
on: <Type extends V2Event["type"]>(
|
||||||
|
type: Type,
|
||||||
|
handler: (event: Extract<V2Event, { type: Type }>) => void,
|
||||||
|
) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TuiDispose = () => void | Promise<void>
|
export type TuiDispose = () => void | Promise<void>
|
||||||
|
|
|
||||||
|
|
@ -969,7 +969,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
|
||||||
<div data-component="context-tool-group-trigger">
|
<div data-component="context-tool-group-trigger">
|
||||||
<span
|
<span
|
||||||
data-slot="context-tool-group-title"
|
data-slot="context-tool-group-title"
|
||||||
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
|
class="min-w-0 flex items-center gap-2 text-14-medium"
|
||||||
|
classList={{
|
||||||
|
"text-text-strong": pending(),
|
||||||
|
"text-text-weak": !pending(),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span data-slot="context-tool-group-label" class="shrink-0">
|
<span data-slot="context-tool-group-label" class="shrink-0">
|
||||||
<ToolStatusTitle
|
<ToolStatusTitle
|
||||||
|
|
@ -981,7 +985,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
data-slot="context-tool-group-summary"
|
data-slot="context-tool-group-summary"
|
||||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
|
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal"
|
||||||
|
classList={{
|
||||||
|
"text-text-base": pending(),
|
||||||
|
"text-text-weak": !pending(),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AnimatedCountList
|
<AnimatedCountList
|
||||||
items={[
|
items={[
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ const appBindingCommands = [
|
||||||
|
|
||||||
export type TuiInput = {
|
export type TuiInput = {
|
||||||
client: OpencodeClient
|
client: OpencodeClient
|
||||||
|
reload?: () => Promise<OpencodeClient>
|
||||||
args: Args
|
args: Args
|
||||||
config: TuiConfig.Resolved
|
config: TuiConfig.Resolved
|
||||||
onSnapshot?: () => Promise<string[]>
|
onSnapshot?: () => Promise<string[]>
|
||||||
|
|
@ -289,7 +290,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||||
>
|
>
|
||||||
<TuiConfigProvider config={input.config}>
|
<TuiConfigProvider config={input.config}>
|
||||||
<PluginRuntimeProvider value={pluginRuntime}>
|
<PluginRuntimeProvider value={pluginRuntime}>
|
||||||
<SDKProvider client={input.client}>
|
<SDKProvider client={input.client} reload={input.reload}>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
<DataProvider>
|
<DataProvider>
|
||||||
|
|
@ -360,6 +361,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const reload = sdk.reload
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const themeState = useTheme()
|
const themeState = useTheme()
|
||||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||||
|
|
@ -744,6 +746,33 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
},
|
},
|
||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
|
...(reload
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "server.reload",
|
||||||
|
title: "Reload server",
|
||||||
|
slashName: "reload",
|
||||||
|
slashAliases: ["restart"],
|
||||||
|
run: async () => {
|
||||||
|
dialog.clear()
|
||||||
|
toast.show({
|
||||||
|
variant: "info",
|
||||||
|
message: "Reloading server...",
|
||||||
|
duration: 30000,
|
||||||
|
})
|
||||||
|
await reload()
|
||||||
|
.then(() =>
|
||||||
|
toast.show({
|
||||||
|
variant: "success",
|
||||||
|
message: "Server reloaded",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(toast.error)
|
||||||
|
},
|
||||||
|
category: "System",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
name: "theme.switch",
|
name: "theme.switch",
|
||||||
title: "Switch theme",
|
title: "Switch theme",
|
||||||
|
|
@ -940,16 +969,16 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
|
|
||||||
event.on("tui.command.execute", (evt, { workspace }) => {
|
event.on("tui.command.execute", (evt, { workspace }) => {
|
||||||
if (workspace !== project.workspace.current()) return
|
if (workspace !== project.workspace.current()) return
|
||||||
keymap.dispatchCommand(evt.properties.command)
|
keymap.dispatchCommand(evt.data.command)
|
||||||
})
|
})
|
||||||
|
|
||||||
event.on("tui.toast.show", (evt, { workspace }) => {
|
event.on("tui.toast.show", (evt, { workspace }) => {
|
||||||
if (workspace !== project.workspace.current()) return
|
if (workspace !== project.workspace.current()) return
|
||||||
toast.show({
|
toast.show({
|
||||||
title: evt.properties.title,
|
title: evt.data.title,
|
||||||
message: evt.properties.message,
|
message: evt.data.message,
|
||||||
variant: evt.properties.variant,
|
variant: evt.data.variant,
|
||||||
duration: evt.properties.duration,
|
duration: evt.data.duration,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -957,12 +986,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
if (workspace !== project.workspace.current()) return
|
if (workspace !== project.workspace.current()) return
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
sessionID: evt.properties.sessionID,
|
sessionID: evt.data.sessionID,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
event.on("session.deleted", (evt) => {
|
event.on("session.deleted", (evt) => {
|
||||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
if (route.data.type === "session" && route.data.sessionID === evt.data.info.id) {
|
||||||
route.navigate({ type: "home" })
|
route.navigate({ type: "home" })
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "info",
|
variant: "info",
|
||||||
|
|
@ -973,7 +1002,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
|
|
||||||
event.on("session.error", (evt, { workspace }) => {
|
event.on("session.error", (evt, { workspace }) => {
|
||||||
if (workspace !== project.workspace.current()) return
|
if (workspace !== project.workspace.current()) return
|
||||||
const error = evt.properties.error
|
const error = evt.data.error
|
||||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||||
const message = errorMessage(error)
|
const message = errorMessage(error)
|
||||||
|
|
||||||
|
|
@ -986,7 +1015,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
|
|
||||||
event.on("installation.update-available", async (evt) => {
|
event.on("installation.update-available", async (evt) => {
|
||||||
console.log("installation.update-available", evt)
|
console.log("installation.update-available", evt)
|
||||||
const version = evt.properties.version
|
const version = evt.data.version
|
||||||
|
|
||||||
const skipped = kv.get("skipped_version")
|
const skipped = kv.get("skipped_version")
|
||||||
if (skipped && !isVersionGreater(version, skipped)) return
|
if (skipped && !isVersionGreater(version, skipped)) return
|
||||||
|
|
@ -1085,8 +1114,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
<Show when={!startup.skipInitialLoading}>
|
<Show when={!startup.skipInitialLoading}>
|
||||||
<StartupLoading ready={ready} />
|
<StartupLoading ready={ready} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={data.connection.status() === "reconnecting"}>
|
<Show when={sdk.connection.status() === "reconnecting"}>
|
||||||
<Reconnecting attempt={data.connection.attempt()} error={data.connection.error()} />
|
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,12 @@ export function Prompt(props: PromptProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||||
|
const activeSubagents = createMemo(() =>
|
||||||
|
data.session
|
||||||
|
.list()
|
||||||
|
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
|
||||||
|
.length,
|
||||||
|
)
|
||||||
const history = usePromptHistory()
|
const history = usePromptHistory()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
|
|
@ -235,7 +241,7 @@ export function Prompt(props: PromptProps) {
|
||||||
event.on("tui.prompt.append", (evt, { workspace }) => {
|
event.on("tui.prompt.append", (evt, { workspace }) => {
|
||||||
if (workspace !== project.workspace.current()) return
|
if (workspace !== project.workspace.current()) return
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
input.insertText(evt.properties.text)
|
input.insertText(evt.data.text)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// setTimeout is a workaround and needs to be addressed properly
|
// setTimeout is a workaround and needs to be addressed properly
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
|
|
@ -1534,6 +1540,13 @@ export function Prompt(props: PromptProps) {
|
||||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
<Show when={activeSubagents()}>
|
||||||
|
{(count) => (
|
||||||
|
<Spinner color={theme.textMuted}>
|
||||||
|
{count()} active subagent{count() === 1 ? "" : "s"}
|
||||||
|
</Spinner>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||||
esc{" "}
|
esc{" "}
|
||||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,10 @@ import type {
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
import { createSignal, onCleanup } from "solid-js"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
|
||||||
|
|
||||||
export type DataConnectionStatus = "connecting" | "connected" | "reconnecting"
|
|
||||||
export type DataSessionStatus = "idle" | "running"
|
export type DataSessionStatus = "idle" | "running"
|
||||||
|
|
||||||
export type DataEvent = V2Event
|
|
||||||
type DataEventMap = { [T in DataEvent["type"]]: Extract<DataEvent, { type: T }> }
|
|
||||||
|
|
||||||
type LocationData = {
|
type LocationData = {
|
||||||
agent?: AgentV2Info[]
|
agent?: AgentV2Info[]
|
||||||
command?: CommandV2Info[]
|
command?: CommandV2Info[]
|
||||||
|
|
@ -41,11 +36,6 @@ type LocationData = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
connection: {
|
|
||||||
status: DataConnectionStatus
|
|
||||||
attempt: number
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
session: {
|
session: {
|
||||||
info: Record<string, SessionV2Info>
|
info: Record<string, SessionV2Info>
|
||||||
status: Record<string, DataSessionStatus>
|
status: Record<string, DataSessionStatus>
|
||||||
|
|
@ -71,10 +61,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
name: "Data",
|
name: "Data",
|
||||||
init: () => {
|
init: () => {
|
||||||
const [store, setStore] = createStore<Data>({
|
const [store, setStore] = createStore<Data>({
|
||||||
connection: {
|
|
||||||
status: "connecting",
|
|
||||||
attempt: 0,
|
|
||||||
},
|
|
||||||
session: {
|
session: {
|
||||||
info: {},
|
info: {},
|
||||||
status: {},
|
status: {},
|
||||||
|
|
@ -89,7 +75,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const events = createGlobalEmitter<DataEventMap>()
|
|
||||||
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
|
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
})
|
})
|
||||||
|
|
@ -151,6 +136,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
|
|
||||||
function handleEvent(event: V2Event) {
|
function handleEvent(event: V2Event) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
case "session.created":
|
||||||
|
void result.session.refresh(event.data.sessionID)
|
||||||
|
break
|
||||||
case "catalog.updated":
|
case "catalog.updated":
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
result.location.model.refresh(event.location),
|
result.location.model.refresh(event.location),
|
||||||
|
|
@ -477,21 +465,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
])
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
events.emit(event.type, event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
on: events.on,
|
on: sdk.event.on,
|
||||||
listen: events.listen,
|
listen: sdk.event.listen,
|
||||||
connection: {
|
connection: {
|
||||||
status() {
|
status() {
|
||||||
return store.connection.status
|
return sdk.connection.status()
|
||||||
},
|
},
|
||||||
attempt() {
|
attempt() {
|
||||||
return store.connection.attempt
|
return sdk.connection.attempt()
|
||||||
},
|
},
|
||||||
error() {
|
error() {
|
||||||
return store.connection.error
|
return sdk.connection.error()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
|
@ -687,52 +674,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
console.error("Failed to refresh default location data", failure.reason)
|
console.error("Failed to refresh default location data", failure.reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onCleanup(
|
||||||
const controller = new AbortController()
|
sdk.event.listen(({ details }) => {
|
||||||
onCleanup(() => controller.abort())
|
handleEvent(details)
|
||||||
void (async () => {
|
if (details.type === "server.connected") void bootstrap()
|
||||||
while (!controller.signal.aborted) {
|
}),
|
||||||
const error = await (async () => {
|
)
|
||||||
const events = await sdk.client.v2.event.subscribe({
|
|
||||||
signal: controller.signal,
|
|
||||||
sseMaxRetryAttempts: 0,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
const stream = events.stream[Symbol.asyncIterator]()
|
|
||||||
const first = await stream.next()
|
|
||||||
if (first.done) return new Error("Event stream disconnected")
|
|
||||||
setStore("connection", { status: "connected", attempt: 0, error: undefined })
|
|
||||||
handleEvent(first.value)
|
|
||||||
await bootstrap()
|
|
||||||
while (!controller.signal.aborted) {
|
|
||||||
const event = await stream.next()
|
|
||||||
if (event.done) return new Error("Event stream disconnected")
|
|
||||||
handleEvent(event.value)
|
|
||||||
}
|
|
||||||
})().catch((error) => error)
|
|
||||||
if (controller.signal.aborted) return
|
|
||||||
setStore("connection", {
|
|
||||||
status: "reconnecting",
|
|
||||||
attempt: store.connection.status === "connected" ? 1 : store.connection.attempt + 1,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
})
|
|
||||||
await wait(250, controller.signal)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function wait(delay: number, signal: AbortSignal) {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const timer = setTimeout(done, delay)
|
|
||||||
signal.addEventListener("abort", done, { once: true })
|
|
||||||
function done() {
|
|
||||||
clearTimeout(timer)
|
|
||||||
signal.removeEventListener("abort", done)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,27 @@
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
|
|
||||||
type EventMetadata = {
|
type EventMetadata = {
|
||||||
directory: string
|
directory: string | undefined
|
||||||
workspace: string | undefined
|
workspace: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEvent() {
|
export function useEvent() {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
function subscribe(handler: (event: Event, metadata: EventMetadata) => void) {
|
function subscribe(handler: (event: V2Event, metadata: EventMetadata) => void) {
|
||||||
return sdk.event.on("event", (event) => {
|
return sdk.event.listen(({ details }) => {
|
||||||
if (event.payload.type === "sync") {
|
if (details.type === "server.connected") return
|
||||||
return
|
handler(details, { directory: details.location?.directory, workspace: details.location?.workspaceID })
|
||||||
}
|
|
||||||
|
|
||||||
handler(event.payload, { directory: event.directory, workspace: event.workspace })
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function on<T extends Event["type"]>(
|
function on<T extends V2Event["type"]>(
|
||||||
type: T,
|
type: T,
|
||||||
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
|
handler: (event: Extract<V2Event, { type: T }>, metadata: EventMetadata) => void,
|
||||||
) {
|
) {
|
||||||
return subscribe((event: Event, metadata: EventMetadata) => {
|
return sdk.event.on(type, (event) => {
|
||||||
if (event.type !== type) return
|
handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID })
|
||||||
handler(event as Extract<Event, { type: T }>, metadata)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -474,7 +474,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
|
|
||||||
event.on("session.deleted", (evt) => {
|
event.on("session.deleted", (evt) => {
|
||||||
prune(evt.properties.info.id)
|
prune(evt.data.info.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { batch } from "solid-js"
|
import { batch, onCleanup } from "solid-js"
|
||||||
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
|
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
|
|
@ -67,11 +67,11 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
sdk.event.on("event", (event) => {
|
onCleanup(
|
||||||
if (event.payload.type === "workspace.status") {
|
sdk.event.on("workspace.status", (event) => {
|
||||||
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
|
setStore("workspace", "status", event.data.workspaceID, event.data.status)
|
||||||
}
|
}),
|
||||||
})
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: store,
|
data: store,
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,145 @@
|
||||||
import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
|
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
|
import { onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
|
||||||
|
export type SDKConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||||
|
|
||||||
|
type SDKEventMap = { [Type in V2Event["type"]]: Extract<V2Event, { type: Type }> }
|
||||||
|
const connectTimeout = 2_000
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
init: (props: { client: OpencodeClient }) => {
|
init: (props: { client: OpencodeClient; reload?: () => Promise<OpencodeClient> }) => {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const handlers = new Set<(event: GlobalEvent) => void>()
|
let client = props.client
|
||||||
const emitter = {
|
const events = createGlobalEmitter<SDKEventMap>()
|
||||||
emit(_type: "event", event: GlobalEvent) {
|
const [connection, setConnection] = createStore<{
|
||||||
for (const handler of handlers) handler(event)
|
status: SDKConnectionStatus
|
||||||
},
|
attempt: number
|
||||||
on(_type: "event", handler: (event: GlobalEvent) => void) {
|
error?: string
|
||||||
handlers.add(handler)
|
}>({
|
||||||
return () => {
|
status: "connecting",
|
||||||
handlers.delete(handler)
|
attempt: 0,
|
||||||
}
|
})
|
||||||
},
|
let stream: AbortController | undefined
|
||||||
}
|
let pending: Promise<void> | undefined
|
||||||
|
|
||||||
let queue: GlobalEvent[] = []
|
function start() {
|
||||||
let timer: Timer | undefined
|
stream?.abort()
|
||||||
let last = 0
|
const controller = new AbortController()
|
||||||
const retryDelay = 1000
|
const current = client
|
||||||
const maxRetryDelay = 30000
|
let connected!: () => void
|
||||||
|
const ready = new Promise<void>((resolve) => {
|
||||||
const flush = () => {
|
connected = resolve
|
||||||
if (queue.length === 0) return
|
|
||||||
const events = queue
|
|
||||||
queue = []
|
|
||||||
timer = undefined
|
|
||||||
last = Date.now()
|
|
||||||
batch(() => {
|
|
||||||
for (const event of events) emitter.emit("event", event)
|
|
||||||
})
|
})
|
||||||
}
|
stream = controller
|
||||||
|
|
||||||
const handleEvent = (event: GlobalEvent) => {
|
|
||||||
queue.push(event)
|
|
||||||
const elapsed = Date.now() - last
|
|
||||||
if (timer) return
|
|
||||||
if (elapsed < 16) {
|
|
||||||
timer = setTimeout(flush, 16)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
while (!abort.signal.aborted) {
|
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||||
const events = await props.client.global.event({
|
const connection = new AbortController()
|
||||||
signal: abort.signal,
|
const cancel = () => connection.abort(controller.signal.reason)
|
||||||
sseMaxRetryAttempts: 0,
|
const timeout = setTimeout(() => connection.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||||
})
|
controller.signal.addEventListener("abort", cancel, { once: true })
|
||||||
|
const error = await (async () => {
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) await props.client.sync.start().catch(() => {})
|
const response = await current.v2.event.subscribe({
|
||||||
|
signal: connection.signal,
|
||||||
for await (const event of events.stream) {
|
sseMaxRetryAttempts: 0,
|
||||||
if (abort.signal.aborted) break
|
throwOnError: true,
|
||||||
handleEvent(event)
|
})
|
||||||
}
|
const iterator = response.stream[Symbol.asyncIterator]()
|
||||||
|
const first = await iterator.next()
|
||||||
if (timer) clearTimeout(timer)
|
if (abort.signal.aborted || controller.signal.aborted) return
|
||||||
if (queue.length > 0) flush()
|
if (first.done)
|
||||||
|
return connection.signal.reason instanceof Error
|
||||||
|
? connection.signal.reason
|
||||||
|
: new Error("Event stream disconnected")
|
||||||
|
clearTimeout(timeout)
|
||||||
|
attempt = 0
|
||||||
|
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||||
|
events.emit(first.value.type, first.value)
|
||||||
|
connected()
|
||||||
|
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||||
|
const event = await iterator.next()
|
||||||
|
if (abort.signal.aborted || controller.signal.aborted) return
|
||||||
|
if (event.done) return new Error("Event stream disconnected")
|
||||||
|
events.emit(event.value.type, event.value)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.catch((error) => error)
|
||||||
|
.finally(() => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
controller.signal.removeEventListener("abort", cancel)
|
||||||
|
})
|
||||||
|
if (abort.signal.aborted || controller.signal.aborted) return
|
||||||
attempt += 1
|
attempt += 1
|
||||||
if (abort.signal.aborted) break
|
setConnection({
|
||||||
await new Promise((resolve) =>
|
status: "reconnecting",
|
||||||
setTimeout(resolve, Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)),
|
attempt,
|
||||||
)
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
await wait(250, controller.signal)
|
||||||
}
|
}
|
||||||
})().catch(() => {})
|
})()
|
||||||
})
|
return ready
|
||||||
|
}
|
||||||
|
|
||||||
|
const reload = props.reload
|
||||||
|
? () => {
|
||||||
|
if (pending) return pending
|
||||||
|
pending = Promise.resolve()
|
||||||
|
.then(props.reload)
|
||||||
|
.then(async (next) => {
|
||||||
|
client = next
|
||||||
|
if (!abort.signal.aborted) await start()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending = undefined
|
||||||
|
})
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
onMount(() => void start())
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
abort.abort()
|
abort.abort()
|
||||||
if (timer) clearTimeout(timer)
|
stream?.abort()
|
||||||
handlers.clear()
|
events.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client: props.client,
|
get client() {
|
||||||
event: emitter,
|
return client
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
on: events.on,
|
||||||
|
listen: events.listen,
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
status() {
|
||||||
|
return connection.status
|
||||||
|
},
|
||||||
|
attempt() {
|
||||||
|
return connection.attempt
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
return connection.error
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reload,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function wait(delay: number, signal: AbortSignal) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(done, delay)
|
||||||
|
signal.addEventListener("abort", done, { once: true })
|
||||||
|
function done() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal.removeEventListener("abort", done)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||||
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
import type { BuiltinTuiPlugin } from "../builtins"
|
import type { BuiltinTuiPlugin } from "../builtins"
|
||||||
|
|
||||||
const id = "internal:notifications"
|
const id = "internal:notifications"
|
||||||
|
|
||||||
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"]
|
type SessionError = Extract<V2Event, { type: "session.error" }>["data"]["error"]
|
||||||
|
|
||||||
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
|
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
|
||||||
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
||||||
|
|
@ -33,27 +33,27 @@ const tui: TuiPlugin = async (api) => {
|
||||||
const permissions = new Set<string>()
|
const permissions = new Set<string>()
|
||||||
|
|
||||||
api.event.on("question.asked", (event) => {
|
api.event.on("question.asked", (event) => {
|
||||||
if (questions.has(event.properties.id)) return
|
if (questions.has(event.data.id)) return
|
||||||
questions.add(event.properties.id)
|
questions.add(event.data.id)
|
||||||
notify(api, event.properties.sessionID, "Question needs input", "question")
|
notify(api, event.data.sessionID, "Question needs input", "question")
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("question.replied", (event) => {
|
api.event.on("question.replied", (event) => {
|
||||||
questions.delete(event.properties.requestID)
|
questions.delete(event.data.requestID)
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("question.rejected", (event) => {
|
api.event.on("question.rejected", (event) => {
|
||||||
questions.delete(event.properties.requestID)
|
questions.delete(event.data.requestID)
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("permission.asked", (event) => {
|
api.event.on("permission.asked", (event) => {
|
||||||
if (permissions.has(event.properties.id)) return
|
if (permissions.has(event.data.id)) return
|
||||||
permissions.add(event.properties.id)
|
permissions.add(event.data.id)
|
||||||
notify(api, event.properties.sessionID, "Permission needs input", "permission")
|
notify(api, event.data.sessionID, "Permission needs input", "permission")
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("permission.replied", (event) => {
|
api.event.on("permission.replied", (event) => {
|
||||||
permissions.delete(event.properties.requestID)
|
permissions.delete(event.data.requestID)
|
||||||
})
|
})
|
||||||
|
|
||||||
const started = (sessionID: string) => {
|
const started = (sessionID: string) => {
|
||||||
|
|
@ -74,18 +74,18 @@ const tui: TuiPlugin = async (api) => {
|
||||||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||||
}
|
}
|
||||||
|
|
||||||
api.event.on("session.next.prompted", (event) => started(event.properties.sessionID))
|
api.event.on("session.next.prompted", (event) => started(event.data.sessionID))
|
||||||
api.event.on("session.next.shell.started", (event) => started(event.properties.sessionID))
|
api.event.on("session.next.shell.started", (event) => started(event.data.sessionID))
|
||||||
api.event.on("session.next.step.started", (event) => started(event.properties.sessionID))
|
api.event.on("session.next.step.started", (event) => started(event.data.sessionID))
|
||||||
api.event.on("session.next.retried", (event) => started(event.properties.sessionID))
|
api.event.on("session.next.retried", (event) => started(event.data.sessionID))
|
||||||
api.event.on("session.next.compaction.started", (event) => started(event.properties.sessionID))
|
api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID))
|
||||||
api.event.on("session.next.shell.ended", (event) => ended(event.properties.sessionID))
|
api.event.on("session.next.shell.ended", (event) => ended(event.data.sessionID))
|
||||||
api.event.on("session.next.step.ended", (event) => {
|
api.event.on("session.next.step.ended", (event) => {
|
||||||
if (event.properties.finish === "tool-calls") return
|
if (event.data.finish === "tool-calls") return
|
||||||
ended(event.properties.sessionID)
|
ended(event.data.sessionID)
|
||||||
})
|
})
|
||||||
api.event.on("session.next.step.failed", (event) => {
|
api.event.on("session.next.step.failed", (event) => {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.data.sessionID
|
||||||
if (!active.has(sessionID)) return
|
if (!active.has(sessionID)) return
|
||||||
errored.add(sessionID)
|
errored.add(sessionID)
|
||||||
notify(api, sessionID, "Session error", "error")
|
notify(api, sessionID, "Session error", "error")
|
||||||
|
|
@ -93,11 +93,11 @@ const tui: TuiPlugin = async (api) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
api.event.on("session.error", (event) => {
|
api.event.on("session.error", (event) => {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.data.sessionID
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
if (!active.has(sessionID)) return
|
if (!active.has(sessionID)) return
|
||||||
errored.add(sessionID)
|
errored.add(sessionID)
|
||||||
notify(api, sessionID, sessionErrorMessage(event.properties.error), "error")
|
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1091,32 +1091,15 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||||
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
|
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<>
|
<box paddingLeft={3}>
|
||||||
<Show when={props.message.error}>
|
<text>
|
||||||
<box
|
<span style={{ fg: local.agent.color(props.message.agent) }}>{Locale.titlecase(props.message.agent)}</span>
|
||||||
border={["left"]}
|
<span style={{ fg: theme.textMuted }}> · {model()}</span>
|
||||||
paddingTop={1}
|
<Show when={duration()}>
|
||||||
paddingBottom={1}
|
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
||||||
paddingLeft={2}
|
</Show>
|
||||||
backgroundColor={theme.backgroundPanel}
|
</text>
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
</box>
|
||||||
borderColor={theme.error}
|
|
||||||
>
|
|
||||||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<box paddingLeft={3} marginTop={props.message.error ? 1 : 0}>
|
|
||||||
<text>
|
|
||||||
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
|
|
||||||
{Locale.titlecase(props.message.agent)}
|
|
||||||
</span>
|
|
||||||
<span style={{ fg: theme.textMuted }}> · {model()}</span>
|
|
||||||
<Show when={duration()}>
|
|
||||||
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
|
||||||
</Show>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1868,13 +1851,19 @@ function Shell(props: ToolProps) {
|
||||||
const color = createMemo(() => (permission() ? theme.warning : theme.text))
|
const color = createMemo(() => (permission() ? theme.warning : theme.text))
|
||||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||||
const command = createMemo(() => stringValue(props.input.command))
|
const command = createMemo(() => stringValue(props.input.command))
|
||||||
const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? ""))
|
const output = createMemo(() => {
|
||||||
|
if (props.part.state.status === "pending") return ""
|
||||||
|
const content = props.part.state.content[0]
|
||||||
|
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
|
||||||
|
})
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const maxLines = 10
|
const maxLines = 10
|
||||||
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
||||||
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
const input = createMemo(() => (command() ? `$ ${command()}` : ""))
|
||||||
|
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
|
||||||
|
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
|
||||||
const limited = createMemo(() => {
|
const limited = createMemo(() => {
|
||||||
if (expanded() || !collapsed().overflow) return output()
|
if (expanded() || !collapsed().overflow) return content()
|
||||||
return collapsed().output
|
return collapsed().output
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1882,13 +1871,21 @@ function Shell(props: ToolProps) {
|
||||||
<BlockTool part={props.part} onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}>
|
<BlockTool part={props.part} onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}>
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<Show when={command()} fallback={<Spinner color={color()}>Writing command...</Spinner>}>
|
<Show when={command()} fallback={<Spinner color={color()}>Writing command...</Spinner>}>
|
||||||
<Show when={isRunning()} fallback={<text fg={permission() ? theme.warning : theme.textMuted}>$ {command()}</text>}>
|
<Show
|
||||||
<Spinner color={color()}>{command()}</Spinner>
|
when={isRunning()}
|
||||||
|
fallback={
|
||||||
|
<text>
|
||||||
|
<span style={{ fg: theme.text }}>{limited().slice(0, input().length)}</span>
|
||||||
|
<span style={{ fg: theme.textMuted }}>{limited().slice(input().length)}</span>
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Spinner color={color()}>
|
||||||
|
<span style={{ fg: theme.text }}>{limited().slice(0, input().length)}</span>
|
||||||
|
<span style={{ fg: theme.textMuted }}>{limited().slice(input().length)}</span>
|
||||||
|
</Spinner>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={output()}>
|
|
||||||
<text fg={theme.text}>{limited()}</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={collapsed().overflow}>
|
<Show when={collapsed().overflow}>
|
||||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||||
export function reduceSessionRows(messages: SessionMessage[]) {
|
export function reduceSessionRows(messages: SessionMessage[]) {
|
||||||
return messages.reduce<SessionRow[]>((rows, message) => {
|
return messages.reduce<SessionRow[]>((rows, message) => {
|
||||||
if (message.type !== "assistant") {
|
if (message.type !== "assistant") {
|
||||||
|
completePrevious(rows)
|
||||||
rows.push({ type: "message", messageID: message.id })
|
rows.push({ type: "message", messageID: message.id })
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
@ -152,8 +153,10 @@ export function reduceSessionRows(messages: SessionMessage[]) {
|
||||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||||
append(rows, { messageID: message.id, partID: part.id }, part)
|
append(rows, { messageID: message.id, partID: part.id }, part)
|
||||||
})
|
})
|
||||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error)
|
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
|
||||||
|
completePrevious(rows)
|
||||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||||
|
}
|
||||||
return rows
|
return rows
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
|
||||||
return { output, overflow: false }
|
return { output, overflow: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = lines.slice(0, maxLines).join("\n")
|
const visible = lines.slice(0, maxLines)
|
||||||
|
if (lines.length > maxLines && visible.length > 0) visible[visible.length - 1] += "…"
|
||||||
|
const preview = visible.join("\n")
|
||||||
if (Array.from(preview).length > maxChars) {
|
if (Array.from(preview).length > maxChars) {
|
||||||
return {
|
return {
|
||||||
output:
|
output:
|
||||||
|
|
@ -15,5 +17,5 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
|
return { output: preview, overflow: true }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||||
import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
|
import type { PermissionRequest, QuestionRequest, Session, V2Event } from "@opencode-ai/sdk/v2"
|
||||||
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
||||||
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
const notifications: TuiAttentionNotifyInput[] = []
|
const notifications: TuiAttentionNotifyInput[] = []
|
||||||
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
|
const handlers = new Map<V2Event["type"], ((event: V2Event) => void)[]>()
|
||||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
|
@ -33,9 +33,12 @@ async function setup() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
|
on: <Type extends V2Event["type"]>(
|
||||||
|
type: Type,
|
||||||
|
handler: (event: Extract<V2Event, { type: Type }>) => void,
|
||||||
|
) => {
|
||||||
const list = handlers.get(type) ?? []
|
const list = handlers.get(type) ?? []
|
||||||
const wrapped = handler as (event: Event) => void
|
const wrapped = handler as (event: V2Event) => void
|
||||||
list.push(wrapped)
|
list.push(wrapped)
|
||||||
handlers.set(type, list)
|
handlers.set(type, list)
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -58,7 +61,7 @@ async function setup() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications,
|
notifications,
|
||||||
emit(event: Event) {
|
emit(event: V2Event) {
|
||||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -83,11 +86,11 @@ function permission(id: string, sessionID = "session"): PermissionRequest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepStarted(id: string, sessionID = "session"): Event {
|
function stepStarted(id: string, sessionID = "session"): V2Event {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type: "session.next.step.started",
|
type: "session.next.step.started",
|
||||||
properties: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: `msg_${id}`,
|
assistantMessageID: `msg_${id}`,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
|
@ -97,11 +100,11 @@ function stepStarted(id: string, sessionID = "session"): Event {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepEnded(id: string, sessionID = "session", finish = "stop"): Event {
|
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type: "session.next.step.ended",
|
type: "session.next.step.ended",
|
||||||
properties: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: `msg_${id}`,
|
assistantMessageID: `msg_${id}`,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
|
@ -112,11 +115,11 @@ function stepEnded(id: string, sessionID = "session", finish = "stop"): Event {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepFailed(id: string, sessionID = "session"): Event {
|
function stepFailed(id: string, sessionID = "session"): V2Event {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type: "session.next.step.failed",
|
type: "session.next.step.failed",
|
||||||
properties: {
|
data: {
|
||||||
sessionID,
|
sessionID,
|
||||||
assistantMessageID: `msg_${id}`,
|
assistantMessageID: `msg_${id}`,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
|
@ -143,8 +146,8 @@ describe("internal notifications TUI plugin", () => {
|
||||||
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
|
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
|
||||||
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
|
harness.emit({ id: "event-2", type: "permission.asked", data: permission("permission-1") })
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
|
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
|
||||||
})
|
})
|
||||||
|
|
@ -152,23 +155,23 @@ describe("internal notifications TUI plugin", () => {
|
||||||
test("dedupes pending questions and permissions until they are resolved", async () => {
|
test("dedupes pending questions and permissions until they are resolved", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
|
||||||
harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
|
harness.emit({ id: "event-2", type: "question.asked", data: question("question-1") })
|
||||||
harness.emit({
|
harness.emit({
|
||||||
id: "event-3",
|
id: "event-3",
|
||||||
type: "question.replied",
|
type: "question.replied",
|
||||||
properties: { sessionID: "session", requestID: "question-1", answers: [] },
|
data: { sessionID: "session", requestID: "question-1", answers: [] },
|
||||||
})
|
})
|
||||||
harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
|
harness.emit({ id: "event-4", type: "question.asked", data: question("question-1") })
|
||||||
|
|
||||||
harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
|
harness.emit({ id: "event-5", type: "permission.asked", data: permission("permission-1") })
|
||||||
harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
|
harness.emit({ id: "event-6", type: "permission.asked", data: permission("permission-1") })
|
||||||
harness.emit({
|
harness.emit({
|
||||||
id: "event-7",
|
id: "event-7",
|
||||||
type: "permission.replied",
|
type: "permission.replied",
|
||||||
properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
data: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
||||||
})
|
})
|
||||||
harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
|
harness.emit({ id: "event-8", type: "permission.asked", data: permission("permission-1") })
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
questionNotification,
|
questionNotification,
|
||||||
|
|
@ -198,7 +201,7 @@ describe("internal notifications TUI plugin", () => {
|
||||||
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
||||||
const harness = await setup()
|
const harness = await setup()
|
||||||
|
|
||||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
|
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1", "subagent") })
|
||||||
harness.emit(stepStarted("event-2", "subagent"))
|
harness.emit(stepStarted("event-2", "subagent"))
|
||||||
harness.emit(stepEnded("event-3", "subagent"))
|
harness.emit(stepEnded("event-3", "subagent"))
|
||||||
|
|
||||||
|
|
@ -242,13 +245,13 @@ describe("internal notifications TUI plugin", () => {
|
||||||
harness.emit({
|
harness.emit({
|
||||||
id: "event-2",
|
id: "event-2",
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||||
})
|
})
|
||||||
harness.emit(stepStarted("event-3", "timeout"))
|
harness.emit(stepStarted("event-3", "timeout"))
|
||||||
harness.emit({
|
harness.emit({
|
||||||
id: "event-4",
|
id: "event-4",
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(harness.notifications).toEqual([
|
expect(harness.notifications).toEqual([
|
||||||
|
|
|
||||||
14
packages/tui/test/cli/tui/collapse-tool-output.test.ts
Normal file
14
packages/tui/test/cli/tui/collapse-tool-output.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { collapseToolOutput } from "../../../src/util/collapse-tool-output"
|
||||||
|
|
||||||
|
test("limits command input and output to the same line budget", () => {
|
||||||
|
const command = Array.from({ length: 8 }, (_, index) => `command ${index + 1}`).join("\n")
|
||||||
|
const output = Array.from({ length: 4 }, (_, index) => `output ${index + 1}`).join("\n")
|
||||||
|
const collapsed = collapseToolOutput(`$ ${command}\n\n${output}`, 10, 1_000)
|
||||||
|
|
||||||
|
expect(collapsed.overflow).toBe(true)
|
||||||
|
expect(collapsed.output.split("\n")).toHaveLength(10)
|
||||||
|
expect(collapsed.output).toContain("$ command 1")
|
||||||
|
expect(collapsed.output).toContain("command 8\n\noutput 1…")
|
||||||
|
expect(collapsed.output).not.toContain("output 2")
|
||||||
|
})
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { ProjectProvider } from "../../../src/context/project"
|
import { ProjectProvider } from "../../../src/context/project"
|
||||||
import { SDKProvider } from "../../../src/context/sdk"
|
import { SDKProvider } from "../../../src/context/sdk"
|
||||||
|
|
@ -17,12 +17,8 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function global(payload: Event): GlobalEvent {
|
function emitEvent(events: ReturnType<typeof createEventStream>, event: V2Event) {
|
||||||
return { directory, project: "proj_test", payload }
|
events.emit({ ...event, location: { directory } })
|
||||||
}
|
|
||||||
|
|
||||||
function emitEvent(events: ReturnType<typeof createEventStream>, payload: Event) {
|
|
||||||
events.emit(global(payload))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("refreshes resources into reactive getters", async () => {
|
test("refreshes resources into reactive getters", async () => {
|
||||||
|
|
@ -205,7 +201,7 @@ test("tracks session status from active sessions and execution events", async ()
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_step_started",
|
id: "evt_step_started",
|
||||||
type: "session.next.step.started",
|
type: "session.next.step.started",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-live",
|
sessionID: "session-live",
|
||||||
assistantMessageID: "message-live",
|
assistantMessageID: "message-live",
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
|
@ -218,7 +214,7 @@ test("tracks session status from active sessions and execution events", async ()
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_step_ended",
|
id: "evt_step_ended",
|
||||||
type: "session.next.step.ended",
|
type: "session.next.step.ended",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-live",
|
sessionID: "session-live",
|
||||||
assistantMessageID: "message-live",
|
assistantMessageID: "message-live",
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
|
|
@ -291,7 +287,7 @@ test("refreshes integrations after integration updates", async () => {
|
||||||
expect(data.location.integration.list()).toEqual([])
|
expect(data.location.integration.list()).toEqual([])
|
||||||
const before = { ...requests }
|
const before = { ...requests }
|
||||||
|
|
||||||
emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} })
|
emitEvent(events, { id: "evt_integration", type: "integration.updated", data: {} })
|
||||||
await wait(() => data.location.integration.list()?.length === 1)
|
await wait(() => data.location.integration.list()?.length === 1)
|
||||||
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
||||||
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
|
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
|
||||||
|
|
@ -329,7 +325,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
|
||||||
try {
|
try {
|
||||||
await wait(() => requests.model > 0 && requests.provider > 0)
|
await wait(() => requests.model > 0 && requests.provider > 0)
|
||||||
const before = { ...requests }
|
const before = { ...requests }
|
||||||
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", properties: {} })
|
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", data: {} })
|
||||||
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
|
|
@ -374,7 +370,7 @@ test("refreshes references after updates", async () => {
|
||||||
try {
|
try {
|
||||||
await mounted
|
await mounted
|
||||||
await wait(() => requests === 1)
|
await wait(() => requests === 1)
|
||||||
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} })
|
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", data: {} })
|
||||||
await wait(() => data.location.reference.list()?.length === 1)
|
await wait(() => data.location.reference.list()?.length === 1)
|
||||||
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
|
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -409,7 +405,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_permission_asked_1",
|
id: "evt_permission_asked_1",
|
||||||
type: "permission.v2.asked",
|
type: "permission.v2.asked",
|
||||||
properties: {
|
data: {
|
||||||
id: "per_1",
|
id: "per_1",
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
action: "bash",
|
action: "bash",
|
||||||
|
|
@ -419,7 +415,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_permission_asked_2",
|
id: "evt_permission_asked_2",
|
||||||
type: "permission.v2.asked",
|
type: "permission.v2.asked",
|
||||||
properties: {
|
data: {
|
||||||
id: "per_2",
|
id: "per_2",
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
action: "read",
|
action: "read",
|
||||||
|
|
@ -431,7 +427,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_permission_replied_1",
|
id: "evt_permission_replied_1",
|
||||||
type: "permission.v2.replied",
|
type: "permission.v2.replied",
|
||||||
properties: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
|
data: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
|
||||||
})
|
})
|
||||||
await wait(() => data.session.permission.list("ses_1")?.length === 1)
|
await wait(() => data.session.permission.list("ses_1")?.length === 1)
|
||||||
expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2")
|
expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2")
|
||||||
|
|
@ -439,7 +435,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_permission_replied_2",
|
id: "evt_permission_replied_2",
|
||||||
type: "permission.v2.replied",
|
type: "permission.v2.replied",
|
||||||
properties: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
|
data: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
|
||||||
})
|
})
|
||||||
await wait(() => data.session.permission.list("ses_1")?.length === 0)
|
await wait(() => data.session.permission.list("ses_1")?.length === 0)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -474,7 +470,7 @@ test("adds and dismisses question requests from live events", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_question_asked_1",
|
id: "evt_question_asked_1",
|
||||||
type: "question.v2.asked",
|
type: "question.v2.asked",
|
||||||
properties: {
|
data: {
|
||||||
id: "que_1",
|
id: "que_1",
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
|
questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
|
||||||
|
|
@ -483,7 +479,7 @@ test("adds and dismisses question requests from live events", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_question_asked_2",
|
id: "evt_question_asked_2",
|
||||||
type: "question.v2.asked",
|
type: "question.v2.asked",
|
||||||
properties: {
|
data: {
|
||||||
id: "que_2",
|
id: "que_2",
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
|
questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
|
||||||
|
|
@ -494,7 +490,7 @@ test("adds and dismisses question requests from live events", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_question_replied_1",
|
id: "evt_question_replied_1",
|
||||||
type: "question.v2.replied",
|
type: "question.v2.replied",
|
||||||
properties: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
|
data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
|
||||||
})
|
})
|
||||||
await wait(() => data.session.question.list("ses_1")?.length === 1)
|
await wait(() => data.session.question.list("ses_1")?.length === 1)
|
||||||
expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
|
expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
|
||||||
|
|
@ -502,7 +498,7 @@ test("adds and dismisses question requests from live events", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_question_rejected_2",
|
id: "evt_question_rejected_2",
|
||||||
type: "question.v2.rejected",
|
type: "question.v2.rejected",
|
||||||
properties: { sessionID: "ses_1", requestID: "que_2" },
|
data: { sessionID: "ses_1", requestID: "que_2" },
|
||||||
})
|
})
|
||||||
await wait(() => data.session.question.list("ses_1")?.length === 0)
|
await wait(() => data.session.question.list("ses_1")?.length === 0)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -542,12 +538,12 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_agent_1",
|
id: "evt_agent_1",
|
||||||
type: "session.next.agent.switched",
|
type: "session.next.agent.switched",
|
||||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
data: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||||
})
|
})
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_model_1",
|
id: "evt_model_1",
|
||||||
type: "session.next.model.switched",
|
type: "session.next.model.switched",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
messageID: "msg_model_1",
|
messageID: "msg_model_1",
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
|
@ -557,7 +553,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_step_started_1",
|
id: "evt_step_started_1",
|
||||||
type: "session.next.step.started",
|
type: "session.next.step.started",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
|
@ -568,7 +564,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_input_1",
|
id: "evt_input_1",
|
||||||
type: "session.next.tool.input.started",
|
type: "session.next.tool.input.started",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
|
|
@ -579,7 +575,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_called_1",
|
id: "evt_called_1",
|
||||||
type: "session.next.tool.called",
|
type: "session.next.tool.called",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
|
|
@ -592,7 +588,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_failed_1",
|
id: "evt_failed_1",
|
||||||
type: "session.next.tool.failed",
|
type: "session.next.tool.failed",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
|
|
@ -673,7 +669,7 @@ test("renders admitted prompts only after they become model-visible", async () =
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_admitted_1",
|
id: "evt_admitted_1",
|
||||||
type: "session.next.prompt.admitted",
|
type: "session.next.prompt.admitted",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
messageID: "msg_user_1",
|
messageID: "msg_user_1",
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
|
@ -686,7 +682,7 @@ test("renders admitted prompts only after they become model-visible", async () =
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_prompted_1",
|
id: "evt_prompted_1",
|
||||||
type: "session.next.prompted",
|
type: "session.next.prompted",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
messageID: "msg_user_1",
|
messageID: "msg_user_1",
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
|
@ -744,7 +740,7 @@ test("projects live context updates with their message ID", async () => {
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_context_1",
|
id: "evt_context_1",
|
||||||
type: "session.next.context.updated",
|
type: "session.next.context.updated",
|
||||||
properties: {
|
data: {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
messageID: "msg_context_1",
|
messageID: "msg_context_1",
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,39 @@ test("groups across empty assistant reasoning parts", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("completes exploration groups when another row follows", () => {
|
||||||
|
const finished = assistant("assistant-2", [
|
||||||
|
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||||
|
])
|
||||||
|
finished.finish = "stop"
|
||||||
|
const messages: SessionMessage[] = [
|
||||||
|
assistant("assistant-1", [
|
||||||
|
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
|
||||||
|
]),
|
||||||
|
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
|
||||||
|
finished,
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(reduceSessionRows(messages)).toEqual([
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
kind: "exploration",
|
||||||
|
pending: [],
|
||||||
|
completed: true,
|
||||||
|
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||||
|
},
|
||||||
|
{ type: "message", messageID: "user-1" },
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
kind: "exploration",
|
||||||
|
pending: [],
|
||||||
|
completed: true,
|
||||||
|
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||||
|
},
|
||||||
|
{ type: "assistant-footer", messageID: "assistant-2" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||||
return {
|
return {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
|
||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { ProjectProvider, useProject } from "../../../src/context/project"
|
import { ProjectProvider, useProject } from "../../../src/context/project"
|
||||||
import { SDKProvider } from "../../../src/context/sdk"
|
import { SDKProvider, useSDK } from "../../../src/context/sdk"
|
||||||
import { useEvent } from "../../../src/context/event"
|
import { useEvent } from "../../../src/context/event"
|
||||||
import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk"
|
import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk"
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
|
|
@ -19,41 +19,40 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
|
function event(payload: V2Event, input: { directory: string; project?: string; workspace?: string }): V2Event {
|
||||||
return {
|
return {
|
||||||
directory: input.directory,
|
...payload,
|
||||||
project: input.project,
|
location: { directory: input.directory, workspaceID: input.workspace },
|
||||||
workspace: input.workspace,
|
|
||||||
payload,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function vcs(branch: string): Event {
|
function vcs(branch: string): V2Event {
|
||||||
return {
|
return {
|
||||||
id: `evt_vcs_${branch}`,
|
id: `evt_vcs_${branch}`,
|
||||||
type: "vcs.branch.updated",
|
type: "vcs.branch.updated",
|
||||||
properties: {
|
data: {
|
||||||
branch,
|
branch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function update(version: string): Event {
|
function update(version: string): V2Event {
|
||||||
return {
|
return {
|
||||||
id: `evt_update_${version}`,
|
id: `evt_update_${version}`,
|
||||||
type: "installation.update-available",
|
type: "installation.update-available",
|
||||||
properties: {
|
data: {
|
||||||
version,
|
version,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mount() {
|
async function mount(reload?: () => Promise<OpencodeClient>) {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch(undefined, events)
|
const calls = createFetch(undefined, events)
|
||||||
const seen: Event[] = []
|
const seen: V2Event[] = []
|
||||||
const workspaces: Array<string | undefined> = []
|
const workspaces: Array<string | undefined> = []
|
||||||
let project!: ReturnType<typeof useProject>
|
let project!: ReturnType<typeof useProject>
|
||||||
|
let sdk!: ReturnType<typeof useSDK>
|
||||||
let done!: () => void
|
let done!: () => void
|
||||||
const ready = new Promise<void>((resolve) => {
|
const ready = new Promise<void>((resolve) => {
|
||||||
done = resolve
|
done = resolve
|
||||||
|
|
@ -61,11 +60,12 @@ async function mount() {
|
||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(() => (
|
||||||
<TestTuiContexts>
|
<TestTuiContexts>
|
||||||
<SDKProvider client={createClient(calls.fetch)}>
|
<SDKProvider client={createClient(calls.fetch)} reload={reload}>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<Probe
|
<Probe
|
||||||
onReady={async (ctx) => {
|
onReady={async (ctx) => {
|
||||||
project = ctx.project
|
project = ctx.project
|
||||||
|
sdk = ctx.sdk
|
||||||
await project.sync()
|
await project.sync()
|
||||||
done()
|
done()
|
||||||
}}
|
}}
|
||||||
|
|
@ -78,15 +78,16 @@ async function mount() {
|
||||||
))
|
))
|
||||||
|
|
||||||
await ready
|
await ready
|
||||||
return { app, emit: events.emit, project, seen, workspaces }
|
return { app, emit: events.emit, project, sdk, seen, workspaces }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Probe(props: {
|
function Probe(props: {
|
||||||
seen: Event[]
|
seen: V2Event[]
|
||||||
workspaces: Array<string | undefined>
|
workspaces: Array<string | undefined>
|
||||||
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
|
onReady: (ctx: { project: ReturnType<typeof useProject>; sdk: ReturnType<typeof useSDK> }) => void
|
||||||
}) {
|
}) {
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
|
const sdk = useSDK()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -94,7 +95,7 @@ function Probe(props: {
|
||||||
props.seen.push(evt)
|
props.seen.push(evt)
|
||||||
props.workspaces.push(workspace)
|
props.workspaces.push(workspace)
|
||||||
})
|
})
|
||||||
props.onReady({ project })
|
props.onReady({ project, sdk })
|
||||||
})
|
})
|
||||||
|
|
||||||
return <box />
|
return <box />
|
||||||
|
|
@ -109,7 +110,7 @@ describe("useEvent", () => {
|
||||||
|
|
||||||
await wait(() => seen.length === 1)
|
await wait(() => seen.length === 1)
|
||||||
|
|
||||||
expect(seen).toEqual([vcs("main")])
|
expect(seen).toEqual([event(vcs("main"), { directory: "/tmp/other", workspace: "ws_a" })])
|
||||||
expect(workspaces).toEqual(["ws_a"])
|
expect(workspaces).toEqual(["ws_a"])
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
|
|
@ -125,7 +126,7 @@ describe("useEvent", () => {
|
||||||
|
|
||||||
await wait(() => seen.length === 1)
|
await wait(() => seen.length === 1)
|
||||||
|
|
||||||
expect(seen).toEqual([vcs("ws")])
|
expect(seen).toEqual([event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_b" })])
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +141,54 @@ describe("useEvent", () => {
|
||||||
|
|
||||||
await wait(() => seen.length === 1)
|
await wait(() => seen.length === 1)
|
||||||
|
|
||||||
expect(seen).toEqual([update("1.2.3")])
|
expect(seen).toEqual([event(update("1.2.3"), { directory: "global" })])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reloads the host and reconnects the event stream", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const events = createEventStream()
|
||||||
|
const replacement = createClient(createFetch(undefined, events).fetch)
|
||||||
|
const { app, sdk, seen } = await mount(async () => {
|
||||||
|
calls += 1
|
||||||
|
return replacement
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => sdk.connection.status() === "connected")
|
||||||
|
await sdk.reload?.()
|
||||||
|
await wait(() => sdk.connection.status() === "connected")
|
||||||
|
events.emit(event(vcs("reloaded"), { directory: "/tmp/reloaded" }))
|
||||||
|
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded"))
|
||||||
|
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
expect(sdk.client).toBe(replacement)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the current event stream alive while the host reload is pending", async () => {
|
||||||
|
let complete!: (client: OpencodeClient) => void
|
||||||
|
const pending = new Promise<OpencodeClient>((resolve) => {
|
||||||
|
complete = resolve
|
||||||
|
})
|
||||||
|
const replacementEvents = createEventStream()
|
||||||
|
const replacement = createClient(createFetch(undefined, replacementEvents).fetch)
|
||||||
|
const { app, emit, sdk, seen } = await mount(() => pending)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => sdk.connection.status() === "connected")
|
||||||
|
const reload = sdk.reload?.()
|
||||||
|
emit(event(vcs("during-reload"), { directory: "/tmp/reload" }))
|
||||||
|
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "during-reload"))
|
||||||
|
|
||||||
|
expect(sdk.connection.status()).toBe("connected")
|
||||||
|
complete(replacement)
|
||||||
|
await reload
|
||||||
|
expect(sdk.client).toBe(replacement)
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { V2Event } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
export const worktree = "/tmp/opencode"
|
export const worktree = "/tmp/opencode"
|
||||||
export const directory = `${worktree}/packages/tui`
|
export const directory = `${worktree}/packages/tui`
|
||||||
|
|
@ -13,12 +13,8 @@ export function json(data: unknown, init?: ResponseInit) {
|
||||||
|
|
||||||
export function createEventStream() {
|
export function createEventStream() {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const global = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
|
||||||
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||||
const pending = {
|
const pending: Uint8Array[] = []
|
||||||
global: [] as Uint8Array[],
|
|
||||||
v2: [] as Uint8Array[],
|
|
||||||
}
|
|
||||||
const response = (
|
const response = (
|
||||||
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
|
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
|
||||||
queued: Uint8Array[],
|
queued: Uint8Array[],
|
||||||
|
|
@ -54,24 +50,14 @@ export function createEventStream() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emit(event: GlobalEvent) {
|
emit(event: V2Event) {
|
||||||
send(global, pending.global, event)
|
send(v2, pending, event)
|
||||||
if (!("properties" in event.payload)) return
|
|
||||||
send(v2, pending.v2, {
|
|
||||||
...event.payload,
|
|
||||||
location: { directory: event.directory, workspaceID: event.workspace },
|
|
||||||
data: event.payload.properties,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
global() {
|
|
||||||
return response(global, pending.global)
|
|
||||||
},
|
},
|
||||||
v2() {
|
v2() {
|
||||||
return response(v2, pending.v2, { id: "evt_connected", type: "server.connected", data: {} })
|
return response(v2, pending, { id: "evt_connected", type: "server.connected", data: {} })
|
||||||
},
|
},
|
||||||
disconnect() {
|
disconnect() {
|
||||||
for (const controller of [...global, ...v2]) controller.close()
|
for (const controller of v2) controller.close()
|
||||||
global.clear()
|
|
||||||
v2.clear()
|
v2.clear()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +72,6 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||||
if (url.pathname === "/session") session.push(url)
|
if (url.pathname === "/session") session.push(url)
|
||||||
const overridden = await override?.(url)
|
const overridden = await override?.(url)
|
||||||
if (overridden) return overridden
|
if (overridden) return overridden
|
||||||
if (url.pathname === "/global/event" && events) return events.global()
|
|
||||||
if (url.pathname === "/api/event" && events) return events.v2()
|
if (url.pathname === "/api/event" && events) return events.v2()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
|
"@opencode-ai/tui#test": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
"@opencode-ai/app#test": {
|
"@opencode-ai/app#test": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": []
|
"outputs": []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue