Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28dc3cab22 | |||
| b47cfbee7c | |||
| 5504245f7b | |||
| f64b50d71b | |||
| a7b2ea94e5 | |||
| 7b775c2582 | |||
| 12a931a220 | |||
| 90100c1365 | |||
| 139c9febe4 | |||
| 06290907a9 | |||
| 1c8175a61a | |||
| fe91698ed6 | |||
| 068c32df39 | |||
| a2885d1662 | |||
| 38a3dbb4c4 | |||
| 43383d4fba | |||
| 40c4c3918a | |||
| 754ea99d86 | |||
| 37a1b80d5a | |||
| f95d04fea0 | |||
| 08b80da931 | |||
| f6fb1a7cdd | |||
| 5bcc0016a6 | |||
| 771174b5c3 | |||
| 44cd984589 | |||
| c445d98188 | |||
| 27e7b0558a | |||
| fb975eeb7c | |||
| e556aca833 | |||
| 73bd8a264b | |||
| 077338fcc8 | |||
| b671a77145 | |||
| 30d09a7d7e | |||
| ee02fb4fce |
@@ -0,0 +1,16 @@
|
||||
import type { Context } from "../../../packages/plugin/src/tui/context"
|
||||
|
||||
export default {
|
||||
id: "test.tui-discovery-smoke",
|
||||
setup(context: Context) {
|
||||
const timer = setTimeout(() => {
|
||||
context.ui.toast.show({
|
||||
title: "TUI plugin discovery works",
|
||||
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
|
||||
variant: "success",
|
||||
duration: 30_000,
|
||||
})
|
||||
}, 1_000)
|
||||
return () => clearTimeout(timer)
|
||||
},
|
||||
}
|
||||
@@ -147,6 +147,11 @@
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.10.1",
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||
@@ -164,6 +169,11 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
@@ -364,6 +374,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
@@ -596,17 +607,20 @@
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
"@opentui/keymap",
|
||||
"@opentui/solid",
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/protocol": {
|
||||
@@ -859,6 +873,20 @@
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode-ai/theme",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.4",
|
||||
@@ -868,6 +896,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -1030,6 +1059,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/cloudflare": "14.1.4",
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@types/bun": "catalog:",
|
||||
"astro": "7.1.3",
|
||||
"effect": "catalog:",
|
||||
@@ -1677,6 +1707,8 @@
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="],
|
||||
|
||||
"@ff-labs/fff-node": ["@ff-labs/fff-node@0.10.1", "", { "dependencies": { "ffi-rs": "^1.0.0" }, "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-I2TIWHkey4wpLCncUQymBz/yWrjp/TE91yjrPvSLaPMUMRf3wXwEmelGNnZ5ynxy3cUauYg5b5hsUwhOK4skfA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
@@ -2075,6 +2107,8 @@
|
||||
|
||||
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
|
||||
|
||||
"@opencode-ai/theme": ["@opencode-ai/theme@workspace:packages/theme"],
|
||||
|
||||
"@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
|
||||
|
||||
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
|
||||
@@ -3251,6 +3285,28 @@
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g=="],
|
||||
|
||||
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.2", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w=="],
|
||||
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg=="],
|
||||
|
||||
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
|
||||
|
||||
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
||||
@@ -4015,6 +4071,8 @@
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./testing": "./src/testing.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
}
|
||||
if (input.status !== undefined && input.status >= 500)
|
||||
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
@@ -145,7 +145,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 409 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
export * as TestLLM from "./testing"
|
||||
|
||||
import { LLMClient, type Interface as LLMClientShape } from "./route/client"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
type FinishReasonDetails,
|
||||
type LLMError,
|
||||
type LLMRequest,
|
||||
type UsageInput,
|
||||
} from "./schema"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
export interface Interface {
|
||||
readonly requests: LLMRequest[]
|
||||
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
|
||||
readonly always: (response: Response) => Effect.Effect<void>
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
|
||||
readonly client: LLMClientShape
|
||||
}
|
||||
|
||||
export interface LayerOptions {
|
||||
readonly transformRequest?: (request: LLMRequest) => LLMRequest
|
||||
/** Used after the one-shot response queue is exhausted. Omit to defect on unexpected requests. */
|
||||
readonly fallback?: Response
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
|
||||
|
||||
export const complete = (
|
||||
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
|
||||
...events: readonly LLMEvent[]
|
||||
) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
...events,
|
||||
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
|
||||
LLMEvent.finish({ reason: options.reason }),
|
||||
]
|
||||
|
||||
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
|
||||
|
||||
export const toolCalls = (...events: readonly LLMEvent[]) =>
|
||||
complete({ reason: { normalized: "tool-calls" } }, ...events)
|
||||
|
||||
const textEvents = (value: string, id: string) => [
|
||||
LLMEvent.textStart({ id }),
|
||||
LLMEvent.textDelta({ id, text: value }),
|
||||
LLMEvent.textEnd({ id }),
|
||||
]
|
||||
|
||||
export const text = (value: string, id: string) => stop(...textEvents(value, id))
|
||||
|
||||
export const textWithUsage = (value: string, id: string, inputTokens: number) =>
|
||||
complete(
|
||||
{ reason: { normalized: "stop" }, usage: { inputTokens, nonCachedInputTokens: inputTokens } },
|
||||
...textEvents(value, id),
|
||||
)
|
||||
|
||||
export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input }))
|
||||
|
||||
export const failAfter = (error: LLMError, ...events: readonly LLMEvent[]) =>
|
||||
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error)))
|
||||
|
||||
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
|
||||
|
||||
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
|
||||
|
||||
export const layer = (options: LayerOptions = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const requests: LLMRequest[] = []
|
||||
const responses: Response[] = []
|
||||
let started = Deferred.makeUnsafe<void>()
|
||||
let fallback = options.fallback
|
||||
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
|
||||
const stream = ((request: LLMRequest) => {
|
||||
requests.push(options.transformRequest?.(request) ?? request)
|
||||
const waiting = started
|
||||
started = Deferred.makeUnsafe()
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
const response = responses.shift() ?? fallback
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`))
|
||||
const streamed = toStream(response)
|
||||
const gate = activeGate
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
}) as LLMClientShape["stream"]
|
||||
const client = LLMClient.Service.of({
|
||||
prepare: () => Effect.die("TestLLM does not prepare provider-native requests"),
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("TestLLM response ended without a terminal finish event")
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
requests,
|
||||
push: (...input) =>
|
||||
Effect.sync(() => {
|
||||
responses.push(...input)
|
||||
}),
|
||||
always: (response) =>
|
||||
Effect.sync(() => {
|
||||
fallback = response
|
||||
}),
|
||||
wait,
|
||||
gate: Effect.gen(function* () {
|
||||
const gate = {
|
||||
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
|
||||
release: yield* Latch.make(),
|
||||
}
|
||||
activeGate = gate
|
||||
const release = Effect.sync(() => {
|
||||
if (activeGate === gate) activeGate = undefined
|
||||
}).pipe(Effect.andThen(gate.release.open), Effect.asVoid)
|
||||
yield* Effect.addFinalizer(() => release)
|
||||
return {
|
||||
started: Queue.take(gate.started),
|
||||
release,
|
||||
}
|
||||
}),
|
||||
client,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const clientLayer = Layer.effect(
|
||||
LLMClient.Service,
|
||||
Effect.map(Service, (service) => service.client),
|
||||
)
|
||||
|
||||
export const push = (...responses: readonly Response[]) => Service.use((service) => service.push(...responses))
|
||||
|
||||
export const always = (response: Response) => Service.use((service) => service.always(response))
|
||||
|
||||
export const wait = (count: number) => Service.use((service) => service.wait(count))
|
||||
|
||||
export const gate = Service.use((service) => service.gate)
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
OpenResponses,
|
||||
} from "@opencode-ai/ai/protocols"
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
|
||||
describe("public exports", () => {
|
||||
test("root exposes app-facing runtime APIs", () => {
|
||||
@@ -28,6 +29,7 @@ describe("public exports", () => {
|
||||
expect(ImageInput.bytes).toBeFunction()
|
||||
expect(Provider.make).toBeFunction()
|
||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||
expect(TestLLM.layer).toBeFunction()
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
|
||||
@@ -58,6 +58,12 @@ describe("provider error classification", () => {
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect(
|
||||
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
expect(
|
||||
[
|
||||
|
||||
@@ -6,22 +6,17 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Show } from "solid-js"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
import {
|
||||
type ServerDomainController,
|
||||
type ServerFormController,
|
||||
useServerDomainController,
|
||||
useServerFormController,
|
||||
} from "@/components/server/server-management-controller"
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
@@ -40,76 +35,6 @@ interface ServerFormProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return false
|
||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (
|
||||
value: string,
|
||||
username: string,
|
||||
password: string,
|
||||
setStatus: (value: boolean | undefined) => void,
|
||||
) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { previewStatus }
|
||||
}
|
||||
|
||||
function ServerForm(props: ServerFormProps) {
|
||||
const language = useLanguage()
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
@@ -177,400 +102,40 @@ function ServerForm(props: ServerFormProps) {
|
||||
|
||||
export function DialogSelectServer() {
|
||||
const dialog = useDialog()
|
||||
const controller = useServerManagementController({ onSelect: dialog.close })
|
||||
const language = useLanguage()
|
||||
const domain = useServerDomainController({ onSelect: () => dialog.close() })
|
||||
const form = useServerFormController({ onSelect: () => dialog.close() })
|
||||
const title = () => {
|
||||
if (!form.state.open()) return language.t("dialog.server.title")
|
||||
return (
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
|
||||
<span>
|
||||
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={controller.formTitle()}>
|
||||
<Dialog title={title()}>
|
||||
<div class="flex flex-1 min-h-0 flex-col px-5">
|
||||
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
||||
<ServerConnectionForm controller={controller} />
|
||||
<Show
|
||||
when={form.state.open()}
|
||||
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
|
||||
>
|
||||
<ServerConnectionForm form={form} />
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [store, setStore] = createStore({
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
editServer: {
|
||||
id: undefined as string | undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const resetAdd = () => {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const resetEdit = () => {
|
||||
setStore("editServer", {
|
||||
id: undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const addMutation = useMutation(() => ({
|
||||
mutationFn: async (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
if (options.navigateOnAdd === false) {
|
||||
server.add(conn)
|
||||
options.onSelect?.()
|
||||
return
|
||||
}
|
||||
await select(conn, true)
|
||||
},
|
||||
}))
|
||||
|
||||
const editMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||
if (input.original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(input.value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = input.original.displayName
|
||||
if (
|
||||
normalized === input.original.http.url &&
|
||||
name === existingName &&
|
||||
username === input.original.http.username &&
|
||||
password === input.original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(input.original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
},
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const originalKey = ServerConnection.key(original)
|
||||
const active = server.key
|
||||
tabs.removeServer(originalKey)
|
||||
const newConn = server.add(next)
|
||||
if (!newConn) return
|
||||
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
||||
if (nextActive) server.setActive(nextActive)
|
||||
server.remove(originalKey)
|
||||
}
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((x) => x !== current)]
|
||||
})
|
||||
|
||||
const settings = useSettings()
|
||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||
settings.general.newLayoutDesigns()
|
||||
? undefined
|
||||
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
||||
)
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = settings.general.newLayoutDesigns()
|
||||
? raw
|
||||
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff =
|
||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||
options.onSelect?.()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||
}
|
||||
|
||||
const handleAddChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddNameChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleAddUsernameChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { username: value, error: "" })
|
||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddPasswordChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { password: value, error: "" })
|
||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleEditUsernameChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { username: value, error: "" })
|
||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditPasswordChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { password: value, error: "" })
|
||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
return "list"
|
||||
})
|
||||
|
||||
const editing = createMemo(() => {
|
||||
if (!store.editServer.id) return
|
||||
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const startAdd = () => {
|
||||
resetEdit()
|
||||
setStore("addServer", {
|
||||
showForm: true,
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const startEdit = (conn: ServerConnection.Http) => {
|
||||
resetAdd()
|
||||
setStore("editServer", {
|
||||
id: conn.http.url,
|
||||
value: conn.http.url,
|
||||
name: conn.displayName ?? "",
|
||||
username: conn.http.username ?? "",
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
||||
})
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { error: "" })
|
||||
addMutation.mutate(store.addServer.url)
|
||||
return
|
||||
}
|
||||
const original = editing()
|
||||
if (!original) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { error: "" })
|
||||
editMutation.mutate({ original, value: store.editServer.value })
|
||||
}
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||
|
||||
const formTitle = createMemo(() => {
|
||||
if (!isFormMode()) return language.t("dialog.server.title")
|
||||
return (
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.editServer.id) return
|
||||
if (editing()) return
|
||||
resetEdit()
|
||||
})
|
||||
|
||||
async function handleRemove(key: ServerConnection.Key) {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) {
|
||||
await setDefault(null)
|
||||
}
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultKey,
|
||||
canDefault,
|
||||
current,
|
||||
sortedItems,
|
||||
status: () => global.servers.health,
|
||||
isFormMode,
|
||||
isAddMode,
|
||||
formTitle,
|
||||
formBusy,
|
||||
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
||||
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
||||
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
||||
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
||||
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
||||
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
||||
select,
|
||||
setDefault,
|
||||
startAdd,
|
||||
startEdit,
|
||||
resetForm,
|
||||
submitForm,
|
||||
handleRemove,
|
||||
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
||||
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
||||
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
||||
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
||||
}
|
||||
}
|
||||
|
||||
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||
export function ServerConnectionList(props: {
|
||||
domain: ServerDomainController
|
||||
onAdd: () => void
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
@@ -584,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
}}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={props.controller.sortedItems}
|
||||
items={props.domain.collection.items}
|
||||
key={(x) => x.http.url}
|
||||
onSelect={(x) => {
|
||||
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
||||
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
||||
}}
|
||||
divider={true}
|
||||
>
|
||||
@@ -596,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-center w-5">
|
||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={props.controller.status()[key]?.healthy === false}
|
||||
status={props.controller.status()[key]}
|
||||
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
||||
status={props.domain.collection.health()[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
|
||||
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -613,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-4 pl-4">
|
||||
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
||||
<Show
|
||||
when={
|
||||
props.domain.collection.current() &&
|
||||
ServerConnection.key(props.domain.collection.current()!) === key
|
||||
}
|
||||
>
|
||||
<Icon name="check" class="h-6" />
|
||||
</Show>
|
||||
|
||||
@@ -632,30 +202,32 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
props.controller.startEdit(i)
|
||||
props.onEdit(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={props.domain.connection.canRemove(key)}>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => props.domain.connection.remove(key)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
@@ -671,7 +243,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={props.controller.startAdd}
|
||||
onClick={props.onAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
@@ -681,38 +253,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||
export function ServerConnectionForm(props: { form: ServerFormController }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<ServerForm
|
||||
value={props.controller.formValue()}
|
||||
name={props.controller.formName()}
|
||||
username={props.controller.formUsername()}
|
||||
password={props.controller.formPassword()}
|
||||
value={props.form.state.value()}
|
||||
name={props.form.state.name()}
|
||||
username={props.form.state.username()}
|
||||
password={props.form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={props.controller.formBusy()}
|
||||
error={props.controller.formError()}
|
||||
status={props.controller.formStatus()}
|
||||
onChange={props.controller.handleFormChange()}
|
||||
onNameChange={props.controller.handleFormNameChange()}
|
||||
onUsernameChange={props.controller.handleFormUsernameChange()}
|
||||
onPasswordChange={props.controller.handleFormPasswordChange()}
|
||||
onSubmit={props.controller.submitForm}
|
||||
onBack={props.controller.resetForm}
|
||||
busy={props.form.state.busy()}
|
||||
error={props.form.state.error()}
|
||||
status={props.form.state.status()}
|
||||
onChange={props.form.change.value}
|
||||
onNameChange={props.form.change.name}
|
||||
onUsernameChange={props.form.change.username}
|
||||
onPasswordChange={props.form.change.password}
|
||||
onSubmit={props.form.submit}
|
||||
onBack={props.form.reset}
|
||||
/>
|
||||
<div class="shrink-0 pb-5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={props.controller.submitForm}
|
||||
disabled={props.controller.formBusy()}
|
||||
onClick={props.form.submit}
|
||||
disabled={props.form.state.busy()}
|
||||
class="px-3 py-1.5"
|
||||
>
|
||||
{props.controller.formBusy()
|
||||
{props.form.state.busy()
|
||||
? language.t("dialog.server.add.checking")
|
||||
: props.controller.isAddMode()
|
||||
: props.form.state.adding()
|
||||
? language.t("dialog.server.add.button")
|
||||
: language.t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
type FormMode = "list" | "add" | "edit"
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultKeyActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
return (await platform.getDefaultServer?.()) ?? null
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const set = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultKeyActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: () => defaultKey.latest,
|
||||
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
|
||||
set,
|
||||
}
|
||||
}
|
||||
|
||||
function useServerMutations() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
return {
|
||||
add: (connection: ServerConnection.Http) => server.add(connection),
|
||||
replace: (original: ServerConnection.Http, next: ServerConnection.Http) =>
|
||||
replaceServerConnection(original, next, {
|
||||
active: () => server.key,
|
||||
removeTabs: (key) => tabs.removeServer(key),
|
||||
add: (connection) => server.add(connection),
|
||||
setActive: (key) => server.setActive(key),
|
||||
remove: (key) => server.remove(key),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function useServerActionsController() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const defaults = useDefaultServer()
|
||||
|
||||
const remove = async (key: ServerConnection.Key) => {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaults, connection: { canRemove: server.canRemove, remove } }
|
||||
}
|
||||
|
||||
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
||||
|
||||
export function useServerCollectionController() {
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
const actions = useServerActionsController()
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((item) => item !== current)]
|
||||
})
|
||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||
settings.general.newLayoutDesigns()
|
||||
? undefined
|
||||
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
||||
)
|
||||
const sorted = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = settings.general.newLayoutDesigns()
|
||||
? raw
|
||||
: raw.filter((item) => global.ensureServerCtx(item).sdk.protocolKind() !== "v2")
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((item, index) => [item, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff =
|
||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
collection: {
|
||||
items: sorted,
|
||||
current,
|
||||
health: () => global.servers.health,
|
||||
},
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
||||
|
||||
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const collection = useServerCollectionController()
|
||||
|
||||
const select = async (connection: ServerConnection.Any) => {
|
||||
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
||||
options.onSelect?.()
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
||||
}
|
||||
|
||||
return { ...collection, selection: { select } }
|
||||
}
|
||||
|
||||
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
||||
|
||||
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const mutations = useServerMutations()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const healthPreview = createServerHealthPreview(checkServerHealth)
|
||||
const [store, setStore] = createStore({
|
||||
mode: "list" as FormMode,
|
||||
originalUrl: undefined as string | undefined,
|
||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
onCleanup(healthPreview.cancel)
|
||||
|
||||
const reset = () => {
|
||||
healthPreview.cancel()
|
||||
setStore({
|
||||
mode: "list",
|
||||
originalUrl: undefined,
|
||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const allServers = () => {
|
||||
if (!server.current || server.list.includes(server.current)) return server.list
|
||||
return [server.current, ...server.list]
|
||||
}
|
||||
const editing = createMemo(() =>
|
||||
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
||||
)
|
||||
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const normalized = normalizeServerUrl(store.values.url)
|
||||
if (!normalized) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const original = store.mode === "edit" ? editing() : undefined
|
||||
if (store.mode === "edit" && !original) return
|
||||
const name = store.values.name.trim() || undefined
|
||||
const username = store.values.username || undefined
|
||||
const password = store.values.password || undefined
|
||||
if (
|
||||
original?.type === "http" &&
|
||||
normalized === original.http.url &&
|
||||
name === original.displayName &&
|
||||
username === original.http.username &&
|
||||
password === original.http.password
|
||||
) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const connection: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: {
|
||||
url: normalized,
|
||||
username: store.mode === "add" && !password ? undefined : username,
|
||||
password,
|
||||
},
|
||||
}
|
||||
const result = await checkServerHealth(connection.http)
|
||||
if (!result.healthy) {
|
||||
setStore("error", language.t("dialog.server.add.error"))
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(connection.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("error", language.t("dialog.server.add.error"))
|
||||
return
|
||||
}
|
||||
|
||||
if (original?.type === "http") {
|
||||
if (normalized === original.http.url) mutations.add(connection)
|
||||
if (normalized !== original.http.url) mutations.replace(original, connection)
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
reset()
|
||||
if (options.navigateOnAdd === false) {
|
||||
mutations.add(connection)
|
||||
options.onSelect?.()
|
||||
return
|
||||
}
|
||||
mutations.add(connection)
|
||||
options.onSelect?.()
|
||||
navigate("/")
|
||||
},
|
||||
}))
|
||||
|
||||
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
|
||||
const change = (field: keyof ServerFormValues, value: string) => {
|
||||
if (request.isPending) return
|
||||
setStore("values", field, value)
|
||||
setStore("error", "")
|
||||
if (field !== "name") preview()
|
||||
}
|
||||
const startAdd = () => {
|
||||
reset()
|
||||
setStore("mode", "add")
|
||||
}
|
||||
const startEdit = (connection: ServerConnection.Http) => {
|
||||
reset()
|
||||
setStore({
|
||||
mode: "edit",
|
||||
originalUrl: connection.http.url,
|
||||
values: {
|
||||
url: connection.http.url,
|
||||
name: connection.displayName ?? "",
|
||||
username: connection.http.username ?? "",
|
||||
password: connection.http.password ?? "",
|
||||
},
|
||||
error: "",
|
||||
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
|
||||
})
|
||||
}
|
||||
const submit = () => {
|
||||
if (store.mode === "list" || request.isPending) return
|
||||
setStore("error", "")
|
||||
request.mutate()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.mode !== "edit") return
|
||||
if (editing()) return
|
||||
reset()
|
||||
})
|
||||
|
||||
return {
|
||||
state: {
|
||||
mode: () => store.mode,
|
||||
open: () => store.mode !== "list",
|
||||
adding: () => store.mode === "add",
|
||||
busy: () => request.isPending,
|
||||
value: () => store.values.url,
|
||||
name: () => store.values.name,
|
||||
username: () => store.values.username,
|
||||
password: () => store.values.password,
|
||||
error: () => store.error,
|
||||
status: () => store.status,
|
||||
},
|
||||
change: {
|
||||
value: (value: string) => change("url", value),
|
||||
name: (value: string) => change("name", value),
|
||||
username: (value: string) => change("username", value),
|
||||
password: (value: string) => change("password", value),
|
||||
},
|
||||
start: { add: startAdd, edit: startEdit },
|
||||
reset,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerFormController = ReturnType<typeof useServerFormController>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
|
||||
|
||||
describe("createServerHealthPreview", () => {
|
||||
test("ignores an older response that resolves after the latest response", async () => {
|
||||
const first = deferred<{ healthy: boolean }>()
|
||||
const second = deferred<{ healthy: boolean }>()
|
||||
const requests = [first, second]
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => requests.shift()!.promise)
|
||||
|
||||
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
|
||||
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
|
||||
second.resolve({ healthy: true })
|
||||
await latest
|
||||
first.resolve({ healthy: false })
|
||||
await older
|
||||
|
||||
expect(status).toEqual([undefined, undefined, true])
|
||||
})
|
||||
|
||||
test("an incomplete value invalidates an in-flight response", async () => {
|
||||
const request = deferred<{ healthy: boolean }>()
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => request.promise)
|
||||
|
||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||
await preview.preview(values("server"), (value) => status.push(value))
|
||||
request.resolve({ healthy: true })
|
||||
await pending
|
||||
|
||||
expect(status).toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
test("cancellation prevents an in-flight response from updating status", async () => {
|
||||
const request = deferred<{ healthy: boolean }>()
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => request.promise)
|
||||
|
||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||
preview.cancel()
|
||||
request.resolve({ healthy: true })
|
||||
await pending
|
||||
|
||||
expect(status).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe("replaceServerConnection", () => {
|
||||
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
|
||||
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
|
||||
|
||||
test("moves active selection after adding the replacement and removes the original", () => {
|
||||
const calls: string[] = []
|
||||
|
||||
replaceServerConnection(original, next, {
|
||||
active: () => ServerConnection.key(original),
|
||||
removeTabs: (key) => calls.push(`tabs:${key}`),
|
||||
add: (server) => {
|
||||
calls.push(`add:${ServerConnection.key(server)}`)
|
||||
return server
|
||||
},
|
||||
setActive: (key) => calls.push(`active:${key}`),
|
||||
remove: (key) => calls.push(`remove:${key}`),
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
"tabs:https://old.example.com",
|
||||
"add:https://new.example.com",
|
||||
"active:https://new.example.com",
|
||||
"remove:https://old.example.com",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps the original when the replacement cannot be added", () => {
|
||||
const removed: ServerConnection.Key[] = []
|
||||
|
||||
replaceServerConnection(original, next, {
|
||||
active: () => ServerConnection.key(original),
|
||||
removeTabs: () => {},
|
||||
add: () => undefined,
|
||||
setActive: () => {},
|
||||
remove: (key) => removed.push(key),
|
||||
})
|
||||
|
||||
expect(removed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
|
||||
export type ServerFormValues = {
|
||||
url: string
|
||||
name: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function createServerHealthPreview(
|
||||
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
|
||||
) {
|
||||
let generation = 0
|
||||
|
||||
const cancel = () => {
|
||||
generation += 1
|
||||
}
|
||||
|
||||
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
|
||||
const current = ++generation
|
||||
setStatus(undefined)
|
||||
const normalized = normalizeServerUrl(values.url)
|
||||
if (!normalized) return
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return
|
||||
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
|
||||
return
|
||||
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (values.username) http.username = values.username
|
||||
if (values.password) http.password = values.password
|
||||
const result = await check(http)
|
||||
if (current !== generation) return
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { cancel, preview }
|
||||
}
|
||||
|
||||
export function replaceServerConnection(
|
||||
original: ServerConnection.Http,
|
||||
next: ServerConnection.Http,
|
||||
operations: {
|
||||
active: () => ServerConnection.Key | undefined
|
||||
removeTabs: (key: ServerConnection.Key) => void
|
||||
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
||||
setActive: (key: ServerConnection.Key) => void
|
||||
remove: (key: ServerConnection.Key) => void
|
||||
},
|
||||
) {
|
||||
const originalKey = ServerConnection.key(original)
|
||||
const active = operations.active()
|
||||
operations.removeTabs(originalKey)
|
||||
const added = operations.add(next)
|
||||
if (!added) return
|
||||
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
||||
if (nextActive) operations.setActive(nextActive)
|
||||
operations.remove(originalKey)
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export const ServerRowMenu: Component<{
|
||||
server: ServerConnection.Any
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
domain: ServerActionsController
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
@@ -19,12 +19,13 @@ export const ServerRowMenu: Component<{
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(language)}
|
||||
canDefault={props.controller.canDefault()}
|
||||
isDefault={props.controller.defaultKey() === key}
|
||||
canDefault={props.domain.defaults.available()}
|
||||
isDefault={props.domain.defaults.key() === key}
|
||||
canRemove={props.domain.connection.canRemove(key)}
|
||||
onEdit={props.onEdit}
|
||||
onSetDefault={() => props.controller.setDefault(key)}
|
||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||
onRemove={() => props.controller.handleRemove(key)}
|
||||
onSetDefault={() => props.domain.defaults.set(key)}
|
||||
onRemoveDefault={() => props.domain.defaults.set(null)}
|
||||
onRemove={() => props.domain.connection.remove(key)}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
@@ -47,6 +48,7 @@ export const ServerRowMenuView: Component<{
|
||||
labels: ReturnType<typeof serverMenuLabels>
|
||||
canDefault: boolean
|
||||
isDefault: boolean
|
||||
canRemove: boolean
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
onSetDefault: () => void
|
||||
onRemoveDefault: () => void
|
||||
@@ -84,10 +86,10 @@ export const ServerRowMenuView: Component<{
|
||||
<Show when={props.canDefault && props.isDefault}>
|
||||
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||
{props.labels.delete}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canRemove}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={props.onRemove}>{props.labels.delete}</MenuV2.Item>
|
||||
</Show>
|
||||
</MenuV2.Group>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Show, type Component } from "solid-js"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
|
||||
import { useServerDomainController, useServerFormController } from "./server/server-management-controller"
|
||||
import { ServerConnectionForm, ServerConnectionList } from "./dialog-select-server"
|
||||
|
||||
export const SettingsServers: Component = () => {
|
||||
const language = useLanguage()
|
||||
const controller = useServerManagementController()
|
||||
const domain = useServerDomainController()
|
||||
const form = useServerFormController()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
|
||||
<Show
|
||||
when={controller.isFormMode()}
|
||||
when={form.state.open()}
|
||||
fallback={
|
||||
<>
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
@@ -18,13 +21,25 @@ export const SettingsServers: Component = () => {
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ServerConnectionList controller={controller} />
|
||||
<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
|
||||
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
|
||||
<ServerConnectionForm controller={controller} />
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={form.reset}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
<span>
|
||||
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ServerConnectionForm form={form} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type ServerConnection } from "@/context/server"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import { useServerFormController } from "../server/server-management-controller"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const DialogServerV2: Component<{
|
||||
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const controller = useServerManagementController({
|
||||
const form = useServerFormController({
|
||||
onSelect: () => dialog.close(),
|
||||
navigateOnAdd: false,
|
||||
})
|
||||
const [opened, setOpened] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
if (props.mode === "add") controller.startAdd()
|
||||
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
||||
if (props.mode === "add") form.start.add()
|
||||
if (props.mode === "edit" && props.server) form.start.edit(props.server)
|
||||
setOpened(true)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
controller.resetForm()
|
||||
form.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) return
|
||||
if (controller.isFormMode()) return
|
||||
if (form.state.open()) return
|
||||
dialog.close()
|
||||
})
|
||||
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
controller.submitForm()
|
||||
form.submit()
|
||||
}
|
||||
|
||||
const title = () =>
|
||||
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||
|
||||
const submitLabel = () => {
|
||||
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
||||
if (form.state.busy()) return language.t("dialog.server.add.checking")
|
||||
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||
return language.t("common.save")
|
||||
}
|
||||
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formValue()}
|
||||
value={form.state.value()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!controller.formError()}
|
||||
disabled={controller.formBusy()}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<Show when={controller.formError()}>
|
||||
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
||||
<Show when={form.state.error()}>
|
||||
<span class="settings-v2-server-dialog-error">{form.state.error()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formName()}
|
||||
value={form.state.name()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formUsername()}
|
||||
value={form.state.username()}
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.username(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formPassword()}
|
||||
value={form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
||||
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
{submitLabel()}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import { useServerCollectionController } from "../server/server-management-controller"
|
||||
import { DialogServerV2 } from "./dialog-server-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||
@@ -19,16 +19,16 @@ import "./settings-v2.css"
|
||||
export const SettingsServersV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const controller = useServerManagementController()
|
||||
const domain = useServerCollectionController()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const wslServers = useFilteredWslServers(() => store.filter)
|
||||
|
||||
const showSearch = createMemo(
|
||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
)
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||
const items = domain.collection.items().filter((item) => !isWslServer(item))
|
||||
const query = store.filter.trim()
|
||||
if (!query) return items
|
||||
return fuzzysort
|
||||
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
|
||||
})
|
||||
|
||||
const openAdd = () => {
|
||||
dialog.push(() => <DialogServerV2 mode="add" />)
|
||||
void dialog.push(() => <DialogServerV2 mode="add" />)
|
||||
}
|
||||
|
||||
const openEdit = (server: ServerConnection.Http) => {
|
||||
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
|
||||
}
|
||||
>
|
||||
<SettingsListV2>
|
||||
<WslServerSettings controller={controller} servers={wslServers} />
|
||||
<WslServerSettings domain={domain} servers={wslServers} />
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const health = () => controller.status()[key]
|
||||
const isDefault = () => controller.defaultKey() === key
|
||||
const health = () => domain.collection.health()[key]
|
||||
const isDefault = () => domain.defaults.key() === key
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={controller.canDefault() && isDefault()}>
|
||||
<Show when={domain.defaults.available() && isDefault()}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
||||
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -395,27 +395,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Show when={!creating()}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("command.session.new")}
|
||||
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("command.session.new")}
|
||||
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
<div class="flex-1" />
|
||||
<TitlebarV2Right state={v2RightState()} />
|
||||
</div>
|
||||
|
||||
@@ -178,6 +178,17 @@ export function resolveServerList(input: {
|
||||
return [...deduped.values()]
|
||||
}
|
||||
|
||||
export function canRemoveServer(input: {
|
||||
key: ServerConnection.Key
|
||||
provided?: Array<ServerConnection.Any>
|
||||
stored: StoredServer[]
|
||||
}) {
|
||||
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
|
||||
return input.stored.some((server) =>
|
||||
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
|
||||
)
|
||||
}
|
||||
|
||||
export namespace ServerConnection {
|
||||
type Base = { displayName?: string; label?: string }
|
||||
|
||||
@@ -312,6 +323,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
})
|
||||
}
|
||||
|
||||
function canRemove(key: ServerConnection.Key) {
|
||||
return canRemoveServer({ key, provided: props.servers, stored: store.list })
|
||||
}
|
||||
|
||||
const isReady = Object.assign(
|
||||
createMemo(() => ready() && !!state.active),
|
||||
{ promise: ready.promise },
|
||||
@@ -350,6 +365,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
setActive,
|
||||
add,
|
||||
remove,
|
||||
canRemove,
|
||||
scope,
|
||||
projects: {
|
||||
...projects,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { useServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const language = useLanguage()
|
||||
const notification = useNotification()
|
||||
const openSettings = useSettingsCommand()
|
||||
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
||||
const serverManagement = useServerActionsController()
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
@@ -56,11 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const key = ServerConnection.key(conn)
|
||||
setState("collapsed", key, !state().collapsed[key])
|
||||
},
|
||||
canDefault: serverManagement.canDefault,
|
||||
defaultKey: serverManagement.defaultKey,
|
||||
canDefault: serverManagement.defaults.available,
|
||||
defaultKey: serverManagement.defaults.key,
|
||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
||||
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
||||
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||
focus: home.selection.focusServer,
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ export type HomeProjectsViewProps = {
|
||||
onToggleCollapsed: (server: ServerConnection.Any) => void
|
||||
onEditServer: (server: ServerConnection.Http) => void
|
||||
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
|
||||
canRemoveServer: (server: ServerConnection.Any) => boolean
|
||||
onRemoveServer: (server: ServerConnection.Any) => void
|
||||
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
|
||||
onSelectProject: (server: ServerConnection.Any, directory: string) => void
|
||||
@@ -192,6 +193,7 @@ function HomeServerRow(props: {
|
||||
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
|
||||
onEditServer: HomeProjectsViewProps["onEditServer"]
|
||||
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
|
||||
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
|
||||
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
|
||||
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
|
||||
onChooseProject: HomeProjectsViewProps["onChooseProject"]
|
||||
@@ -277,6 +279,7 @@ function HomeServerRow(props: {
|
||||
labels={serverMenuLabels(props.language)}
|
||||
canDefault={props.canDefaultServer()}
|
||||
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
|
||||
canRemove={props.canRemoveServer(props.server)}
|
||||
onEdit={props.onEditServer}
|
||||
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
||||
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
|
||||
onToggleCollapsed={props.projects.server.toggleCollapsed}
|
||||
onEditServer={props.projects.server.edit}
|
||||
onSetDefaultServer={props.projects.server.setDefault}
|
||||
canRemoveServer={props.projects.server.canRemove}
|
||||
onRemoveServer={props.projects.server.remove}
|
||||
onMoveProject={props.projects.project.move}
|
||||
onSelectProject={props.projects.project.select}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { type Accessor, For, Show, createMemo } from "solid-js"
|
||||
import type { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import type { ServerCollectionController } from "@/components/server/server-management-controller"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -17,8 +17,6 @@ import { DialogAddWslServer } from "./dialog-add-server"
|
||||
import { useWslServers } from "./context"
|
||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||
|
||||
type Controller = ReturnType<typeof useServerManagementController>
|
||||
|
||||
export function isWslServer(server: ServerConnection.Any) {
|
||||
return server.type === "sidecar" && server.variant === "wsl"
|
||||
}
|
||||
@@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const openAddWsl = () => {
|
||||
dialog.push(() => <DialogAddWslServer />)
|
||||
void dialog.push(() => <DialogAddWslServer />)
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
@@ -67,7 +65,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
||||
}
|
||||
|
||||
export function WslServerSettings(props: {
|
||||
controller: Controller
|
||||
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
||||
servers: ReturnType<typeof useFilteredWslServers>
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
@@ -86,7 +84,7 @@ export function WslServerSettings(props: {
|
||||
}))
|
||||
|
||||
const remove = (key: ServerConnection.Key) => {
|
||||
request.mutate(() => props.controller.handleRemove(key))
|
||||
request.mutate(() => props.domain.connection.remove(key))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -100,7 +98,7 @@ export function WslServerSettings(props: {
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
<div class="settings-v2-servers-copy">
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
||||
@@ -114,7 +112,7 @@ export function WslServerSettings(props: {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<Show when={opencodeAction()}>
|
||||
@@ -145,13 +143,13 @@ export function WslServerSettings(props: {
|
||||
{language.t("wsl.server.retryStart")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
|
||||
@@ -56,6 +56,16 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.10.1",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
@@ -22,7 +23,7 @@ await rm(outdir, { recursive: true, force: true })
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
const solidPlugin = createSolidTransformPlugin()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -55,6 +56,16 @@ const targets = singleFlag
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
|
||||
for (const item of targets) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||
const parcelWatcherPlugin: BunPlugin = {
|
||||
name: "parcel-watcher-binding",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
|
||||
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
}
|
||||
const target = [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
@@ -69,7 +80,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
plugins: [solidPlugin, parcelWatcherPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -37,6 +37,8 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||
}),
|
||||
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
|
||||
{ key: target.fffAsset, source: fileURLToPath(import.meta.resolve(target.fffPackage)) },
|
||||
{ key: target.fffFfiAsset, source: fileURLToPath(import.meta.resolve(target.fffFfiPackage)) },
|
||||
{
|
||||
key: photonWasmAsset,
|
||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||
|
||||
@@ -13,7 +13,7 @@ const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["nod
|
||||
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
|
||||
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
@@ -29,6 +29,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
|
||||
const errors: Array<Promise<string>> = []
|
||||
let failure: unknown
|
||||
try {
|
||||
await fs.mkdir(path.join(root, ".opencode"))
|
||||
spawnService()
|
||||
spawnService()
|
||||
const registration = await waitForRegistration()
|
||||
@@ -49,6 +50,11 @@ try {
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
)
|
||||
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
|
||||
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
|
||||
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
|
||||
await fs.mkdir(path.dirname(plugin), { recursive: true })
|
||||
await fs.writeFile(plugin, pluginSource())
|
||||
await waitForPlugin(info.url, headers)
|
||||
|
||||
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
@@ -88,6 +94,7 @@ try {
|
||||
} finally {
|
||||
processes.forEach((process) => process.kill())
|
||||
await Promise.all(processes.map((process) => process.exited))
|
||||
if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
|
||||
}
|
||||
|
||||
const output = await Promise.all(errors)
|
||||
@@ -133,3 +140,31 @@ async function waitForReady(url: string, headers: HeadersInit) {
|
||||
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
|
||||
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
|
||||
}
|
||||
|
||||
function pluginSource() {
|
||||
return 'export default { id: "smoke", setup: async () => {} }\n'
|
||||
}
|
||||
|
||||
async function pluginIDs(url: string, headers: HeadersInit) {
|
||||
const endpoint = new URL("/api/plugin", url)
|
||||
endpoint.searchParams.set("location[directory]", root)
|
||||
const response = await fetch(endpoint, { headers, signal: AbortSignal.timeout(5_000) })
|
||||
const body: unknown = await response.json()
|
||||
if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) {
|
||||
throw new Error("Compiled service returned an invalid plugin list")
|
||||
}
|
||||
return body.data.flatMap((plugin) =>
|
||||
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string"
|
||||
? [plugin.id]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForPlugin(url: string, headers: HeadersInit) {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (Date.now() < deadline) {
|
||||
if ((await pluginIDs(url, headers)).includes("smoke")) return
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error("Compiled service did not discover the created plugin")
|
||||
}
|
||||
|
||||
@@ -14,10 +14,23 @@ const ServerParams = {
|
||||
),
|
||||
}
|
||||
|
||||
const PermissionParams = {
|
||||
auto: Flag.boolean("auto").pipe(
|
||||
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
|
||||
Flag.withDefault(false),
|
||||
Flag.withHidden,
|
||||
),
|
||||
}
|
||||
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
params: {
|
||||
...ServerParams,
|
||||
...PermissionParams,
|
||||
directory: Argument.string("directory").pipe(
|
||||
Argument.withDescription("Directory to start OpenCode in"),
|
||||
Argument.optional,
|
||||
@@ -196,11 +209,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
),
|
||||
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
|
||||
auto: Flag.boolean("auto").pipe(
|
||||
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
...PermissionParams,
|
||||
},
|
||||
}),
|
||||
Spec.make("service", {
|
||||
|
||||
@@ -59,6 +59,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
continue: input.continue,
|
||||
sessionID: Option.getOrUndefined(input.session),
|
||||
prompt: Option.getOrUndefined(input.prompt),
|
||||
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
||||
},
|
||||
config: {
|
||||
path: config.path,
|
||||
|
||||
@@ -24,7 +24,7 @@ export default Runtime.handler(Commands.commands.run, (input) =>
|
||||
file: [...input.file],
|
||||
title: Option.getOrUndefined(input.title),
|
||||
thinking: input.thinking,
|
||||
auto: input.auto || input.yolo,
|
||||
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -11,6 +11,8 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
const targetArch = arch as "arm64" | "x64"
|
||||
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
|
||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
||||
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
|
||||
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
|
||||
|
||||
return {
|
||||
platform: targetPlatform,
|
||||
@@ -19,6 +21,10 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
|
||||
parcelWatcherPackage,
|
||||
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
|
||||
fffPackage,
|
||||
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
|
||||
fffFfiPackage,
|
||||
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,44 @@ function runtimeRequirePlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function fffNodePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:fff-node",
|
||||
enforce: "pre",
|
||||
transform(code, id) {
|
||||
const normalized = id.replaceAll("\\", "/")
|
||||
if (normalized.endsWith("/ffi-rs/index.js")) {
|
||||
const start = code.indexOf("if (!nativeBinding) {")
|
||||
if (start === -1) this.error("Failed to rewrite ffi-rs native binding loader")
|
||||
return `const unavailable = () => { throw new Error("ffi-rs native binding unavailable") }
|
||||
const nativeBinding = globalThis.__OPENCODE_FFF_FFI ?? {
|
||||
DataType: new Proxy({}, { get: (target, key) => target[key] ?? key }),
|
||||
PointerType: {},
|
||||
FFITypeTag: {},
|
||||
open: unavailable,
|
||||
close: unavailable,
|
||||
load: unavailable,
|
||||
isNullPointer: unavailable,
|
||||
createPointer: unavailable,
|
||||
restorePointer: unavailable,
|
||||
unwrapPointer: unavailable,
|
||||
wrapPointer: unavailable,
|
||||
freePointer: unavailable,
|
||||
}
|
||||
const loadError = undefined
|
||||
${code.slice(start)}`
|
||||
}
|
||||
if (!normalized.endsWith("/fff-node/dist/src/binary.js")) return
|
||||
const transformed = code.replace(
|
||||
"export function findBinary() {",
|
||||
"export function findBinary() { if (process.env.FFF_BINARY_PATH) return process.env.FFF_BINARY_PATH;",
|
||||
)
|
||||
if (transformed === code) this.error("Failed to rewrite FFF binary loader")
|
||||
return transformed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const resolve = {
|
||||
alias: [
|
||||
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
|
||||
@@ -156,6 +194,11 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
try {
|
||||
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
|
||||
} catch {}
|
||||
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
|
||||
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
|
||||
}
|
||||
@@ -174,6 +217,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
plugins: [
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
solid({
|
||||
solid: {
|
||||
generate: "universal",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
|
||||
@@ -1,71 +1,52 @@
|
||||
import type {
|
||||
DirItem,
|
||||
DirSearchResult,
|
||||
FileItem,
|
||||
GrepCursor,
|
||||
GrepMatch,
|
||||
GrepResult,
|
||||
InitOptions,
|
||||
MixedItem,
|
||||
MixedSearchResult,
|
||||
SearchResult,
|
||||
} from "@ff-labs/fff-node"
|
||||
|
||||
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export interface Init {
|
||||
basePath: string
|
||||
frecencyDbPath?: string
|
||||
historyDbPath?: string
|
||||
useUnsafeNoLock?: boolean
|
||||
disableMmapCache?: boolean
|
||||
disableContentIndexing?: boolean
|
||||
disableWatch?: boolean
|
||||
aiMode?: boolean
|
||||
logFilePath?: string
|
||||
logLevel?: "trace" | "debug" | "info" | "warn" | "error"
|
||||
enableFsRootScanning?: boolean
|
||||
enableHomeDirScanning?: boolean
|
||||
}
|
||||
|
||||
export interface File {
|
||||
relativePath: string
|
||||
fileName: string
|
||||
modified: number
|
||||
}
|
||||
|
||||
export interface Directory {
|
||||
relativePath: string
|
||||
dirName: string
|
||||
maxAccessFrecency: number
|
||||
}
|
||||
|
||||
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
|
||||
export type Init = InitOptions
|
||||
|
||||
export interface Search {
|
||||
items: File[]
|
||||
scores: Array<{ total: number }>
|
||||
items: FileItem[]
|
||||
scores: SearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
export interface DirSearch {
|
||||
items: Directory[]
|
||||
scores: Array<{ total: number }>
|
||||
items: DirItem[]
|
||||
scores: DirSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export interface MixedSearch {
|
||||
items: Mixed[]
|
||||
scores: Array<{ total: number }>
|
||||
items: MixedItem[]
|
||||
scores: MixedSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export type Cursor = null
|
||||
|
||||
export interface Hit {
|
||||
relativePath: string
|
||||
fileName: string
|
||||
lineNumber: number
|
||||
byteOffset: number
|
||||
lineContent: string
|
||||
matchRanges: [number, number][]
|
||||
contextBefore?: string[]
|
||||
contextAfter?: string[]
|
||||
}
|
||||
export type File = FileItem
|
||||
export type Directory = DirItem
|
||||
export type Mixed = MixedItem
|
||||
export type Cursor = GrepCursor | null
|
||||
export type Hit = GrepMatch
|
||||
|
||||
export interface Grep {
|
||||
items: Hit[]
|
||||
items: GrepResult["items"]
|
||||
totalMatched: number
|
||||
totalFilesSearched: number
|
||||
totalFiles: number
|
||||
@@ -128,11 +109,30 @@ export interface Picker {
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return false
|
||||
return FileFinder?.isAvailable() ?? false
|
||||
}
|
||||
|
||||
export function create(_opts: Init): Result<Picker> {
|
||||
return { ok: false, error: "fff unavailable on node runtime" }
|
||||
export function create(opts: Init): Result<Picker> {
|
||||
if (!FileFinder) return { ok: false, error: "fff unavailable on node runtime" }
|
||||
const made = FileFinder.create(opts)
|
||||
if (!made.ok) return made
|
||||
const pick = made.value
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
destroy: () => pick.destroy(),
|
||||
isScanning: () => pick.isScanning(),
|
||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||
glob: (pattern, next) => pick.glob(pattern, next),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
grep: (query, next) => pick.grep(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as Fff from "./fff.node"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export default function load() {
|
||||
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||
return require(
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
|
||||
)
|
||||
}
|
||||
@@ -9,23 +9,14 @@ import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "ef
|
||||
import { lazy } from "../util/lazy"
|
||||
import { watch as watchFileSystem } from "node:fs"
|
||||
import path from "path"
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
import loadBinding from "./watcher-binding"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const Event = { Updated: FileSystem.Event.Changed }
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||
const binding = require(
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Agent } from "../agent"
|
||||
@@ -230,10 +230,8 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const ready = yield* Deferred.make<void>()
|
||||
let observed = 0
|
||||
let applied = -1
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
@@ -265,61 +263,40 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (applied >= target) return
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
applied = target
|
||||
}),
|
||||
)
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
})
|
||||
const sourceChanges = config.changes().pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
|
||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||
// Make accepted filesystem work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() => Effect.sync(() => ++observed)),
|
||||
Stream.debounce("100 millis"),
|
||||
)
|
||||
const busUpdates = bus
|
||||
.subscribe([Event.Updated, SdkPlugins.Updated])
|
||||
.pipe(Stream.mapEffect(() => Effect.sync(() => ++observed)))
|
||||
const updates = yield* Stream.merge(busUpdates, sourceChanges).pipe(
|
||||
Stream.toQueue({ capacity: 1, strategy: "sliding" }),
|
||||
)
|
||||
const signals = yield* Stream.concat(Stream.succeed(0), Stream.fromQueue(updates)).pipe(
|
||||
Stream.broadcast({ capacity: 1, strategy: "sliding", replay: 1 }),
|
||||
)
|
||||
const attempt = (target: number) =>
|
||||
activate(target).pipe(
|
||||
Effect.map(() => observed === target),
|
||||
Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }).pipe(Effect.as(false))),
|
||||
)
|
||||
|
||||
yield* signals.pipe(
|
||||
Stream.runForEach((target) =>
|
||||
activate(target).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
const updates = Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
|
||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() => Effect.sync(() => ++observed)),
|
||||
)
|
||||
yield* signals.pipe(
|
||||
yield* Stream.concat(Stream.succeed(0), updates).pipe(
|
||||
// Keep observing updates while activation runs, retaining only the latest generation request.
|
||||
Stream.buffer({ capacity: 1, strategy: "sliding" }),
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.mapEffect(attempt),
|
||||
Stream.filter((settled) => settled),
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.andThen(Deferred.succeed(ready, undefined)),
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate()
|
||||
if (observed === target) yield* Deferred.succeed(ready, undefined)
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: Deferred.await(ready) })
|
||||
|
||||
@@ -44,6 +44,7 @@ type Active = {
|
||||
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly name: () => Effect.Effect<string>
|
||||
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
|
||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||
readonly list: () => Effect.Effect<Shell.Info[]>
|
||||
@@ -134,6 +135,13 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
@@ -167,8 +175,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
|
||||
const id = Shell.ID.ascending()
|
||||
const cwd = input.cwd ?? location.directory
|
||||
const configShell = Config.latest(yield* config.entries(), "shell")
|
||||
const shell = ShellSelect.preferred(configShell, options)
|
||||
const shell = yield* resolve()
|
||||
const args = ShellSelect.args(shell, input.command)
|
||||
const file = path.join(outputDir, `${id}.out`)
|
||||
const env = {
|
||||
@@ -312,7 +319,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -15,22 +15,40 @@ import { Shell } from "../../shell"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||
const BACKGROUND_INSTRUCTION =
|
||||
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
const OS =
|
||||
process.platform === "darwin"
|
||||
? "macOS"
|
||||
: process.platform === "win32"
|
||||
? "Windows"
|
||||
: process.platform === "linux"
|
||||
? "Linux"
|
||||
: process.platform
|
||||
const description = (shell?: string) =>
|
||||
[
|
||||
"Execute a shell command and return its output.",
|
||||
...(shell ? [`Commands run on ${OS} using ${shell}.`] : []),
|
||||
"Quote file paths containing spaces or special characters.",
|
||||
"Prefer dedicated tools over shell commands when possible.",
|
||||
"When output is large, the full result is saved to a file and a truncated preview is returned.",
|
||||
"Rely on automatic truncation unless filtering the output is more useful.",
|
||||
"Commands accept an optional timeout, background commands have no timeout by default.",
|
||||
"Background commands return immediately, and you will be notified when they complete.",
|
||||
].join(" ")
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
workdir: Schema.optionalKey(Schema.String).annotate({
|
||||
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
|
||||
description:
|
||||
"Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead.",
|
||||
}),
|
||||
timeout: Schema.optionalKey(NonNegativeInt).annotate({
|
||||
description: `Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to ${DEFAULT_TIMEOUT_MS} for foreground commands. Background commands have no timeout by default.`,
|
||||
}),
|
||||
timeout: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)))
|
||||
.annotate({
|
||||
description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`,
|
||||
}),
|
||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
|
||||
@@ -69,13 +87,11 @@ const modelOutput = (output: Output): string | undefined => {
|
||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||
// TODO: Port BashArity reusable command-prefix approvals.
|
||||
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
|
||||
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
|
||||
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
|
||||
// TODO: Persist job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
||||
// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
|
||||
|
||||
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
||||
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
|
||||
@@ -144,7 +160,7 @@ export const Plugin = {
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
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. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||
description: description(),
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
@@ -189,8 +205,12 @@ export const Plugin = {
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
const truncated = page.size > page.cursor
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const truncated = latest.size > MAX_CAPTURE_BYTES
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
||||
limit: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return {
|
||||
output: `${page.output || "(no output)"}${notice}`,
|
||||
@@ -291,5 +311,13 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
tool.description = description(yield* shell.name())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -32,85 +32,109 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
{
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.query],
|
||||
save: ["*"],
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
})
|
||||
const result = yield* ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return Effect.gen(function* () {
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
if (providers.length === 0) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a provider so the agent can search the web",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
title: "Provider",
|
||||
description: "OpenCode will use your choice for future searches.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
...providers.map((provider) => ({ value: provider.id, label: provider.name })),
|
||||
{ value: "__disable__", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const answer = response.answer.provider
|
||||
if (answer === "__disable__") {
|
||||
yield* kv.set("websearch:provider", false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
if (typeof answer !== "string" || !providers.some((provider) => provider.id === answer))
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* kv.set("websearch:provider", answer)
|
||||
return yield* ctx.websearch.query(input)
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.query],
|
||||
save: ["*"],
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
})
|
||||
const result = yield* ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return Effect.gen(function* () {
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow web search via ${defaultProvider.name}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
const output = {
|
||||
provider: result.data.providerID,
|
||||
results: result.data.results,
|
||||
}
|
||||
const content = output.results.length
|
||||
? output.results
|
||||
.map((result) => {
|
||||
const title = result.title ?? result.url
|
||||
const published = result.time.published
|
||||
? `\nPublished: ${new Date(result.time.published).toISOString()}`
|
||||
: ""
|
||||
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
|
||||
})
|
||||
.join("\n\n")
|
||||
: NO_RESULTS
|
||||
return { output, content, metadata: { provider: output.provider } }
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
),
|
||||
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* kv.set("websearch:provider", false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* kv.set("websearch:provider", providerID)
|
||||
return yield* ctx.websearch.query(input)
|
||||
})
|
||||
}),
|
||||
)
|
||||
const output = {
|
||||
provider: result.data.providerID,
|
||||
results: result.data.results,
|
||||
}
|
||||
const content = output.results.length
|
||||
? output.results
|
||||
.map((result) => {
|
||||
const title = result.title ?? result.url
|
||||
const published = result.time.published
|
||||
? `\nPublished: ${new Date(result.time.published).toISOString()}`
|
||||
: ""
|
||||
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
|
||||
})
|
||||
.join("\n\n")
|
||||
: NO_RESULTS
|
||||
return { output, content, metadata: { provider: output.provider } }
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai"
|
||||
import { Model } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Generate } from "@opencode-ai/core/generate"
|
||||
@@ -9,7 +10,7 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { ID, Info, Ref } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const selected = Info.make({
|
||||
@@ -64,21 +65,7 @@ const aisdk = Layer.mock(AISDK.Service, {
|
||||
},
|
||||
model: () => Effect.succeed(runtime),
|
||||
})
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: () => Stream.die("unused"),
|
||||
generate: () =>
|
||||
Effect.sync(() => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "generate" }),
|
||||
LLMEvent.textDelta({ id: "generate", text: "OK" }),
|
||||
LLMEvent.textEnd({ id: "generate" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
if (!response) throw new Error("Incomplete generate response")
|
||||
return response
|
||||
}),
|
||||
})
|
||||
const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
|
||||
|
||||
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Model,
|
||||
SystemPart,
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
type LLMClientShape,
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/ai"
|
||||
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -78,77 +77,59 @@ import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
|
||||
const requests: LLMRequest[] = []
|
||||
let requests: LLMRequest[] = []
|
||||
const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}`
|
||||
let response: LLMEvent[] = []
|
||||
let responses: LLMEvent[][] | undefined
|
||||
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
||||
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
|
||||
let streamGate: Deferred.Deferred<void> | undefined
|
||||
let streamStarted: Deferred.Deferred<void> | undefined
|
||||
let streamFailure: LLMError | undefined
|
||||
let toolExecutionGate: Deferred.Deferred<void> | undefined
|
||||
let toolExecutionsStarted: Deferred.Deferred<void> | undefined
|
||||
let toolExecutionsReady = 5
|
||||
let activeToolExecutions = 0
|
||||
let maxActiveToolExecutions = 0
|
||||
const client = Layer.succeed(
|
||||
LLMClient.Service,
|
||||
LLMClient.Service.of({
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: ((request: LLMRequest) => {
|
||||
requests.push({
|
||||
...request,
|
||||
system: request.system.map((part) => ({
|
||||
...part,
|
||||
text: part.text.replace(emptyCodeMode, ""),
|
||||
})),
|
||||
tools: request.tools.filter((tool) => tool.name !== "execute"),
|
||||
})
|
||||
if (responseStreams) return responseStreams.shift() ?? Stream.empty
|
||||
if (responseStream) {
|
||||
const stream = responseStream
|
||||
responseStream = undefined
|
||||
return stream
|
||||
}
|
||||
const bus = streamFailure
|
||||
? Stream.fail(streamFailure)
|
||||
: Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
|
||||
if (!streamGate) return bus
|
||||
return Stream.unwrap(
|
||||
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
|
||||
Effect.andThen(Deferred.await(streamGate)),
|
||||
Effect.as(bus),
|
||||
),
|
||||
)
|
||||
}) as unknown as LLMClientShape["stream"],
|
||||
generate: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const reply = {
|
||||
stop: () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
],
|
||||
text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents,
|
||||
textWithUsage: (text: string, id: string, inputTokens: number) =>
|
||||
fragmentFixture("text", id, [text]).completeEvents.map((event) =>
|
||||
LLMEvent.is.stepFinish(event)
|
||||
? LLMEvent.stepFinish({
|
||||
index: event.index,
|
||||
reason: event.reason,
|
||||
usage: { inputTokens, nonCachedInputTokens: inputTokens },
|
||||
})
|
||||
: event,
|
||||
),
|
||||
tool: (id: string, name: string, input: unknown) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id, name, input }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
],
|
||||
type ToolBarrier = {
|
||||
readonly count: number
|
||||
readonly started: Deferred.Deferred<void>
|
||||
readonly release: Deferred.Deferred<void>
|
||||
active: number
|
||||
maxActive: number
|
||||
}
|
||||
let toolBarrier: ToolBarrier | undefined
|
||||
const releaseTools = (barrier: ToolBarrier) =>
|
||||
Effect.sync(() => {
|
||||
if (toolBarrier === barrier) toolBarrier = undefined
|
||||
}).pipe(Effect.andThen(Deferred.succeed(barrier.release, undefined)), Effect.asVoid)
|
||||
const blockTools = (count = 1) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.all({ started: Deferred.make<void>(), release: Deferred.make<void>() }).pipe(
|
||||
Effect.map((deferreds) => {
|
||||
const barrier = { count, ...deferreds, active: 0, maxActive: 0 }
|
||||
toolBarrier = barrier
|
||||
return barrier
|
||||
}),
|
||||
),
|
||||
releaseTools,
|
||||
).pipe(
|
||||
Effect.map((barrier) => ({
|
||||
started: Deferred.await(barrier.started),
|
||||
release: releaseTools(barrier),
|
||||
maxActive: Effect.sync(() => barrier.maxActive),
|
||||
})),
|
||||
)
|
||||
const awaitToolBarrier = Effect.suspend(() => {
|
||||
const barrier = toolBarrier
|
||||
if (!barrier) return Effect.void
|
||||
barrier.active++
|
||||
barrier.maxActive = Math.max(barrier.maxActive, barrier.active)
|
||||
return (barrier.active === barrier.count ? Deferred.succeed(barrier.started, undefined) : Effect.void).pipe(
|
||||
Effect.andThen(Deferred.await(barrier.release)),
|
||||
Effect.ensuring(Effect.sync(() => barrier.active--)),
|
||||
)
|
||||
})
|
||||
const testLLM = TestLLM.layer({
|
||||
fallback: [],
|
||||
transformRequest: (request) =>
|
||||
LLMRequest.update(request, {
|
||||
system: request.system.map((part) => ({
|
||||
...part,
|
||||
text: part.text.replace(emptyCodeMode, ""),
|
||||
})),
|
||||
tools: request.tools.filter((tool) => tool.name !== "execute"),
|
||||
}),
|
||||
})
|
||||
const client = TestLLM.clientLayer
|
||||
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
||||
const defaultSystem = PROMPT_DEFAULT
|
||||
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
|
||||
@@ -221,7 +202,7 @@ test("does not apply an ineligible tier without base pricing", () => {
|
||||
|
||||
const authorizations: Tool.Context[] = []
|
||||
const executions: string[] = []
|
||||
const permissionFail = ({
|
||||
const permissionFail = {
|
||||
name: "permission_fail",
|
||||
description: "Reject a permission",
|
||||
input: Schema.Struct({}),
|
||||
@@ -235,7 +216,7 @@ const permissionFail = ({
|
||||
resources: ["src/index.ts"],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
@@ -247,11 +228,7 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const transformTools = (
|
||||
registry: Tool.Interface,
|
||||
tools: Readonly<Record<string, Info>>,
|
||||
options?: Tool.Options,
|
||||
) =>
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
|
||||
registry.transform((draft) =>
|
||||
Object.entries(tools).forEach(([name, tool]) =>
|
||||
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
|
||||
@@ -259,9 +236,10 @@ const transformTools = (
|
||||
)
|
||||
const echo = Layer.effectDiscard(
|
||||
Tool.Service.use((registry) =>
|
||||
transformTools(registry,
|
||||
transformTools(
|
||||
registry,
|
||||
{
|
||||
echo: ({
|
||||
echo: {
|
||||
name: "echo",
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
@@ -270,32 +248,24 @@ const echo = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
authorizations.push(context)
|
||||
executions.push(text)
|
||||
activeToolExecutions++
|
||||
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
||||
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
|
||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||
}
|
||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||
yield* awaitToolBarrier
|
||||
return { output: { text }, content: text }
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||
}),
|
||||
defect: ({
|
||||
}),
|
||||
},
|
||||
defect: {
|
||||
name: "defect",
|
||||
description: "Fail unexpectedly",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () =>
|
||||
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
|
||||
Effect.andThen(Effect.die("unexpected tool defect")),
|
||||
),
|
||||
}),
|
||||
storefail: ({
|
||||
execute: () => awaitToolBarrier.pipe(Effect.andThen(Effect.die("unexpected tool defect"))),
|
||||
},
|
||||
storefail: {
|
||||
name: "storefail",
|
||||
description: "Produce output that cannot be persisted",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ output: {} }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ codemode: false },
|
||||
),
|
||||
@@ -469,11 +439,16 @@ const it = testEffect(
|
||||
[Config.node, config],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
],
|
||||
),
|
||||
).pipe(Layer.provideMerge(testLLM)),
|
||||
)
|
||||
const sessionID = Session.ID.make("ses_runner_test")
|
||||
const otherSessionID = Session.ID.make("ses_runner_other")
|
||||
const admit = (session: Session.Interface, text: string) => session.prompt({ sessionID, text, resume: false })
|
||||
const runPrompt = Effect.fnUntraced(function* (session: Session.Interface, text: string) {
|
||||
const message = yield* admit(session, text)
|
||||
yield* session.resume(sessionID)
|
||||
return message
|
||||
})
|
||||
|
||||
const insertSession = (id: Session.ID) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -506,10 +481,9 @@ const setup = Effect.gen(function* () {
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
|
||||
discard: true,
|
||||
})
|
||||
requests.length = 0
|
||||
requests = (yield* TestLLM.Service).requests
|
||||
authorizations.length = 0
|
||||
executions.length = 0
|
||||
response = []
|
||||
systemBaseline = "Initial context"
|
||||
systemRemoved = false
|
||||
systemUnavailable = false
|
||||
@@ -518,17 +492,7 @@ const setup = Effect.gen(function* () {
|
||||
pluginFlushHook = Effect.void
|
||||
currentModel = model
|
||||
skillBaselines.clear()
|
||||
responses = undefined
|
||||
streamFailure = undefined
|
||||
responseStream = undefined
|
||||
responseStreams = undefined
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
toolExecutionsReady = 5
|
||||
activeToolExecutions = 0
|
||||
maxActiveToolExecutions = 0
|
||||
toolBarrier = undefined
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
@@ -567,9 +531,8 @@ const rateLimited = (retryAfterMs?: number) =>
|
||||
|
||||
const setupOverflowRecovery = Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = reply.text("Earlier answer", "text-earlier")
|
||||
yield* admit(session, "Earlier question ".repeat(700))
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-earlier"))
|
||||
yield* runPrompt(session, "Earlier question ".repeat(700))
|
||||
currentModel = recoveryModel
|
||||
requests.length = 0
|
||||
return session
|
||||
@@ -581,6 +544,7 @@ const messageTexts = (request: LLMRequest, role: "user" | "system") =>
|
||||
)
|
||||
const userTexts = (request: LLMRequest) => messageTexts(request, "user")
|
||||
const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
|
||||
const messageRoles = (request: LLMRequest | undefined) => request?.messages.map((message) => message.role)
|
||||
|
||||
const recordedEventTypes = (id: Session.ID) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -619,6 +583,9 @@ const recordedStepSettlementEvents = (id: Session.ID, assistantMessageID: Sessio
|
||||
)
|
||||
})
|
||||
|
||||
const recordedStepSettlementTypes = (id: Session.ID, assistantMessageID: SessionMessage.ID) =>
|
||||
recordedStepSettlementEvents(id, assistantMessageID).pipe(Effect.map((events) => events.map((event) => event.type)))
|
||||
|
||||
const hostedCall = (id: string, query: string) =>
|
||||
LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true })
|
||||
|
||||
@@ -742,7 +709,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
|
||||
const bus = yield* Bus.Service
|
||||
const live = yield* bus.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
response = fixture.completeEvents
|
||||
yield* TestLLM.push(fixture.completeEvents)
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -769,7 +736,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
||||
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
|
||||
const failure = providerUnavailable()
|
||||
yield* admit(session, prompt)
|
||||
responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
|
||||
yield* TestLLM.push(TestLLM.failAfter(failure, ...fixture.partialEvents))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -802,9 +769,11 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"])
|
||||
const streamed = yield* Deferred.make<void>()
|
||||
yield* admit(session, prompt)
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable(fixture.partialEvents),
|
||||
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
||||
yield* TestLLM.push(
|
||||
Stream.concat(
|
||||
Stream.fromIterable(fixture.partialEvents),
|
||||
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
||||
),
|
||||
)
|
||||
|
||||
const runner = yield* SessionRunner.Service
|
||||
@@ -840,7 +809,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
yield* admit(session, "Original message")
|
||||
responses = [reply.tool("call-removed", "echo", { text: "blocked" })]
|
||||
yield* TestLLM.push(TestLLM.tool("call-removed", "echo", { text: "blocked" }))
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -872,9 +841,10 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const registry = yield* Tool.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* transformTools(registry,
|
||||
yield* transformTools(
|
||||
registry,
|
||||
{
|
||||
location_context: ({
|
||||
location_context: {
|
||||
name: "location_context",
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
@@ -885,12 +855,12 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* context.progress({ phase: "reading" })
|
||||
return { output: { answer: query.toUpperCase() } }
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Use application context")
|
||||
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
|
||||
yield* TestLLM.push(TestLLM.tool("call-location", "location_context", { query: "hello" }), [])
|
||||
const bus = yield* Bus.Service
|
||||
const progressFiber = yield* bus.subscribe(SessionEvent.Tool.Progress).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-location"),
|
||||
@@ -934,50 +904,42 @@ describe("SessionRunnerLLM", () => {
|
||||
const registry = yield* Tool.Service
|
||||
const scope = yield* Scope.make()
|
||||
const executions: string[] = []
|
||||
yield* transformTools(registry,
|
||||
{
|
||||
reloaded: ({
|
||||
name: "reloaded",
|
||||
description: "Record the advertised tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () =>
|
||||
Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ output: { value: "advertised" } })),
|
||||
}),
|
||||
yield* transformTools(
|
||||
registry,
|
||||
{
|
||||
reloaded: {
|
||||
name: "reloaded",
|
||||
description: "Record the advertised tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () =>
|
||||
Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ output: { value: "advertised" } })),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
},
|
||||
{ codemode: false },
|
||||
).pipe(Scope.provide(scope))
|
||||
yield* admit(session, "Use the reloaded tool")
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-reloaded", name: "reloaded", input: {} }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(TestLLM.tool("call-reloaded", "reloaded", {}), [])
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* transformTools(registry,
|
||||
yield* transformTools(
|
||||
registry,
|
||||
{
|
||||
reloaded: ({
|
||||
reloaded: {
|
||||
name: "reloaded",
|
||||
description: "Record the replacement tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () =>
|
||||
Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ output: { value: "replacement" } })),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(executions).toEqual(["advertised"])
|
||||
@@ -1019,16 +981,16 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const releaseSecond = yield* Deferred.make<void>()
|
||||
responseStreams = [
|
||||
Stream.fromIterable(reply.tool("call-echo", "echo", { text: "background started" })),
|
||||
yield* TestLLM.push(
|
||||
Stream.fromIterable(TestLLM.tool("call-echo", "echo", { text: "background started" })),
|
||||
Stream.unwrap(
|
||||
Deferred.succeed(secondStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseSecond)),
|
||||
Effect.as(Stream.fromIterable(reply.stop())),
|
||||
Effect.as(Stream.fromIterable(TestLLM.stop())),
|
||||
),
|
||||
),
|
||||
Stream.fromIterable(reply.text("Handled completion", "text-completion")),
|
||||
]
|
||||
Stream.fromIterable(TestLLM.text("Handled completion", "text-completion")),
|
||||
)
|
||||
yield* admit(session, "Start background work")
|
||||
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(secondStarted)
|
||||
@@ -1038,7 +1000,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* Fiber.join(running)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[2]!)).toContain("Background work completed")
|
||||
expect(userTexts(requests[2])).toContain("Background work completed")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1046,9 +1008,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "First")
|
||||
yield* admit(session, "Second")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
@@ -1071,12 +1031,9 @@ describe("SessionRunnerLLM", () => {
|
||||
if (event.type === "session.instructions.updated") instructionEvents.push(event)
|
||||
}),
|
||||
)
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
systemBaseline = "Changed context"
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
yield* unsubscribe
|
||||
|
||||
expect(instructionEvents).toHaveLength(2)
|
||||
@@ -1113,7 +1070,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.wait(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
|
||||
expect(messageRoles(requests[0])).toEqual(["user"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1122,8 +1079,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* admit(session, "First")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
|
||||
yield* bus.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
@@ -1145,14 +1101,11 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("forks instruction values at the selected message instead of the parent's latest state", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const first = yield* admit(session, "First")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
systemBaseline = "Changed context"
|
||||
const second = yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
const second = yield* runPrompt(session, "Second")
|
||||
systemBaseline = "Latest context"
|
||||
yield* admit(session, "Third")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Third")
|
||||
|
||||
const forked = yield* session.fork({ sessionID, messageID: second.id })
|
||||
expect(
|
||||
@@ -1201,11 +1154,9 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("caps nested fork instruction ancestry at the selected message", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "First")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
systemBaseline = "Changed context"
|
||||
const second = yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
const second = yield* runPrompt(session, "Second")
|
||||
|
||||
const child = yield* session.fork({ sessionID, messageID: second.id })
|
||||
const inheritedFirst = (yield* session.messages({ sessionID: child.id })).find(
|
||||
@@ -1224,6 +1175,7 @@ describe("SessionRunnerLLM", () => {
|
||||
initial_values: { "test/context": Instructions.hash("Initial context") },
|
||||
current_values: { "test/context": Instructions.hash("Initial context") },
|
||||
})
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1231,8 +1183,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
yield* admit(session, "First")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).run()
|
||||
yield* admit(session, "Second")
|
||||
requests.length = 0
|
||||
@@ -1241,7 +1192,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "user"])
|
||||
expect(messageRoles(requests[0])).toEqual(["user", "user"])
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
@@ -1259,24 +1210,21 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("keeps the initial instructions stable and derives a chronological update from values", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
systemBaseline = "Changed context"
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
|
||||
expect(
|
||||
PromptCacheDiagnostics.compare(
|
||||
PromptCacheDiagnostics.snapshot(requests[0]!),
|
||||
PromptCacheDiagnostics.snapshot(requests[1]!),
|
||||
PromptCacheDiagnostics.snapshot(requests[0]),
|
||||
PromptCacheDiagnostics.snapshot(requests[1]),
|
||||
),
|
||||
).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 3 })
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
||||
const { db } = yield* Database.Service
|
||||
@@ -1307,7 +1255,7 @@ describe("SessionRunnerLLM", () => {
|
||||
currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
|
||||
yield* admit(session, "First")
|
||||
|
||||
response = reply.text("Done", "text-provider-prompt")
|
||||
yield* TestLLM.push(TestLLM.text("Done", "text-provider-prompt"))
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
@@ -1330,7 +1278,7 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
yield* admit(session, "First")
|
||||
|
||||
response = reply.text("Done", "text-empty-agent-system")
|
||||
yield* TestLLM.push(TestLLM.text("Done", "text-empty-agent-system"))
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
@@ -1352,7 +1300,7 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
yield* admit(session, "First")
|
||||
|
||||
response = reply.text("Done", "text-build")
|
||||
yield* TestLLM.push(TestLLM.text("Done", "text-build"))
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
|
||||
@@ -1376,7 +1324,7 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
yield* admit(session, "First")
|
||||
|
||||
response = reply.text("Done", "text-reviewer")
|
||||
yield* TestLLM.push(TestLLM.text("Done", "text-reviewer"))
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
|
||||
@@ -1396,7 +1344,7 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
yield* admit(session, "First")
|
||||
|
||||
response = reply.text("Done", "text-no-system")
|
||||
yield* TestLLM.push(TestLLM.text("Done", "text-no-system"))
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
|
||||
@@ -1422,7 +1370,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.pipe(Effect.orDie)
|
||||
yield* admit(session, "First")
|
||||
|
||||
response = reply.text("Done", "text-selected")
|
||||
yield* TestLLM.push(TestLLM.text("Done", "text-selected"))
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
|
||||
@@ -1444,7 +1392,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.prompt({ sessionID, text: "Inspect files", resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* TestLLM.push([])
|
||||
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
@@ -1465,7 +1413,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.prompt({ sessionID, text: "Wait for plugins", resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* TestLLM.push([])
|
||||
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.yieldNow
|
||||
|
||||
@@ -1489,22 +1437,19 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
skillBaselines.set(Agent.ID.make("build"), "Build skills")
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
skillBaselines.set(Agent.ID.make("reviewer"), "Reviewer skills")
|
||||
yield* bus.publish(SessionEvent.AgentSelected, {
|
||||
sessionID,
|
||||
agent: Agent.ID.make("reviewer"),
|
||||
})
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context\n\nBuild skills"],
|
||||
[defaultSystem, "Initial context\n\nBuild skills"],
|
||||
])
|
||||
expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills"))
|
||||
expect(systemTexts(requests[1])).toContainEqual(expect.stringContaining("Reviewer skills"))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1525,9 +1470,7 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context\n\nBuild skills"],
|
||||
@@ -1550,9 +1493,7 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
expect(requests.map((request) => request.model)).toEqual([model])
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context"],
|
||||
@@ -1563,14 +1504,11 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("admits removed context as a chronological System message", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
systemRemoved = true
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
||||
{ type: "text", text: "System context source removed: test/context" },
|
||||
])
|
||||
@@ -1583,9 +1521,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const contextEntries = yield* InstructionEntry.Service
|
||||
yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" })
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
|
||||
// String values render verbatim inside the initial tagged block.
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([
|
||||
@@ -1595,10 +1531,9 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
// Non-string JSON pretty-prints; the change narrates as a System update.
|
||||
yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } })
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
@@ -1616,10 +1551,9 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
// Deleting the row announces removal through the stored removal text.
|
||||
yield* contextEntries.remove({ sessionID, key: "deploy-target" })
|
||||
yield* admit(session, "Third")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Third")
|
||||
|
||||
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"])
|
||||
expect(messageRoles(requests[2])).toEqual(["user", "system", "user", "system", "user"])
|
||||
expect(requests[2]?.messages.at(-2)?.content).toEqual([
|
||||
{ type: "text", text: 'The context under "deploy-target" no longer applies. Disregard it.' },
|
||||
])
|
||||
@@ -1632,12 +1566,10 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const entries = yield* InstructionEntry.Service
|
||||
yield* entries.put({ sessionID, key: "nullable", value: "present" })
|
||||
yield* admit(session, "First")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
|
||||
yield* entries.put({ sessionID, key: "nullable", value: null })
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
||||
{
|
||||
@@ -1673,26 +1605,22 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
systemBaseline = "Changed context"
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
yield* admit(session, "Third")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Third")
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
||||
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
@@ -1702,8 +1630,7 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(4)
|
||||
yield* admit(session, "Fourth")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Fourth")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1711,20 +1638,16 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
|
||||
})
|
||||
systemUnavailable = true
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
systemUnavailable = false
|
||||
systemBaseline = "Replacement context"
|
||||
yield* admit(session, "Third")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Third")
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context"],
|
||||
@@ -1738,9 +1661,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
@@ -1753,18 +1674,16 @@ describe("SessionRunnerLLM", () => {
|
||||
recent: "",
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Replacement context" }])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
yield* admit(session, "Third")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Third")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1772,17 +1691,16 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
currentModel = recoveryModel
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
responses = [
|
||||
reply.tool("call-active", "echo", { text: "active" }),
|
||||
const stream = yield* TestLLM.gate
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
reply.text("Steer complete", "text-steer"),
|
||||
reply.text("Queue complete", "text-queue"),
|
||||
]
|
||||
TestLLM.text("Steer complete", "text-steer"),
|
||||
TestLLM.text("Queue complete", "text-queue"),
|
||||
)
|
||||
yield* admit(session, "Active work")
|
||||
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
|
||||
const first = yield* session.compact({ sessionID })
|
||||
const second = yield* session.compact({ sessionID })
|
||||
@@ -1802,7 +1720,7 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
expect(yield* SessionPending.has((yield* Database.Service).db, sessionID, "steer")).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(active)
|
||||
|
||||
expect(requests).toHaveLength(4)
|
||||
@@ -1823,16 +1741,15 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
currentModel = recoveryModel
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
responses = [
|
||||
reply.text("Active complete", "text-active-failure"),
|
||||
const stream = yield* TestLLM.gate
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("Active complete", "text-active-failure"),
|
||||
[],
|
||||
reply.text("Continued", "text-after-failure"),
|
||||
]
|
||||
TestLLM.text("Continued", "text-after-failure"),
|
||||
)
|
||||
yield* admit(session, "Active work")
|
||||
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.prompt({
|
||||
@@ -1841,7 +1758,7 @@ describe("SessionRunnerLLM", () => {
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(active)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
@@ -1886,12 +1803,11 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("manually compacts when the model has no context limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = reply.text("Earlier answer", "text-manual-unknown-history")
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-unknown-history"))
|
||||
yield* runPrompt(session, "Earlier question")
|
||||
|
||||
requests.length = 0
|
||||
response = reply.text("Manual summary", "text-manual-unknown-summary")
|
||||
yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -1908,11 +1824,10 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("preserves provider errors from manual compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = reply.text("Earlier answer", "text-manual-provider-history")
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-provider-history"))
|
||||
yield* runPrompt(session, "Earlier question")
|
||||
|
||||
response = [LLMEvent.providerError({ message: "summary unavailable" })]
|
||||
yield* TestLLM.push([LLMEvent.providerError({ message: "summary unavailable" })])
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -1927,11 +1842,10 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("preserves typed provider failures from manual compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = reply.text("Earlier answer", "text-manual-failure-history")
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-failure-history"))
|
||||
yield* runPrompt(session, "Earlier question")
|
||||
|
||||
responseStream = Stream.fail(providerUnavailable())
|
||||
yield* TestLLM.push(Stream.fail(providerUnavailable()))
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -1946,15 +1860,16 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("records cancelled manual compaction without surfacing an internal failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = reply.text("Earlier answer", "text-manual-interrupt-history")
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-interrupt-history"))
|
||||
yield* runPrompt(session, "Earlier question")
|
||||
|
||||
const streamed = yield* Deferred.make<void>()
|
||||
const partial = fragmentFixture("text", "text-manual-interrupt-summary", ["Partial summary"])
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable(partial.partialEvents),
|
||||
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
||||
yield* TestLLM.push(
|
||||
Stream.concat(
|
||||
Stream.fromIterable(partial.partialEvents),
|
||||
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
||||
),
|
||||
)
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
@@ -1975,9 +1890,8 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = reply.text("Earlier answer", "text-manual-resolution-history")
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-resolution-history"))
|
||||
yield* runPrompt(session, "Earlier question")
|
||||
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
modelResolveHook = Effect.die("model resolution failed")
|
||||
@@ -2001,18 +1915,16 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("automatically compacts into a completed summary and retained recent turn", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = reply.textWithUsage("Earlier answer", "text-first", 3_950)
|
||||
yield* admit(session, "Earlier question ".repeat(180))
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-first", 3_950))
|
||||
yield* runPrompt(session, "Earlier question ".repeat(180))
|
||||
|
||||
currentModel = compactModel
|
||||
requests.length = 0
|
||||
responses = [
|
||||
reply.text("## Objective\n- Preserve the task", "text-summary"),
|
||||
reply.textWithUsage("Continued", "text-final", 3_950),
|
||||
]
|
||||
yield* admit(session, "Recent exact request ".repeat(180))
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("## Objective\n- Preserve the task", "text-summary"),
|
||||
TestLLM.textWithUsage("Continued", "text-final", 3_950),
|
||||
)
|
||||
yield* runPrompt(session, "Recent exact request ".repeat(180))
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[0])[0]).toContain("## Objective")
|
||||
@@ -2029,12 +1941,11 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
responses = [
|
||||
reply.text("## Objective\n- Preserve the updated task", "text-summary-2"),
|
||||
reply.text("Continued again", "text-final-2"),
|
||||
]
|
||||
yield* admit(session, "Newest exact request ".repeat(180))
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("## Objective\n- Preserve the updated task", "text-summary-2"),
|
||||
TestLLM.text("Continued again", "text-final-2"),
|
||||
)
|
||||
yield* runPrompt(session, "Newest exact request ".repeat(180))
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[0])[0]).toContain(
|
||||
@@ -2052,14 +1963,12 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
currentModel = fullOutputModel
|
||||
response = reply.textWithUsage("Earlier answer", "text-full-output-first", 9_500)
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-full-output-first", 9_500))
|
||||
yield* runPrompt(session, "Earlier question")
|
||||
|
||||
requests.length = 0
|
||||
response = reply.text("Continued", "text-full-output-final")
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.text("Continued", "text-full-output-final"))
|
||||
yield* runPrompt(session, "Continue")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0])).toContain("Continue")
|
||||
@@ -2070,16 +1979,15 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("stops after required automatic compaction fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = reply.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950)
|
||||
yield* admit(session, "Earlier question ".repeat(180))
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950))
|
||||
yield* runPrompt(session, "Earlier question ".repeat(180))
|
||||
|
||||
currentModel = compactModel
|
||||
requests.length = 0
|
||||
responses = [
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
|
||||
reply.text("Must not run", "text-after-failed-compaction"),
|
||||
]
|
||||
TestLLM.text("Must not run", "text-after-failed-compaction"),
|
||||
)
|
||||
yield* admit(session, "Recent exact request ".repeat(180))
|
||||
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
|
||||
|
||||
@@ -2099,16 +2007,15 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("forces one compaction and retries after provider context overflow", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
yield* TestLLM.push(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
],
|
||||
reply.text("## Objective\n- Recover overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
]
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
TestLLM.text("## Objective\n- Recover overflow", "text-summary"),
|
||||
TestLLM.text("Recovered", "text-final"),
|
||||
)
|
||||
yield* runPrompt(session, "Continue")
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[1])[0]).toContain("## Objective")
|
||||
@@ -2129,13 +2036,12 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = model
|
||||
responses = [
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
||||
reply.text("Recovered", "text-final-unknown-limit"),
|
||||
]
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
TestLLM.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
||||
TestLLM.text("Recovered", "text-final-unknown-limit"),
|
||||
)
|
||||
yield* runPrompt(session, "Continue")
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -2149,13 +2055,12 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = undersizedContextModel
|
||||
responses = [
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||
reply.text("Recovered", "text-final-undersized-limit"),
|
||||
]
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
TestLLM.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||
TestLLM.text("Recovered", "text-final-undersized-limit"),
|
||||
)
|
||||
yield* runPrompt(session, "Continue")
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -2172,7 +2077,7 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
|
||||
yield* TestLLM.push(overflow(), TestLLM.text("## Objective\n- Recover once", "text-summary"), overflow())
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
@@ -2187,22 +2092,23 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("recovers once from a raw context overflow failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responseStream = Stream.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({
|
||||
message: "prompt too long",
|
||||
classification: "context-overflow",
|
||||
yield* TestLLM.push(
|
||||
Stream.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({
|
||||
message: "prompt too long",
|
||||
classification: "context-overflow",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
responses = [
|
||||
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
]
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("## Objective\n- Recover raw overflow", "text-summary"),
|
||||
TestLLM.text("Recovered", "text-final"),
|
||||
)
|
||||
yield* runPrompt(session, "Continue")
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -2215,10 +2121,10 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("publishes the original overflow when recovery summarization fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "summary unavailable" })],
|
||||
]
|
||||
)
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
@@ -2243,26 +2149,29 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("interrupts overflow recovery while the summary provider is running", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
reply.text("## Objective\n- Interrupted", "text-summary"),
|
||||
]
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const summaryGate = yield* Deferred.make<void>()
|
||||
streamGate = firstGate
|
||||
TestLLM.text("## Objective\n- Interrupted", "text-summary"),
|
||||
)
|
||||
const first = yield* TestLLM.gate
|
||||
yield* admit(session, "Continue")
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
streamGate = summaryGate
|
||||
yield* Deferred.succeed(firstGate, undefined)
|
||||
while (requests.length < 2) yield* Effect.yieldNow
|
||||
yield* first.started
|
||||
|
||||
const summary = yield* TestLLM.gate
|
||||
yield* first.release
|
||||
yield* summary.started
|
||||
|
||||
yield* session.interrupt(sessionID)
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
streamGate = undefined
|
||||
expect(requests).toHaveLength(2)
|
||||
const exit = yield* Fiber.await(run)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
expect(yield* session.context(sessionID)).toContainEqual(
|
||||
expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }),
|
||||
expect.objectContaining({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -2271,12 +2180,9 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
yield* admit(session, "First")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "First")
|
||||
systemBaseline = "Changed context"
|
||||
yield* admit(session, "Second")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Second")
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
@@ -2289,8 +2195,7 @@ describe("SessionRunnerLLM", () => {
|
||||
recent: "",
|
||||
})
|
||||
systemUnavailable = true
|
||||
yield* admit(session, "Third")
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Third")
|
||||
|
||||
// Compaction already moved current values into the new epoch before the unavailable read.
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
|
||||
@@ -2303,50 +2208,49 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Use tools")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.reasoningStart({ id: "reasoning-1" }),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "Think" }),
|
||||
LLMEvent.reasoningEnd({ id: "reasoning-1" }),
|
||||
LLMEvent.toolInputStart({ id: "call-error", name: "write" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-error", name: "write", text: '{"path":"README.md"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call-error", name: "write" }),
|
||||
LLMEvent.toolCall({ id: "call-error", name: "write", input: { path: "README.md" }, providerExecuted: true }),
|
||||
LLMEvent.toolError({ id: "call-error", name: "write", message: "Denied" }),
|
||||
LLMEvent.toolResult({ id: "call-error", name: "write", result: { type: "error", value: "Denied" } }),
|
||||
LLMEvent.toolCall({
|
||||
id: "call-provider",
|
||||
name: "web_search",
|
||||
input: { query: "hello" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { source: "provider" } },
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "call-provider",
|
||||
name: "web_search",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
|
||||
],
|
||||
yield* TestLLM.push(
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
nonCachedInputTokens: 8,
|
||||
outputTokens: 4,
|
||||
reasoningTokens: 1,
|
||||
cacheReadInputTokens: 2,
|
||||
},
|
||||
},
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { source: "provider" } },
|
||||
}),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
nonCachedInputTokens: 8,
|
||||
outputTokens: 4,
|
||||
reasoningTokens: 1,
|
||||
cacheReadInputTokens: 2,
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
]
|
||||
LLMEvent.reasoningStart({ id: "reasoning-1" }),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "Think" }),
|
||||
LLMEvent.reasoningEnd({ id: "reasoning-1" }),
|
||||
LLMEvent.toolInputStart({ id: "call-error", name: "write" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-error", name: "write", text: '{"path":"README.md"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call-error", name: "write" }),
|
||||
LLMEvent.toolCall({ id: "call-error", name: "write", input: { path: "README.md" }, providerExecuted: true }),
|
||||
LLMEvent.toolError({ id: "call-error", name: "write", message: "Denied" }),
|
||||
LLMEvent.toolResult({ id: "call-error", name: "write", result: { type: "error", value: "Denied" } }),
|
||||
LLMEvent.toolCall({
|
||||
id: "call-provider",
|
||||
name: "web_search",
|
||||
input: { query: "hello" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { source: "provider" } },
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "call-provider",
|
||||
name: "web_search",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
|
||||
],
|
||||
},
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { source: "provider" } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -2398,12 +2302,12 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Echo this")
|
||||
|
||||
responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.text("Done", "text-final")]
|
||||
yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.text("Done", "text-final"))
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
|
||||
expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }])
|
||||
expect(executions).toEqual(["hello"])
|
||||
const context = yield* session.context(sessionID)
|
||||
@@ -2428,7 +2332,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
|
||||
])
|
||||
const assistant = requireAssistant(context)
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.success.2",
|
||||
@@ -2443,18 +2347,16 @@ describe("SessionRunnerLLM", () => {
|
||||
const bus = yield* Bus.Service
|
||||
yield* admit(session, "Echo this")
|
||||
|
||||
responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop()]
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.stop())
|
||||
const tools = yield* blockTools()
|
||||
const run = yield* Effect.forkChild(session.resume(sessionID))
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* tools.started
|
||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
|
||||
@@ -2462,7 +2364,7 @@ describe("SessionRunnerLLM", () => {
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(systemTexts(requests[1]!)).toContain("Replacement context")
|
||||
expect(systemTexts(requests[1])).toContain("Replacement context")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2471,32 +2373,31 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Think first")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.reasoningStart({ id: "reasoning-anthropic" }),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-anthropic",
|
||||
providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.reasoningStart({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: {
|
||||
openai: { itemId: "rs_1", reasoningEncryptedContent: null },
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: {
|
||||
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
yield* TestLLM.push(
|
||||
TestLLM.stop(
|
||||
LLMEvent.reasoningStart({ id: "reasoning-anthropic" }),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-anthropic",
|
||||
providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.reasoningStart({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: {
|
||||
openai: { itemId: "rs_1", reasoningEncryptedContent: null },
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: {
|
||||
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
|
||||
@@ -2520,7 +2421,7 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
|
||||
yield* admit(session, "Continue")
|
||||
response = []
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages[1]?.content).toEqual([
|
||||
@@ -2543,17 +2444,16 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Check first")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
|
||||
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
|
||||
LLMEvent.textEnd({
|
||||
id: "commentary",
|
||||
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
yield* TestLLM.push(
|
||||
TestLLM.stop(
|
||||
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
|
||||
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
|
||||
LLMEvent.textEnd({
|
||||
id: "commentary",
|
||||
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
|
||||
@@ -2566,7 +2466,7 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
|
||||
yield* admit(session, "Continue")
|
||||
response = []
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages[1]?.content).toEqual([
|
||||
@@ -2584,33 +2484,32 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Search first")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({
|
||||
id: "hosted-search",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "hosted-search",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ title: "Effect" }] },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
yield* TestLLM.push(
|
||||
TestLLM.stop(
|
||||
LLMEvent.toolCall({
|
||||
id: "hosted-search",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "hosted-search",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ title: "Effect" }] },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
|
||||
yield* admit(session, "Continue")
|
||||
response = []
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"])
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "user"])
|
||||
expect(requests[1]?.messages[1]?.content).toMatchObject([
|
||||
{
|
||||
type: "tool-call",
|
||||
@@ -2638,8 +2537,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Echo five times")
|
||||
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
const tools = yield* blockTools(5)
|
||||
const providerGate = yield* Deferred.make<void>()
|
||||
const initial = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
@@ -2651,16 +2549,15 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
])
|
||||
responseStream = Stream.concat(
|
||||
initial,
|
||||
Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)),
|
||||
yield* TestLLM.push(
|
||||
Stream.concat(initial, Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final))),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* tools.started
|
||||
|
||||
expect(executions).toHaveLength(5)
|
||||
expect(maxActiveToolExecutions).toBe(5)
|
||||
expect(yield* tools.maxActive).toBe(5)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Echo five times" },
|
||||
{
|
||||
@@ -2677,13 +2574,11 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* Effect.yieldNow
|
||||
expect(requests).toHaveLength(1)
|
||||
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
|
||||
expect(executions).toHaveLength(5)
|
||||
expect(maxActiveToolExecutions).toBe(5)
|
||||
expect(yield* tools.maxActive).toBe(5)
|
||||
expect(requests).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
@@ -2693,17 +2588,15 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Echo twice")
|
||||
|
||||
responses = [
|
||||
reply.tool("tool_0", "echo", { text: "first" }),
|
||||
reply.tool("tool_0", "echo", { text: "second" }),
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("tool_0", "echo", { text: "first" }),
|
||||
TestLLM.tool("tool_0", "echo", { text: "second" }),
|
||||
[],
|
||||
]
|
||||
)
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(executions).toEqual(["first", "second"])
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
const expected = [
|
||||
{ type: "user", text: "Echo twice" },
|
||||
{
|
||||
type: "assistant",
|
||||
@@ -2725,33 +2618,14 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
]
|
||||
expect(executions).toEqual(["first", "second"])
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(yield* session.context(sessionID)).toMatchObject(expected)
|
||||
|
||||
yield* replaySessionProjection(sessionID)
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Echo twice" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_0",
|
||||
state: { status: "completed", content: [{ type: "text", text: "first" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_0",
|
||||
state: { status: "completed", content: [{ type: "text", text: "second" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(yield* session.context(sessionID)).toMatchObject(expected)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2760,21 +2634,18 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Run once")
|
||||
|
||||
response = reply.text("Once", "text-once")
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(TestLLM.text("Once", "text-once"))
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -2789,22 +2660,19 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Start working")
|
||||
|
||||
responses = [reply.stop(), reply.stop()]
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* session.prompt({ sessionID, text: "Change direction" })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(first)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working", "Change direction"])
|
||||
expect(userTexts(requests[0])).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1])).toEqual(["Start working", "Change direction"])
|
||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
@@ -2819,26 +2687,23 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Start working")
|
||||
|
||||
responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop(), reply.stop()]
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.stop(), TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Wait until continuation ends",
|
||||
delivery: "queue",
|
||||
})
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(first)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working"])
|
||||
expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"])
|
||||
expect(userTexts(requests[0])).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1])).toEqual(["Start working"])
|
||||
expect(userTexts(requests[2])).toEqual(["Start working", "Wait until continuation ends"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2848,12 +2713,11 @@ describe("SessionRunnerLLM", () => {
|
||||
const { db } = yield* Database.Service
|
||||
yield* admit(session, "Interrupt current work")
|
||||
|
||||
responses = [[], reply.stop()]
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push([], TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Run after interrupt",
|
||||
@@ -2864,15 +2728,13 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* SessionPending.has(db, sessionID, "queue")).toBe(true)
|
||||
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 2) yield* Effect.yieldNow
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.started
|
||||
yield* stream.release
|
||||
yield* Fiber.join(resumed)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
|
||||
expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Run after interrupt"])
|
||||
expect(userTexts(requests[0])).toEqual(["Interrupt current work"])
|
||||
expect(userTexts(requests[1])).toEqual(["Interrupt current work", "Run after interrupt"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2882,12 +2744,11 @@ describe("SessionRunnerLLM", () => {
|
||||
const { db } = yield* Database.Service
|
||||
yield* admit(session, "Interrupt current work")
|
||||
|
||||
responses = [[], reply.stop()]
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push([], TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Steer after interrupt",
|
||||
@@ -2898,15 +2759,13 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
|
||||
|
||||
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 2) yield* Effect.yieldNow
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.started
|
||||
yield* stream.release
|
||||
yield* Fiber.join(resumed)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
|
||||
expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Steer after interrupt"])
|
||||
expect(userTexts(requests[0])).toEqual(["Interrupt current work"])
|
||||
expect(userTexts(requests[1])).toEqual(["Interrupt current work", "Steer after interrupt"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2915,23 +2774,20 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Start working")
|
||||
|
||||
responses = [reply.stop(), reply.stop(), reply.stop()]
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" })
|
||||
yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(first)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
|
||||
expect(userTexts(requests[2]!)).toEqual(["Start working", "Queue first", "Queue second"])
|
||||
expect(userTexts(requests[0])).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1])).toEqual(["Start working", "Queue first"])
|
||||
expect(userTexts(requests[2])).toEqual(["Start working", "Queue first", "Queue second"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2946,13 +2802,13 @@ describe("SessionRunnerLLM", () => {
|
||||
resume: false,
|
||||
})
|
||||
|
||||
responses = [reply.stop(), reply.stop()]
|
||||
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Start steering"])
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start steering", "Queue for later"])
|
||||
expect(userTexts(requests[0])).toEqual(["Start steering"])
|
||||
expect(userTexts(requests[1])).toEqual(["Start steering", "Queue for later"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2961,39 +2817,36 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Start working")
|
||||
|
||||
responses = [reply.stop(), reply.stop(), reply.stop(), reply.stop()]
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const secondGate = yield* Deferred.make<void>()
|
||||
streamGate = firstGate
|
||||
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
|
||||
const firstStream = yield* TestLLM.gate
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
yield* firstStream.started
|
||||
yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" })
|
||||
yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" })
|
||||
streamGate = secondGate
|
||||
yield* Deferred.succeed(firstGate, undefined)
|
||||
while (requests.length < 2) yield* Effect.yieldNow
|
||||
const secondStream = yield* TestLLM.gate
|
||||
yield* firstStream.release
|
||||
yield* secondStream.started
|
||||
yield* session.prompt({ sessionID, text: "Steer before next queued input" })
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Also steer before next queued input",
|
||||
})
|
||||
yield* session.synthetic({ sessionID, text: "Background completion before next queued input" })
|
||||
yield* Deferred.succeed(secondGate, undefined)
|
||||
yield* secondStream.release
|
||||
yield* Fiber.join(first)
|
||||
streamGate = undefined
|
||||
|
||||
expect(requests).toHaveLength(4)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
|
||||
expect(userTexts(requests[2]!)).toEqual([
|
||||
expect(userTexts(requests[0])).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1])).toEqual(["Start working", "Queue first"])
|
||||
expect(userTexts(requests[2])).toEqual([
|
||||
"Start working",
|
||||
"Queue first",
|
||||
"Steer before next queued input",
|
||||
"Also steer before next queued input",
|
||||
"Background completion before next queued input",
|
||||
])
|
||||
expect(userTexts(requests[3]!)).toEqual([
|
||||
expect(userTexts(requests[3])).toEqual([
|
||||
"Start working",
|
||||
"Queue first",
|
||||
"Steer before next queued input",
|
||||
@@ -3009,22 +2862,19 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Start working")
|
||||
|
||||
responses = [reply.stop(), reply.stop()]
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* session.prompt({ sessionID, text: "First steer" })
|
||||
yield* session.prompt({ sessionID, text: "Second steer" })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(first)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"])
|
||||
expect(userTexts(requests[1])).toEqual(["Start working", "First steer", "Second steer"])
|
||||
yield* (yield* SessionExecution.Service).wake(sessionID)
|
||||
yield* Effect.yieldNow
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -3036,23 +2886,21 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Start working")
|
||||
|
||||
streamFailure = invalidRequest()
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
const failure = invalidRequest()
|
||||
yield* TestLLM.push(Stream.fail(failure))
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* session.prompt({ sessionID, text: "Recover with this" })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure)
|
||||
yield* stream.release
|
||||
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure)
|
||||
|
||||
streamFailure = undefined
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
yield* TestLLM.push([])
|
||||
yield* session.wait(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"])
|
||||
expect(userTexts(requests[1])).toEqual(["Start working", "Recover with this"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3089,11 +2937,11 @@ describe("SessionRunnerLLM", () => {
|
||||
executed: false,
|
||||
})
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
||||
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Recover interrupted tool" },
|
||||
{
|
||||
@@ -3147,11 +2995,11 @@ describe("SessionRunnerLLM", () => {
|
||||
state: { itemId: "call-hosted-interrupted" },
|
||||
})
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant"])
|
||||
expect(messageRoles(requests[0])).toEqual(["user", "assistant"])
|
||||
expect(requests[0]?.messages[1]?.content).toMatchObject([
|
||||
{
|
||||
type: "tool-call",
|
||||
@@ -3184,11 +3032,11 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
})
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
||||
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Recover interrupted tool input" },
|
||||
{ type: "assistant", content: [{ type: "tool", id: "call-pending-interrupted", state: { status: "error" } }] },
|
||||
@@ -3206,11 +3054,13 @@ describe("SessionRunnerLLM", () => {
|
||||
resume: false,
|
||||
})
|
||||
|
||||
const stream = yield* TestLLM.gate
|
||||
yield* (yield* SessionExecution.Service).wake(sessionID)
|
||||
while (requests.length === 0) yield* Effect.yieldNow
|
||||
yield* stream.started
|
||||
yield* stream.release
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Wait in queue"])
|
||||
expect(userTexts(requests[0])).toEqual(["Wait in queue"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3226,12 +3076,14 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
fail = false
|
||||
requests.length = 0
|
||||
response = reply.stop()
|
||||
yield* TestLLM.push(TestLLM.stop())
|
||||
|
||||
const stream = yield* TestLLM.gate
|
||||
yield* (yield* SessionExecution.Service).wake(sessionID)
|
||||
while (requests.length === 0) yield* Effect.yieldNow
|
||||
yield* stream.started
|
||||
yield* stream.release
|
||||
|
||||
expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"])
|
||||
expect(userTexts(requests[0])).toEqual(["Recover promoted input"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3244,21 +3096,17 @@ describe("SessionRunnerLLM", () => {
|
||||
? Effect.die("fail after prompt promotion commits")
|
||||
: Effect.void,
|
||||
)
|
||||
yield* admit(session, "Run committed promotion")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Run committed promotion")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Run committed promotion"])
|
||||
expect(userTexts(requests[0])).toEqual(["Run committed promotion"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds session correlation headers to model requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Run correlated request")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Run correlated request")
|
||||
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
"x-session-affinity": sessionID,
|
||||
@@ -3282,9 +3130,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* admit(session, "Run child request")
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Run child request")
|
||||
|
||||
expect(requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
|
||||
}),
|
||||
@@ -3301,25 +3147,21 @@ describe("SessionRunnerLLM", () => {
|
||||
resume: false,
|
||||
})
|
||||
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* stream.started
|
||||
const second = yield* session.resume(otherSessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([
|
||||
sessionID,
|
||||
otherSessionID,
|
||||
])
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3356,23 +3198,20 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Retry after failure")
|
||||
|
||||
streamFailure = invalidRequest()
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(Stream.fail(invalidRequest()))
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)])
|
||||
expect(secondExit).toEqual(firstExit)
|
||||
|
||||
streamFailure = undefined
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
expect(requests).toHaveLength(2)
|
||||
}),
|
||||
@@ -3383,7 +3222,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call missing")
|
||||
|
||||
responses = [reply.tool("call-missing", "missing", {}), reply.text("Recovered", "text-after-error")]
|
||||
yield* TestLLM.push(TestLLM.tool("call-missing", "missing", {}), TestLLM.text("Recovered", "text-after-error"))
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -3412,12 +3251,12 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call defect")
|
||||
|
||||
responses = [reply.tool("call-defect", "defect", {}), reply.text("Recovered", "text-after-defect")]
|
||||
yield* TestLLM.push(TestLLM.tool("call-defect", "defect", {}), TestLLM.text("Recovered", "text-after-defect"))
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
|
||||
const context = yield* session.context(sessionID)
|
||||
expect(context).toMatchObject([
|
||||
{ type: "user", text: "Call defect" },
|
||||
@@ -3437,7 +3276,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
const assistant = requireAssistant(context)
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.2",
|
||||
@@ -3450,9 +3289,10 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* Tool.Service
|
||||
yield* transformTools(registry,
|
||||
yield* transformTools(
|
||||
registry,
|
||||
{
|
||||
blocked: ({
|
||||
blocked: {
|
||||
name: "blocked",
|
||||
description: "Fail because policy blocked execution",
|
||||
input: Schema.Struct({}),
|
||||
@@ -3461,13 +3301,13 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.fail(new Permission.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
|
||||
Effect.mapError(() => new Tool.Error({ message: "Permission blocked" })),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Call blocked")
|
||||
|
||||
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
|
||||
yield* TestLLM.push(TestLLM.tool("call-blocked", "blocked", {}), TestLLM.stop())
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -3489,21 +3329,22 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* Tool.Service
|
||||
yield* transformTools(registry,
|
||||
yield* transformTools(
|
||||
registry,
|
||||
{
|
||||
declined: ({
|
||||
declined: {
|
||||
name: "declined",
|
||||
description: "Fail because the user declined approval",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new Permission.DeclinedError()),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Call declined")
|
||||
|
||||
response = reply.tool("call-declined", "declined", {})
|
||||
yield* TestLLM.push(TestLLM.tool("call-declined", "declined", {}))
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
|
||||
@@ -3530,9 +3371,10 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* Tool.Service
|
||||
yield* transformTools(registry,
|
||||
yield* transformTools(
|
||||
registry,
|
||||
{
|
||||
corrected: ({
|
||||
corrected: {
|
||||
name: "corrected",
|
||||
description: "Fail with user correction feedback",
|
||||
input: Schema.Struct({}),
|
||||
@@ -3541,13 +3383,13 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.fail(new Permission.CorrectedError({ feedback: "Use another tool" })).pipe(
|
||||
Effect.mapError(() => new Tool.Error({ message: "Use another tool" })),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Call corrected")
|
||||
|
||||
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
|
||||
yield* TestLLM.push(TestLLM.tool("call-corrected", "corrected", {}), TestLLM.stop())
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -3571,10 +3413,10 @@ describe("SessionRunnerLLM", () => {
|
||||
const registry = yield* Tool.Service
|
||||
yield* transformTools(registry, { permissionfail: permissionFail }, { codemode: false })
|
||||
yield* admit(session, "Reject permission")
|
||||
responses = [
|
||||
reply.tool("call-permission", "permissionfail", {}),
|
||||
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })],
|
||||
]
|
||||
yield* TestLLM.push(TestLLM.tool("call-permission", "permissionfail", {}), [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
])
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -3607,21 +3449,22 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* Tool.Service
|
||||
yield* transformTools(registry,
|
||||
yield* transformTools(
|
||||
registry,
|
||||
{
|
||||
question: ({
|
||||
question: {
|
||||
name: "question",
|
||||
description: "Ask the user",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Ask then stop")
|
||||
|
||||
responses = [reply.tool("call-question", "question", {}), []]
|
||||
yield* TestLLM.push(TestLLM.tool("call-question", "question", {}), [])
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
|
||||
const exit = yield* Fiber.join(run)
|
||||
@@ -3650,21 +3493,19 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Settle before failing")
|
||||
const failure = providerUnavailable()
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable([
|
||||
const tools = yield* blockTools()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-failure", name: "echo", input: { text: "settle" } }),
|
||||
]),
|
||||
Stream.fail(failure),
|
||||
),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (executions.length === 0) yield* Effect.yieldNow
|
||||
yield* Effect.yieldNow
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* tools.started
|
||||
yield* tools.release
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
toolExecutionGate = undefined
|
||||
|
||||
const context = yield* session.context(sessionID)
|
||||
expect(context).toMatchObject([
|
||||
@@ -3681,7 +3522,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
])
|
||||
const assistant = requireAssistant(context)
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.success.2",
|
||||
@@ -3694,19 +3535,17 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Interrupt blocked tool")
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable([
|
||||
const tools = yield* blockTools()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.hangAfter(
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-interrupt", name: "echo", input: { text: "blocked" } }),
|
||||
]),
|
||||
Stream.never,
|
||||
),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (executions.length === 0) yield* Effect.yieldNow
|
||||
yield* tools.started
|
||||
yield* session.interrupt(sessionID)
|
||||
toolExecutionGate = undefined
|
||||
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
yield* session.interrupt(sessionID)
|
||||
@@ -3725,7 +3564,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
])
|
||||
const assistant = requireAssistant(context)
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.2",
|
||||
@@ -3739,10 +3578,9 @@ describe("SessionRunnerLLM", () => {
|
||||
{ type: "assistant", content: [{ type: "tool", id: "call-before-interrupt", state: { status: "error" } }] },
|
||||
])
|
||||
requests.length = 0
|
||||
responseStream = undefined
|
||||
response = []
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
||||
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3750,15 +3588,12 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Interrupt provider")
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* session.interrupt(sessionID)
|
||||
const exit = yield* Fiber.await(run)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -3775,16 +3610,13 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Interrupt tool settlement")
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
response = reply.tool("call-await-interrupt", "echo", { text: "blocked" })
|
||||
const tools = yield* blockTools()
|
||||
yield* TestLLM.push(TestLLM.tool("call-await-interrupt", "echo", { text: "blocked" }))
|
||||
|
||||
const runner = yield* SessionRunner.Service
|
||||
const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* tools.started
|
||||
yield* Fiber.interrupt(run)
|
||||
toolExecutionGate = undefined
|
||||
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -3819,10 +3651,10 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
yield* admit(session, "Finish at the limit")
|
||||
|
||||
responses = [
|
||||
reply.tool("call-terminal", "echo", { text: "done" }),
|
||||
reply.tool("call-forbidden", "echo", { text: "forbidden" }),
|
||||
]
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("call-terminal", "echo", { text: "done" }),
|
||||
TestLLM.tool("call-forbidden", "echo", { text: "forbidden" }),
|
||||
)
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -3855,21 +3687,18 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
yield* admit(session, "Start work")
|
||||
|
||||
responses = [
|
||||
reply.tool("call-before-steer", "echo", { text: "before" }),
|
||||
reply.tool("call-after-steer", "echo", { text: "after" }),
|
||||
reply.stop(),
|
||||
]
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("call-before-steer", "echo", { text: "before" }),
|
||||
TestLLM.tool("call-after-steer", "echo", { text: "after" }),
|
||||
TestLLM.stop(),
|
||||
)
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* stream.started
|
||||
yield* session.prompt({ sessionID, text: "Change direction" })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* stream.release
|
||||
yield* Fiber.join(run)
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests[1]?.toolChoice).toBeUndefined()
|
||||
@@ -3882,11 +3711,12 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("projects provider errors as terminal assistant step failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail durably")
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
])
|
||||
|
||||
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
expect((yield* runPrompt(session, "Fail durably").pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -3899,11 +3729,9 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("projects provider errors emitted before assistant step start", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail before step")
|
||||
yield* TestLLM.push([LLMEvent.providerError({ message: "Provider unavailable" })])
|
||||
|
||||
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
expect((yield* runPrompt(session, "Fail before step").pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -3916,20 +3744,20 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("projects content-filter finishes as visible terminal failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Blocked response")
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "partial" }),
|
||||
LLMEvent.textDelta({ id: "partial", text: "Partial" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "content-filter" },
|
||||
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
|
||||
}),
|
||||
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
|
||||
]
|
||||
yield* TestLLM.push(
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "content-filter" },
|
||||
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
|
||||
},
|
||||
LLMEvent.textStart({ id: "partial" }),
|
||||
LLMEvent.textDelta({ id: "partial", text: "Partial" }),
|
||||
),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response")
|
||||
expect((yield* runPrompt(session, "Blocked response").pipe(Effect.flip)).message).toBe(
|
||||
"Provider blocked the response",
|
||||
)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{
|
||||
@@ -3953,22 +3781,18 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Tool before blocked response")
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
|
||||
]
|
||||
const tools = yield* blockTools()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.complete(
|
||||
{ reason: { normalized: "content-filter" } },
|
||||
LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }),
|
||||
),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* tools.started
|
||||
yield* tools.release
|
||||
expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider blocked the response")
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
||||
@@ -3987,16 +3811,14 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("does not recover context overflow after durable assistant output", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail after output")
|
||||
|
||||
response = [
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
])
|
||||
expect((yield* runPrompt(session, "Fail after output").pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -4014,11 +3836,10 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("projects raw provider stream failures as terminal assistant step failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail raw stream durably")
|
||||
const failure = invalidRequest()
|
||||
responseStream = Stream.fail(failure)
|
||||
yield* TestLLM.push(Stream.fail(failure))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(yield* runPrompt(session, "Fail raw stream durably").pipe(Effect.flip)).toBe(failure)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Fail raw stream durably" },
|
||||
@@ -4031,11 +3852,11 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Retry transport")
|
||||
responseStream = Stream.fail(providerUnavailable())
|
||||
response = reply.text("Recovered", "retry-success")
|
||||
yield* TestLLM.push(Stream.fail(providerUnavailable()))
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "retry-success"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("1999 millis")
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
@@ -4058,11 +3879,11 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Retry rate limit")
|
||||
responseStream = Stream.fail(rateLimited(5_000))
|
||||
response = reply.text("Recovered", "retry-after-success")
|
||||
yield* TestLLM.push(Stream.fail(rateLimited(5_000)))
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "retry-after-success"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("4999 millis")
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
@@ -4074,15 +3895,17 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("does not retry eligible failures after observable output", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Do not replay partial output")
|
||||
const failure = rateLimited()
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "partial-rate-limit" }),
|
||||
LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "partial-rate-limit" }),
|
||||
LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(yield* runPrompt(session, "Do not replay partial output").pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -4101,15 +3924,16 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Exhaust retries")
|
||||
streamFailure = providerUnavailable()
|
||||
const failure = providerUnavailable()
|
||||
yield* TestLLM.always(Stream.fail(failure))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
yield* TestLLM.wait(1)
|
||||
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
|
||||
yield* TestClock.adjust(delay)
|
||||
while (requests.length < index + 2) yield* Effect.yieldNow
|
||||
yield* TestLLM.wait(index + 2)
|
||||
}
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(streamFailure)
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(5)
|
||||
|
||||
const database = (yield* Database.Service).db
|
||||
@@ -4150,11 +3974,11 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
yield* admit(session, "Retry without consuming a step")
|
||||
const failure = providerUnavailable()
|
||||
responseStream = Stream.fail(failure)
|
||||
responses = [reply.tool("call-after-retry", "echo", { text: "recovered" }), reply.stop()]
|
||||
yield* TestLLM.push(Stream.fail(failure))
|
||||
yield* TestLLM.push(TestLLM.tool("call-after-retry", "echo", { text: "recovered" }), TestLLM.stop())
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
@@ -4185,11 +4009,10 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("does not retry non-eligible provider failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Do not retry")
|
||||
const failure = invalidRequest()
|
||||
streamFailure = failure
|
||||
yield* TestLLM.push(Stream.fail(failure))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(yield* runPrompt(session, "Do not retry").pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
}),
|
||||
@@ -4198,24 +4021,25 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("settles malformed streamed tool input before the provider failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call a malformed tool")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(yield* runPrompt(session, "Call a malformed tool").pipe(Effect.flip)).toBe(failure)
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
|
||||
response = reply.stop()
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
yield* TestLLM.push(TestLLM.stop())
|
||||
yield* runPrompt(session, "Continue")
|
||||
|
||||
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
||||
{ type: "session.step.started.1" },
|
||||
@@ -4237,12 +4061,10 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("continues after malformed local tool input without exposing raw arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Recover malformed tool input")
|
||||
const marker = "raw-malformed-marker"
|
||||
const raw = `{"text":"${marker}`
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
yield* TestLLM.push(
|
||||
TestLLM.toolCalls(
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: raw }),
|
||||
LLMEvent.toolInputEnd({ id: "call-malformed", name: "echo" }),
|
||||
@@ -4251,13 +4073,11 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw,
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
),
|
||||
TestLLM.stop(),
|
||||
)
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Recover malformed tool input")
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(executions).toEqual([])
|
||||
@@ -4313,7 +4133,7 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
if (!failed) throw new Error("Malformed tool assistant missing")
|
||||
expect(failed.error).toBeUndefined()
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
|
||||
expect(yield* recordedStepSettlementTypes(sessionID, failed.id)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.failed.2",
|
||||
"session.step.ended.1",
|
||||
@@ -4336,31 +4156,24 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Run parallel tools")
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
const tools = yield* blockTools()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.toolCalls(
|
||||
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "valid" } }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
),
|
||||
TestLLM.stop(),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* tools.started
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(executions).toEqual(["valid"])
|
||||
@@ -4379,23 +4192,20 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Interrupt malformed recovery")
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "blocked" } }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
]
|
||||
const tools = yield* blockTools()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.toolCalls(
|
||||
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "blocked" } }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* tools.started
|
||||
while (
|
||||
!(yield* session.context(sessionID)).some(
|
||||
(message) =>
|
||||
@@ -4405,8 +4215,6 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* session.interrupt(sessionID)
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -4427,19 +4235,21 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("records malformed provider-executed input as executed", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail malformed hosted input")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Invalid hosted tool input" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-hosted", name: "web_search", providerExecuted: true }),
|
||||
LLMEvent.toolInputDelta({ id: "call-hosted", name: "web_search", text: '{"query":"partial' }),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-hosted", name: "web_search", providerExecuted: true }),
|
||||
LLMEvent.toolInputDelta({ id: "call-hosted", name: "web_search", text: '{"query":"partial' }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(yield* runPrompt(session, "Fail malformed hosted input").pipe(Effect.flip)).toBe(failure)
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
error: { type: "provider.invalid-output", message: "Invalid hosted tool input" },
|
||||
content: [
|
||||
@@ -4457,22 +4267,24 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("records a provider failure after malformed input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail after malformed input")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Provider failed after malformed input" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(yield* runPrompt(session, "Fail after malformed input").pipe(Effect.flip)).toBe(failure)
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
error: { type: "provider.invalid-output", message: "Provider failed after malformed input" },
|
||||
content: [
|
||||
@@ -4491,25 +4303,22 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("continues after repeated malformed tool input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Keep producing malformed tools")
|
||||
const malformed = (id: string) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputError({
|
||||
id,
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
]
|
||||
responses = [
|
||||
const malformed = (id: string) =>
|
||||
TestLLM.toolCalls(
|
||||
LLMEvent.toolInputError({
|
||||
id,
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
)
|
||||
yield* TestLLM.push(
|
||||
malformed("call-first"),
|
||||
reply.tool("call-valid-between", "echo", { text: "valid" }),
|
||||
TestLLM.tool("call-valid-between", "echo", { text: "valid" }),
|
||||
malformed("call-second"),
|
||||
reply.stop(),
|
||||
]
|
||||
TestLLM.stop(),
|
||||
)
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Keep producing malformed tools")
|
||||
|
||||
expect(requests).toHaveLength(4)
|
||||
expect(executions).toEqual(["valid"])
|
||||
@@ -4526,20 +4335,17 @@ describe("SessionRunnerLLM", () => {
|
||||
agent.steps = 2
|
||||
}),
|
||||
)
|
||||
yield* admit(session, "Stop malformed tools at the step limit")
|
||||
const malformed = (id: string) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputError({
|
||||
id,
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
]
|
||||
responses = [malformed("call-first"), malformed("call-at-limit")]
|
||||
const malformed = (id: string) =>
|
||||
TestLLM.toolCalls(
|
||||
LLMEvent.toolInputError({
|
||||
id,
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
)
|
||||
yield* TestLLM.push(malformed("call-first"), malformed("call-at-limit"))
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Stop malformed tools at the step limit")
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
@@ -4552,28 +4358,23 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Do not continue failed provider")
|
||||
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
response = [
|
||||
const tools = yield* blockTools()
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
])
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* tools.started
|
||||
yield* tools.release
|
||||
expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(executions).toEqual(["settled"])
|
||||
const context = yield* session.context(sessionID)
|
||||
const assistant = requireAssistant(context)
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.success.2",
|
||||
@@ -4585,15 +4386,15 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("durably fails a hosted tool when its provider errors before returning a result", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail hosted tool durably")
|
||||
|
||||
response = [
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
hostedCall("call-hosted-provider-error", "effect"),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
])
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
expect((yield* runPrompt(session, "Fail hosted tool durably").pipe(Effect.flip)).message).toBe(
|
||||
"Provider unavailable",
|
||||
)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
const context = yield* session.context(sessionID)
|
||||
@@ -4605,7 +4406,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
])
|
||||
const assistant = requireAssistant(context)
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.2",
|
||||
@@ -4617,14 +4418,15 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("preserves a tool defect before provider failure settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Defect while provider fails")
|
||||
response = [
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
])
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
expect((yield* runPrompt(session, "Defect while provider fails").pipe(Effect.flip)).message).toBe(
|
||||
"Provider unavailable",
|
||||
)
|
||||
|
||||
const context = yield* session.context(sessionID)
|
||||
const assistant = requireAssistant(context)
|
||||
@@ -4643,13 +4445,15 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Storage fails while provider fails")
|
||||
response = [
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-store-provider-error", name: "storefail", input: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
])
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.exit)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
})
|
||||
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
error: { type: "provider.unknown", message: "Provider unavailable" },
|
||||
@@ -4660,10 +4464,11 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail hosted tool at EOF")
|
||||
response = [LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")]
|
||||
yield* TestLLM.push([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")])
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider did not return a tool result")
|
||||
expect((yield* runPrompt(session, "Fail hosted tool at EOF").pipe(Effect.flip)).message).toBe(
|
||||
"Provider did not return a tool result",
|
||||
)
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
||||
expect(bus.map((event) => event.type)).toEqual([
|
||||
@@ -4692,15 +4497,9 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("fails an unresolved hosted tool before one clean step end", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Settle hosted tool before ending")
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
hostedCall("call-hosted-clean-end", "effect"),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
yield* TestLLM.push(TestLLM.stop(hostedCall("call-hosted-clean-end", "effect")))
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Settle hosted tool before ending")
|
||||
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
||||
@@ -4722,21 +4521,24 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* admit(session, "Fail unresolved tools")
|
||||
const failure = invalidRequest()
|
||||
const providerFailed = yield* Deferred.make<void>()
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }),
|
||||
hostedCall("call-hosted-raw-failure-pair", "effect"),
|
||||
]),
|
||||
Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe(Stream.flatMap(() => Stream.fail(failure))),
|
||||
const tools = yield* blockTools()
|
||||
yield* TestLLM.push(
|
||||
Stream.concat(
|
||||
Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }),
|
||||
hostedCall("call-hosted-raw-failure-pair", "effect"),
|
||||
]),
|
||||
Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe(
|
||||
Stream.flatMap(() => Stream.fail(failure)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(providerFailed)
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* tools.release
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
toolExecutionGate = undefined
|
||||
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
||||
@@ -4757,14 +4559,15 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("durably fails a hosted tool left unresolved by a raw provider stream failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail hosted tool on raw failure")
|
||||
const failure = providerUnavailable()
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
|
||||
Stream.fail(failure),
|
||||
yield* TestLLM.push(
|
||||
Stream.concat(
|
||||
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
|
||||
Stream.fail(failure),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(yield* runPrompt(session, "Fail hosted tool on raw failure").pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(1)
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
|
||||
@@ -4793,15 +4596,13 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("rejects a second text start before the open fragment ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Two blocks")
|
||||
|
||||
response = [
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textStart({ id: "text-2" }),
|
||||
]
|
||||
])
|
||||
|
||||
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
|
||||
const defect = yield* runPrompt(session, "Two blocks").pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
if (!(defect instanceof Error)) return
|
||||
expect(defect.message).toBe("text start before end: text-2")
|
||||
@@ -4811,21 +4612,18 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("projects sequential text fragments as separate content parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Two blocks")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.stop(
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textDelta({ id: "text-1", text: "First" }),
|
||||
LLMEvent.textEnd({ id: "text-1" }),
|
||||
LLMEvent.textStart({ id: "text-2" }),
|
||||
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
|
||||
LLMEvent.textEnd({ id: "text-2" }),
|
||||
),
|
||||
)
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textDelta({ id: "text-1", text: "First" }),
|
||||
LLMEvent.textEnd({ id: "text-1" }),
|
||||
LLMEvent.textStart({ id: "text-2" }),
|
||||
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
|
||||
LLMEvent.textEnd({ id: "text-2" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Two blocks")
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Two blocks" },
|
||||
@@ -4855,7 +4653,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("rejects duplicate streamed text starts", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })]
|
||||
yield* TestLLM.push([LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })])
|
||||
|
||||
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
@@ -4867,19 +4665,16 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("transitions streamed raw tool input to parsed called input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call provider tool")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.stop(
|
||||
LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }),
|
||||
hostedCall("call-parsed", "hello"),
|
||||
),
|
||||
)
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }),
|
||||
hostedCall("call-parsed", "hello"),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* runPrompt(session, "Call provider tool")
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Call provider tool" },
|
||||
@@ -4894,7 +4689,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("rejects malformed streamed tool input ordering", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })]
|
||||
yield* TestLLM.push([LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })])
|
||||
|
||||
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
|
||||
@@ -165,8 +165,8 @@ const bodyExitCommand = isWindows
|
||||
: "printf body && exit 7"
|
||||
const overflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
||||
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||
@@ -200,10 +200,11 @@ describe("ShellTool", () => {
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const shell = definitions.find((tool) => tool.name === "shell")
|
||||
expect(shell).toBeDefined()
|
||||
const definition = definitions.find((tool) => tool.name === "shell")
|
||||
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
||||
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
||||
// Code Mode receives the declared output schema, including the command output text.
|
||||
expect(shell?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(definition?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
@@ -409,7 +410,11 @@ describe("ShellTool", () => {
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
||||
expect(settled.content?.[0]).toMatchObject({
|
||||
const content = settled.content?.[0]
|
||||
if (!content || content.type !== "text") throw new Error("Expected text content")
|
||||
expect(content.text.includes("output-start")).toBe(false)
|
||||
expect(content.text.includes("output-end")).toBe(true)
|
||||
expect(content).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("output truncated; full output saved to:"),
|
||||
})
|
||||
|
||||
@@ -30,6 +30,15 @@ const webSearchToolNode = makeLocationNode({
|
||||
const sessionID = Session.ID.make("ses_websearch_test")
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const queries: WebSearch.Input[] = []
|
||||
const formRequests: Form.CreateInput[] = []
|
||||
const values = new Map<string, KV.Value>()
|
||||
const providers = [
|
||||
{ id: WebSearch.ID.make("exa"), name: "Exa" },
|
||||
{ id: WebSearch.ID.make("parallel"), name: "Parallel" },
|
||||
]
|
||||
let providerRequired = false
|
||||
let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||
const formResponses: Form.TerminalState[] = []
|
||||
let result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -38,6 +47,11 @@ let result = new WebSearch.Response({
|
||||
beforeEach(() => {
|
||||
assertions.length = 0
|
||||
queries.length = 0
|
||||
formRequests.length = 0
|
||||
values.clear()
|
||||
providerRequired = false
|
||||
formResponse = { status: "cancelled" }
|
||||
formResponses.length = 0
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -60,11 +74,15 @@ const websearch = Layer.succeed(
|
||||
WebSearch.Service.of({
|
||||
transform: () => Effect.die("unused"),
|
||||
reload: () => Effect.die("unused"),
|
||||
providers: () => Effect.succeed([]),
|
||||
providers: () => Effect.succeed(providers),
|
||||
default: () => Effect.succeed(undefined),
|
||||
query: (input) =>
|
||||
Effect.sync(() => {
|
||||
Effect.gen(function* () {
|
||||
queries.push(input)
|
||||
const stored = values.get("websearch:provider")
|
||||
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
||||
if (typeof stored === "string")
|
||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||
return result
|
||||
}),
|
||||
}),
|
||||
@@ -73,7 +91,11 @@ const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
create: () => Effect.die("unused"),
|
||||
ask: () => Effect.die("unused"),
|
||||
ask: (input) =>
|
||||
Effect.sync(() => {
|
||||
formRequests.push(input)
|
||||
return formResponses.shift() ?? formResponse
|
||||
}),
|
||||
get: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
state: () => Effect.die("unused"),
|
||||
@@ -84,22 +106,19 @@ const form = Layer.succeed(
|
||||
const kv = Layer.succeed(
|
||||
KV.Service,
|
||||
KV.Service.of({
|
||||
get: () => Effect.succeed(undefined),
|
||||
set: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
get: (key) => Effect.succeed(values.get(key)),
|
||||
set: (key, value) => Effect.sync(() => values.set(key, value)).pipe(Effect.asVoid),
|
||||
remove: (key) => Effect.sync(() => values.delete(key)).pipe(Effect.asVoid),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]),
|
||||
[
|
||||
[Permission.node, permission],
|
||||
[WebSearch.node, websearch],
|
||||
[Form.node, form],
|
||||
[KV.node, kv],
|
||||
[Image.node, imagePassthrough],
|
||||
],
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [
|
||||
[Permission.node, permission],
|
||||
[WebSearch.node, websearch],
|
||||
[Form.node, form],
|
||||
[KV.node, kv],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("WebSearchTool registration", () => {
|
||||
@@ -202,4 +221,116 @@ describe("WebSearchTool registration", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("asks once and uses the default provider when web search is first enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
|
||||
expect(values.get("websearch:provider")).toBe("exa")
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(formRequests).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: "Allow web search via Exa",
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
|
||||
expect(formRequests).toHaveLength(1)
|
||||
expect(queries).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("asks a second form when choosing another provider", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponses.push(
|
||||
{ status: "answered", answer: { choice: "choose" } },
|
||||
{ status: "answered", answer: { provider: "parallel" } },
|
||||
)
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
|
||||
expect(values.get("websearch:provider")).toBe("parallel")
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(formRequests[1]).toEqual({
|
||||
sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{ value: "exa", label: "Exa" },
|
||||
{ value: "parallel", label: "Parallel" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists the choice to disable web search", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "disable" } }
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(values.get("websearch:provider")).toBe(false)
|
||||
expect(queries).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5"
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
@@ -43,6 +44,9 @@
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -52,6 +56,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
|
||||
@@ -113,7 +113,22 @@ export interface Page {
|
||||
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
|
||||
}
|
||||
|
||||
export type Slot = (props: Record<string, any>) => JSX.Element
|
||||
export interface SlotMap {
|
||||
readonly app: Readonly<Record<string, never>>
|
||||
readonly "home.footer": Readonly<Record<string, never>>
|
||||
readonly "sidebar.content": {
|
||||
readonly sessionID: string
|
||||
}
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
}
|
||||
|
||||
export type SlotName = keyof SlotMap
|
||||
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
|
||||
|
||||
export interface App {
|
||||
readonly version: string
|
||||
readonly channel: string
|
||||
}
|
||||
|
||||
export type ToastVariant = "info" | "success" | "warning" | "error"
|
||||
|
||||
@@ -215,11 +230,11 @@ export interface DialogSelectOptions<Value> {
|
||||
}
|
||||
|
||||
export interface Dialog {
|
||||
/** Shows a dialog and returns a function that closes it. */
|
||||
show(render: () => JSX.Element, onClose?: () => void): () => void
|
||||
/** Sets the presentation options for this plugin's active dialog. */
|
||||
/** Shows a dialog. */
|
||||
show(render: () => JSX.Element, onClose?: () => void): void
|
||||
/** Sets the active dialog's presentation options. */
|
||||
set(options: DialogOptions): void
|
||||
/** Closes this plugin's active dialog. */
|
||||
/** Closes the active dialog. */
|
||||
clear(): void
|
||||
alert(options: DialogAlertOptions): Promise<void>
|
||||
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
|
||||
@@ -308,17 +323,21 @@ export interface Keymap {
|
||||
export interface UI {
|
||||
readonly dialog: Dialog
|
||||
readonly toast: Toast
|
||||
readonly format: {
|
||||
path(value: string): string
|
||||
}
|
||||
readonly router: {
|
||||
register(page: Page): () => void
|
||||
navigate(destination: Destination): void
|
||||
current(): Route
|
||||
}
|
||||
readonly slot: (name: string, render: Slot) => () => void
|
||||
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
readonly options: Readonly<Record<string, any>>
|
||||
readonly location: LocationRef | undefined
|
||||
readonly app: App
|
||||
readonly renderer: CliRenderer
|
||||
readonly client: OpenCodeClient
|
||||
readonly data: Data
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { PluginContextProvider, usePlugin } from "./solid.js"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createComponent, createContext, useContext, type JSX } from "solid-js"
|
||||
import type { Context } from "./context.js"
|
||||
|
||||
const PluginContext = createContext<Context>()
|
||||
|
||||
export function PluginContextProvider(props: { readonly value: Context; readonly children: JSX.Element }) {
|
||||
return createComponent(PluginContext.Provider, {
|
||||
value: props.value,
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlugin() {
|
||||
const context = useContext(PluginContext)
|
||||
if (!context) throw new Error("PluginContextProvider is missing")
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/theme",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/theme"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./tui": "./src/tui/index.ts",
|
||||
"./tui/v1": "./src/tui/v1.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
|
||||
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
pkg.exports = Object.fromEntries(
|
||||
Object.entries(pkg.exports).map(([key, value]) => {
|
||||
if (typeof value !== "string") return [key, value]
|
||||
return [
|
||||
key,
|
||||
{
|
||||
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
type OklchColor = {
|
||||
l: number
|
||||
c: number
|
||||
h: number
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function hue(value: number) {
|
||||
return ((value % 360) + 360) % 360
|
||||
}
|
||||
|
||||
function linearToSrgb(value: number) {
|
||||
if (value <= 0.0031308) return value * 12.92
|
||||
return 1.055 * Math.pow(value, 1 / 2.4) - 0.055
|
||||
}
|
||||
|
||||
function srgbToLinear(value: number) {
|
||||
if (value <= 0.04045) return value / 12.92
|
||||
return Math.pow((value + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
|
||||
export function rgbToOklch(red: number, green: number, blue: number): OklchColor {
|
||||
const linearRed = srgbToLinear(red)
|
||||
const linearGreen = srgbToLinear(green)
|
||||
const linearBlue = srgbToLinear(blue)
|
||||
const lRoot = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
|
||||
const mRoot = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
|
||||
const sRoot = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
|
||||
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot
|
||||
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot
|
||||
const b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot
|
||||
const chroma = Math.sqrt(a * a + b * b)
|
||||
const angle = Math.atan2(b, a) * (180 / Math.PI)
|
||||
return { l: lightness, c: chroma, h: angle < 0 ? angle + 360 : angle }
|
||||
}
|
||||
|
||||
function oklchToRgb(color: OklchColor) {
|
||||
const a = color.c * Math.cos((color.h * Math.PI) / 180)
|
||||
const b = color.c * Math.sin((color.h * Math.PI) / 180)
|
||||
const lRoot = color.l + 0.3963377774 * a + 0.2158037573 * b
|
||||
const mRoot = color.l - 0.1055613458 * a - 0.0638541728 * b
|
||||
const sRoot = color.l - 0.0894841775 * a - 1.291485548 * b
|
||||
const l = lRoot * lRoot * lRoot
|
||||
const m = mRoot * mRoot * mRoot
|
||||
const s = sRoot * sRoot * sRoot
|
||||
return {
|
||||
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
||||
}
|
||||
}
|
||||
|
||||
function fitOklch(color: OklchColor): OklchColor {
|
||||
const base = { l: clamp(color.l, 0, 1), c: Math.max(0, color.c), h: hue(color.h) }
|
||||
const rgb = oklchToRgb(base)
|
||||
if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) return base
|
||||
|
||||
const fitted = Array.from({ length: 24 }).reduce<OklchColor | undefined>((result, _, index) => {
|
||||
if (result) return result
|
||||
const next = { ...base, c: base.c * Math.pow(0.9, index + 1) }
|
||||
const output = oklchToRgb(next)
|
||||
if (output.r >= 0 && output.r <= 1 && output.g >= 0 && output.g <= 1 && output.b >= 0 && output.b <= 1) return next
|
||||
}, undefined)
|
||||
return fitted ?? { ...base, c: 0 }
|
||||
}
|
||||
|
||||
export function oklchToHex(color: OklchColor) {
|
||||
const rgb = oklchToRgb(fitOklch(color))
|
||||
const toHex = (value: number) =>
|
||||
Math.round(clamp(value, 0, 1) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HueName, ThemeDocument } from "./schema"
|
||||
import type { HueName, ThemeDocument } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
StatefulColorDefinition,
|
||||
TextDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index"
|
||||
import { ActionState } from "./schema"
|
||||
} from "./index.js"
|
||||
import { ActionState } from "./schema.js"
|
||||
|
||||
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
|
||||
return {
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ThemeTokensDefinition } from "./index.js"
|
||||
import { ActionVariant, FeedbackKind } from "./schema.js"
|
||||
|
||||
export function fallback(): ThemeTokensDefinition {
|
||||
const red = "#ff0000"
|
||||
|
||||
return {
|
||||
text: {
|
||||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
background: {
|
||||
default: red,
|
||||
surface: { offset: red, overlay: red },
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
border: { default: red },
|
||||
scrollbar: { default: red },
|
||||
diff: {
|
||||
text: { added: red, removed: red, context: red, hunkHeader: red },
|
||||
background: { added: red, removed: red, context: red },
|
||||
highlight: { added: red, removed: red },
|
||||
lineNumber: { text: red, background: { added: red, removed: red } },
|
||||
},
|
||||
syntax: {
|
||||
comment: red,
|
||||
keyword: red,
|
||||
function: red,
|
||||
variable: red,
|
||||
string: red,
|
||||
number: red,
|
||||
type: red,
|
||||
operator: red,
|
||||
punctuation: red,
|
||||
},
|
||||
markdown: {
|
||||
text: red,
|
||||
heading: red,
|
||||
link: red,
|
||||
linkText: red,
|
||||
code: red,
|
||||
blockQuote: red,
|
||||
emphasis: red,
|
||||
strong: red,
|
||||
horizontalRule: red,
|
||||
listItem: red,
|
||||
listEnumeration: red,
|
||||
image: red,
|
||||
imageText: red,
|
||||
codeBlock: red,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export {
|
||||
type ContextKey,
|
||||
type TextDefinition,
|
||||
type ThemeTokensDefinition,
|
||||
} from "./schema"
|
||||
} from "./schema.js"
|
||||
|
||||
export type {
|
||||
Categorical,
|
||||
@@ -42,7 +42,9 @@ export type {
|
||||
ResolvedTheme,
|
||||
ResolvedThemeView,
|
||||
StatefulColor,
|
||||
} from "./types"
|
||||
export { DEFAULT_CATEGORICAL } from "./defaults"
|
||||
export { migrateV1 } from "./v1-migrate"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
|
||||
} from "./types.js"
|
||||
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
export { migrateV1 } from "./v1-migrate.js"
|
||||
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
|
||||
export { generateSyntax } from "./syntax.js"
|
||||
@@ -1,8 +1,8 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand"
|
||||
import { fallback } from "./fallback"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
|
||||
import { fallback } from "./fallback.js"
|
||||
import {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
HueStep,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
} from "./schema"
|
||||
} from "./schema.js"
|
||||
import type {
|
||||
ActionStateKey,
|
||||
HueDefinition,
|
||||
@@ -22,8 +22,8 @@ import type {
|
||||
ResolvedThemeView,
|
||||
StatefulColorDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index"
|
||||
import { selectTheme, selectThemeMode } from "./select"
|
||||
} from "./index.js"
|
||||
import { selectTheme, selectThemeMode } from "./select.js"
|
||||
|
||||
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expandTheme, mergeTheme } from "./expand"
|
||||
import { expandTheme, mergeTheme } from "./expand.js"
|
||||
import type {
|
||||
FileThemeDefinition,
|
||||
MergeModeDefinition,
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
ModeDefinition,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
} from "./index"
|
||||
} from "./index.js"
|
||||
|
||||
export function selectTheme(
|
||||
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
|
||||
import type { Mode, ResolvedThemeView } from "./index"
|
||||
import type { Mode, ResolvedThemeView } from "./index.js"
|
||||
|
||||
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
|
||||
const step = mode === "light" ? 800 : 200
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
MarkdownToken,
|
||||
ContextKey,
|
||||
SyntaxToken,
|
||||
} from "./schema"
|
||||
} from "./schema.js"
|
||||
|
||||
export type ResolvedActionState = "default" | ActionState
|
||||
export type ResolvedFormfieldState = ResolvedActionState
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
||||
import type { Theme, ThemeV1Json } from "../v1"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index"
|
||||
import { HueStep } from "./schema"
|
||||
import { oklchToHex, rgbToOklch } from "./color.js"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
|
||||
import { HueStep } from "./schema.js"
|
||||
import type { Theme, ThemeV1Json } from "./v1.js"
|
||||
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
|
||||
export type Theme = {
|
||||
readonly primary: RGBA
|
||||
readonly secondary: RGBA
|
||||
readonly accent: RGBA
|
||||
readonly error: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly success: RGBA
|
||||
readonly info: RGBA
|
||||
readonly text: RGBA
|
||||
readonly textMuted: RGBA
|
||||
readonly selectedListItemText: RGBA
|
||||
readonly background: RGBA
|
||||
readonly backgroundPanel: RGBA
|
||||
readonly backgroundElement: RGBA
|
||||
readonly backgroundMenu: RGBA
|
||||
readonly border: RGBA
|
||||
readonly borderActive: RGBA
|
||||
readonly borderSubtle: RGBA
|
||||
readonly diffAdded: RGBA
|
||||
readonly diffRemoved: RGBA
|
||||
readonly diffContext: RGBA
|
||||
readonly diffHunkHeader: RGBA
|
||||
readonly diffHighlightAdded: RGBA
|
||||
readonly diffHighlightRemoved: RGBA
|
||||
readonly diffAddedBg: RGBA
|
||||
readonly diffRemovedBg: RGBA
|
||||
readonly diffContextBg: RGBA
|
||||
readonly diffLineNumber: RGBA
|
||||
readonly diffAddedLineNumberBg: RGBA
|
||||
readonly diffRemovedLineNumberBg: RGBA
|
||||
readonly markdownText: RGBA
|
||||
readonly markdownHeading: RGBA
|
||||
readonly markdownLink: RGBA
|
||||
readonly markdownLinkText: RGBA
|
||||
readonly markdownCode: RGBA
|
||||
readonly markdownBlockQuote: RGBA
|
||||
readonly markdownEmph: RGBA
|
||||
readonly markdownStrong: RGBA
|
||||
readonly markdownHorizontalRule: RGBA
|
||||
readonly markdownListItem: RGBA
|
||||
readonly markdownListEnumeration: RGBA
|
||||
readonly markdownImage: RGBA
|
||||
readonly markdownImageText: RGBA
|
||||
readonly markdownCodeBlock: RGBA
|
||||
readonly syntaxComment: RGBA
|
||||
readonly syntaxKeyword: RGBA
|
||||
readonly syntaxFunction: RGBA
|
||||
readonly syntaxVariable: RGBA
|
||||
readonly syntaxString: RGBA
|
||||
readonly syntaxNumber: RGBA
|
||||
readonly syntaxType: RGBA
|
||||
readonly syntaxOperator: RGBA
|
||||
readonly syntaxPunctuation: RGBA
|
||||
readonly thinkingOpacity: number
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
|
||||
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
export type HexColor = `#${string}`
|
||||
export type RefName = string
|
||||
export type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeV1Json = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowJs": false,
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./builtins": "./src/feature-plugins/builtins.ts",
|
||||
"./config": "./src/config/index.tsx",
|
||||
"./config/keybind": "./src/config/keybind.ts",
|
||||
"./config/v1": "./src/config/v1/index.tsx",
|
||||
@@ -37,9 +36,6 @@
|
||||
"./context/keymap": "./src/context/keymap.tsx",
|
||||
"./prompt/content": "./src/prompt/content.ts",
|
||||
"./prompt/display": "./src/prompt/display.ts",
|
||||
"./plugin/runtime": "./src/plugin/runtime.tsx",
|
||||
"./plugin/slots": "./src/plugin/slots.tsx",
|
||||
"./plugin/command-shim": "./src/plugin/command-shim.ts",
|
||||
"./parsers-config": "./src/parsers-config.ts",
|
||||
"./util/error": "./src/util/error.ts",
|
||||
"./util/filetype": "./src/util/filetype.ts",
|
||||
@@ -83,6 +79,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
+200
-112
@@ -51,6 +51,7 @@ import { StartupLoading } from "./component/startup-loading"
|
||||
import { DevToolsBar } from "./component/devtools-bar"
|
||||
import { Reconnecting } from "./component/reconnecting"
|
||||
import { DataProvider, useData } from "./context/data"
|
||||
import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs"
|
||||
import { LocationProvider, useLocation } from "./context/location"
|
||||
import { LocalProvider, useLocal } from "./context/local"
|
||||
import { PermissionProvider } from "./context/permission"
|
||||
@@ -65,8 +66,9 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme } from "./context/theme"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
@@ -79,7 +81,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
|
||||
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
@@ -92,9 +93,28 @@ import { AttentionProvider } from "./context/attention"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
"session.new",
|
||||
const appGlobalBindingCommands = ["session.list", "session.new"] as const
|
||||
|
||||
const sessionTabBindingCommands = [
|
||||
"session.tab.next",
|
||||
"session.tab.previous",
|
||||
"session.tab.history.back",
|
||||
"session.tab.history.forward",
|
||||
"session.tab.next_unread",
|
||||
"session.tab.previous_unread",
|
||||
"session.tab.close",
|
||||
"session.tab.select.1",
|
||||
"session.tab.select.2",
|
||||
"session.tab.select.3",
|
||||
"session.tab.select.4",
|
||||
"session.tab.select.5",
|
||||
"session.tab.select.6",
|
||||
"session.tab.select.7",
|
||||
"session.tab.select.8",
|
||||
"session.tab.select.9",
|
||||
] as const
|
||||
|
||||
const pinnedSessionBindingCommands = [
|
||||
"session.quick_switch.1",
|
||||
"session.quick_switch.2",
|
||||
"session.quick_switch.3",
|
||||
@@ -254,8 +274,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
|
||||
)
|
||||
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
|
||||
const pluginRuntime = createPluginRuntime()
|
||||
|
||||
yield* Effect.tryPromise(async () => {
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
@@ -277,113 +295,113 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
||||
>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiLifecycleProvider
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
<TuiLifecycleProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</TuiPathsProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</TuiAppProvider>
|
||||
</EpilogueProvider>
|
||||
@@ -419,17 +437,17 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const renderer = useRenderer()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const keymap = Keymap.use()
|
||||
const event = useEvent()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
|
||||
const theme = useTheme()
|
||||
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const plugins = usePlugin()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
@@ -623,9 +641,71 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
run: () => {
|
||||
local.session.quickSwitch(i + 1)
|
||||
},
|
||||
enabled: () => !sessionTabs.enabled(),
|
||||
run: () => local.session.quickSwitch(i + 1),
|
||||
})),
|
||||
{
|
||||
name: "session.tab.next",
|
||||
title: "Next open session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycle(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous",
|
||||
title: "Previous open session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycle(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.back",
|
||||
title: "Back in session tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.history(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.forward",
|
||||
title: "Forward in session tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.history(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.next_unread",
|
||||
title: "Next unread session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycleUnread(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous_unread",
|
||||
title: "Previous unread session tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycleUnread(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.close",
|
||||
title: "Close current session tab",
|
||||
category: "Session",
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.close(),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.tab.select.${i + 1}`,
|
||||
title: `Switch to session tab ${i + 1}`,
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.selectIndex(i),
|
||||
})),
|
||||
{
|
||||
name: "model.list",
|
||||
@@ -1004,6 +1084,18 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
bindings: appGlobalBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: sessionTabs.enabled,
|
||||
bindings: sessionTabBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: () => !sessionTabs.enabled(),
|
||||
bindings: pinnedSessionBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: () => {
|
||||
const current = promptRef.current
|
||||
@@ -1090,7 +1182,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseDown={(evt) => {
|
||||
if (copyOnSelectEnabled()) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
@@ -1099,16 +1191,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={
|
||||
copyOnSelectEnabled()
|
||||
? () => Selection.copy(renderer, toast, clipboard)
|
||||
: undefined
|
||||
}
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
@@ -1127,10 +1218,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<box flexShrink={0}>
|
||||
<PluginSlot name="app.bottom" />
|
||||
</box>
|
||||
<PluginSlot name="app" />
|
||||
<PluginSlot name="app" input={{}} mode="all" />
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@opentui/core"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
||||
|
||||
@@ -70,7 +70,9 @@ declare module "@opentui/solid" {
|
||||
extend({ go_upsell_art: GoUpsellArtRenderable })
|
||||
|
||||
export function BgPulse() {
|
||||
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||
const themes = useThemes()
|
||||
const theme = themes.contextual("elevated")
|
||||
const mode = themes.mode
|
||||
const renderer = useRenderer()
|
||||
let targetFps = renderer.targetFps
|
||||
let maxFps = renderer.maxFps
|
||||
@@ -91,9 +93,9 @@ export function BgPulse() {
|
||||
<go_upsell_art
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundPanel={themeV2.background.default}
|
||||
primary={themeV2.hue.interactive[mode() === "light" ? 800 : 200]}
|
||||
logoBase={tint(themeV2.background.default, themeV2.text.default, 0.62)}
|
||||
backgroundPanel={theme.background.default}
|
||||
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
|
||||
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
|
||||
live
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useData } from "../context/data"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useRoute } from "../context/route"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { DevTools } from "../devtools"
|
||||
import { usePlugin } from "../plugin/context"
|
||||
import { errorMessage } from "../util/error"
|
||||
@@ -31,12 +31,12 @@ export function DevToolsBar() {
|
||||
const location = useLocation()
|
||||
const route = useRoute()
|
||||
const plugins = usePlugin()
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const keymap = Keymap.use()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2, mode, supports, setMode } = theme
|
||||
const elevatedTheme = theme.contextual("elevated").themeV2
|
||||
const { current: theme, mode, supports, setMode } = themes
|
||||
const elevatedTheme = themes.contextual("elevated")
|
||||
const [panel, setPanel] = createSignal<Panel>()
|
||||
const [dumping, setDumping] = createSignal(false)
|
||||
const [dumpPath, setDumpPath] = createSignal<string>()
|
||||
@@ -196,8 +196,8 @@ export function DevToolsBar() {
|
||||
})),
|
||||
},
|
||||
theme: {
|
||||
name: theme.selected,
|
||||
mode: theme.mode(),
|
||||
name: themes.selected,
|
||||
mode: themes.mode(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
@@ -213,7 +213,7 @@ export function DevToolsBar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
|
||||
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
|
||||
<Show when={panel()}>
|
||||
<box
|
||||
position="absolute"
|
||||
@@ -230,12 +230,12 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "server"
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: serverIndicator().state === "connected"
|
||||
? themeV2.text.feedback.success.default
|
||||
? theme.text.feedback.success.default
|
||||
: serverIndicator().state === "disconnected"
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.default
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.default
|
||||
}
|
||||
>
|
||||
{serverIndicator().icon}
|
||||
@@ -243,10 +243,10 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "server"
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: serverIndicator().state === "disconnected"
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.subdued
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
@@ -279,10 +279,10 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "ui"
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: runtime() === "high"
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.subdued
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{statusIcon(runtime())}
|
||||
@@ -290,10 +290,10 @@ export function DevToolsBar() {
|
||||
<text
|
||||
fg={
|
||||
panel() === "ui"
|
||||
? themeV2.text.action.primary.focused
|
||||
? theme.text.action.primary.focused
|
||||
: runtime() === "high"
|
||||
? themeV2.text.feedback.error.default
|
||||
: themeV2.text.subdued
|
||||
? theme.text.feedback.error.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
@@ -320,11 +320,11 @@ export function DevToolsBar() {
|
||||
</Show>
|
||||
</BarItem>
|
||||
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
|
||||
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
|
||||
<text fg={panel() === "theme" ? theme.text.action.primary.focused : theme.text.subdued}>Theme</text>
|
||||
<Show when={panel() === "theme"}>
|
||||
<PanelBox>
|
||||
<PanelTitle>Theme</PanelTitle>
|
||||
<Row label="Name" value={theme.selected} />
|
||||
<Row label="Name" value={themes.selected} />
|
||||
<Row label="Mode" value={mode()} />
|
||||
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
|
||||
<Show when={canSwitchMode()}>
|
||||
@@ -336,7 +336,7 @@ export function DevToolsBar() {
|
||||
</Show>
|
||||
</BarItem>
|
||||
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
|
||||
<text fg={panel() === "tools" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
|
||||
<text fg={panel() === "tools" ? theme.text.action.primary.focused : theme.text.subdued}>Tools</text>
|
||||
<Show when={panel() === "tools"}>
|
||||
<PanelBox>
|
||||
<PanelTitle>Tools</PanelTitle>
|
||||
@@ -406,14 +406,14 @@ export function DevToolsBar() {
|
||||
</Show>
|
||||
</BarItem>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
<TimeToFirstDraw visible={timing()} width="100%" fg={themeV2.text.subdued} label="Time to first draw" />
|
||||
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
return (
|
||||
<box
|
||||
@@ -423,7 +423,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={props.active ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={props.active ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
props.onClick()
|
||||
@@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
}
|
||||
|
||||
function PanelBox(props: ParentProps) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const renderer = useRenderer()
|
||||
return (
|
||||
<box
|
||||
@@ -448,7 +448,7 @@ function PanelBox(props: ParentProps) {
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
flexDirection="column"
|
||||
onMouseUp={(event) => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
@@ -461,32 +461,32 @@ function PanelBox(props: ParentProps) {
|
||||
}
|
||||
|
||||
function PanelTitle(props: ParentProps) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
return (
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||
{props.children}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function Row(props: { label: string; value: string }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={themeV2.text.subdued}>{props.label}</text>
|
||||
<text fg={theme.text.subdued}>{props.label}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={themeV2.text.default}>{props.value}</text>
|
||||
<text fg={theme.text.default}>{props.value}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
backgroundColor={
|
||||
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
|
||||
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
|
||||
}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
@@ -495,7 +495,7 @@ function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; ho
|
||||
if (!props.disabled) props.onClick()
|
||||
}}
|
||||
>
|
||||
<text fg={props.disabled ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
|
||||
<text fg={props.disabled ? theme.text.subdued : theme.text.action.primary.default}>{props.children}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -506,7 +506,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
|
||||
}
|
||||
|
||||
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const value = () => {
|
||||
const value = props.values.at(-1)
|
||||
if (value === undefined) return "--"
|
||||
@@ -515,13 +515,13 @@ function ProcessStat(props: { label: string; values: readonly number[]; unit: st
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<box width={7}>
|
||||
<text fg={themeV2.text.subdued}>{props.label}</text>
|
||||
<text fg={theme.text.subdued}>{props.label}</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
|
||||
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{brailleGraph(props.values)}</text>
|
||||
</box>
|
||||
<box width={8} alignItems="flex-end">
|
||||
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
|
||||
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{value()}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
@@ -93,6 +93,14 @@ export const settings: Setting[] = [
|
||||
values: ["none", "auto"],
|
||||
keywords: ["transcript", "messages"],
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "enabled"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
@@ -267,7 +275,7 @@ export function settingID(setting: Setting) {
|
||||
export function DialogConfig(props: { current?: string }) {
|
||||
const config = useConfig()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const themes = useThemes()
|
||||
const current = Math.max(
|
||||
0,
|
||||
settings.findIndex((setting) => settingID(setting) === props.current),
|
||||
@@ -280,12 +288,12 @@ export function DialogConfig(props: { current?: string }) {
|
||||
if (!result || typeof result !== "object") return undefined
|
||||
return (result as Record<string, unknown>)[key]
|
||||
}, config.data)
|
||||
if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
|
||||
if (setting.path.join(".") === "theme.name") return current ?? themes.selected
|
||||
return current ?? setting.default
|
||||
}
|
||||
const values = (setting: Setting) =>
|
||||
setting.path.join(".") === "theme.name"
|
||||
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
? Object.keys(themes.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
: setting.values
|
||||
const display = (setting: Setting) => {
|
||||
const current = value(setting)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { describeOS, describeTerminal } from "../util/system"
|
||||
import { useTuiApp } from "../context/runtime"
|
||||
|
||||
export function DialogDebug() {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const local = useLocal()
|
||||
@@ -55,10 +55,10 @@ export function DialogDebug() {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Debug
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -68,10 +68,10 @@ export function DialogDebug() {
|
||||
<For each={entries()}>
|
||||
{(entry) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={themeV2.text.subdued}>
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
{entry.label.padEnd(10)}
|
||||
</text>
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
{entry.value}
|
||||
</text>
|
||||
</box>
|
||||
@@ -79,12 +79,12 @@ export function DialogDebug() {
|
||||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.subdued}>Share this when reporting an issue.</text>
|
||||
<text fg={theme.text.subdued}>Share this when reporting an issue.</text>
|
||||
<text onMouseUp={copy}>
|
||||
<span style={{ fg: copied() ? themeV2.text.feedback.success.default : themeV2.text.default }}>
|
||||
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
|
||||
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>enter</span>
|
||||
<span style={{ fg: theme.text.subdued }}>enter</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
@@ -64,7 +64,7 @@ export function DialogIntegration(
|
||||
) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const options = createMemo(() => {
|
||||
const providers = data.location.websearch.list() ?? []
|
||||
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
|
||||
@@ -87,7 +87,7 @@ export function DialogIntegration(
|
||||
disabled: methods.length === 0 && credentials.length === 0,
|
||||
gutter:
|
||||
integration.connections.length > 0
|
||||
? () => <text fg={themeV2.text.feedback.success.default}>✓</text>
|
||||
? () => <text fg={theme.text.feedback.success.default}>✓</text>
|
||||
: undefined,
|
||||
onSelect: () => {
|
||||
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
|
||||
@@ -103,12 +103,12 @@ export function DialogIntegration(
|
||||
options={options()}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No integrations available</text>
|
||||
<text fg={theme.text.subdued}>No integrations available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No integrations found</text>
|
||||
<text fg={theme.text.subdued}>No integrations found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
@@ -303,16 +303,16 @@ function CommandPending(props: {
|
||||
|
||||
function CommandView(props: { title: string; output: string; message: string }) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
onMount(() => dialog.setSize("large"))
|
||||
return (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc close
|
||||
</text>
|
||||
</box>
|
||||
@@ -326,7 +326,7 @@ function CommandView(props: { title: string; output: string; message: string })
|
||||
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
@@ -341,7 +341,7 @@ function KeyMethod(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [error, setError] = createSignal<string>()
|
||||
|
||||
return (
|
||||
@@ -360,7 +360,7 @@ function KeyMethod(props: {
|
||||
.catch((cause) => setError(message(cause)))
|
||||
}}
|
||||
description={() => (
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
@@ -516,7 +516,7 @@ function OAuthCode(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [error, setError] = createSignal<string>()
|
||||
let settled = false
|
||||
|
||||
@@ -550,9 +550,9 @@ function OAuthCode(props: {
|
||||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={themeV2.text.subdued}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={themeV2.markdown.link} />
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
<text fg={theme.text.subdued}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={theme.markdown.link} />
|
||||
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
@@ -561,31 +561,31 @@ function OAuthCode(props: {
|
||||
|
||||
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={props.url}>
|
||||
{(url) => (
|
||||
<box gap={1}>
|
||||
<Link href={url()} fg={themeV2.markdown.link} />
|
||||
<Link href={url()} fg={theme.markdown.link} />
|
||||
<Show when={props.instructions}>
|
||||
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
|
||||
{(instructions) => <text fg={theme.text.subdued}>{instructions()}</text>}
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
<Show when={props.copy}>
|
||||
<text fg={themeV2.text.default}>
|
||||
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
|
||||
<text fg={theme.text.default}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap"
|
||||
import { pipe, sortBy } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { McpServer } from "@opencode-ai/client"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
@@ -20,12 +20,12 @@ function statusError(status: McpServer["status"]) {
|
||||
}
|
||||
|
||||
function Status(props: { enabled: boolean; loading: boolean }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
if (props.loading) return <span style={{ fg: themeV2.text.subdued }}>⋯ Loading</span>
|
||||
const theme = useThemes().contextual("elevated")
|
||||
if (props.loading) return <span style={{ fg: theme.text.subdued }}>⋯ Loading</span>
|
||||
if (props.enabled) {
|
||||
return <span style={{ fg: themeV2.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
}
|
||||
return <span style={{ fg: themeV2.text.subdued }}>○ Disabled</span>
|
||||
return <span style={{ fg: theme.text.subdued }}>○ Disabled</span>
|
||||
}
|
||||
|
||||
export function DialogMcp() {
|
||||
@@ -33,7 +33,7 @@ export function DialogMcp() {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<McpServer>()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
@@ -110,7 +110,7 @@ export function DialogMcp() {
|
||||
]}
|
||||
footer={
|
||||
<Show when={focusedError()}>
|
||||
<text fg={themeV2.text.subdued}>enter to view error</text>
|
||||
<text fg={theme.text.subdued}>enter to view error</text>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
@@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
@@ -171,14 +171,14 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
MCP server: {props.server.name}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
|
||||
esc back
|
||||
</text>
|
||||
</box>
|
||||
<text fg={themeV2.text.feedback.error.default}>✗ Failed</text>
|
||||
<text fg={theme.text.feedback.error.default}>✗ Failed</text>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
paddingLeft={2}
|
||||
@@ -198,8 +198,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.subdued}>↑↓ scroll</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={copy}>
|
||||
<text fg={theme.text.subdued}>↑↓ scroll</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={copy}>
|
||||
{copied() ? "✓ copied" : "c copy details"}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useData } from "../context/data"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const sessionData = useData()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -172,18 +172,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
return {
|
||||
title,
|
||||
titleView: isRemoving ? (
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>Deleting {item.location}</span>
|
||||
<span style={{ fg: theme.text.feedback.error.default }}>Deleting {item.location}</span>
|
||||
) : deleting ? (
|
||||
<span style={{ fg: themeV2.text.action.destructive.default }}>
|
||||
<span style={{ fg: theme.text.action.destructive.default }}>
|
||||
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
|
||||
</span>
|
||||
) : suffix ? (
|
||||
<>
|
||||
{visible.slice(0, split)}
|
||||
<span style={{ fg: themeV2.text.subdued }}>{visible.slice(split)}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{visible.slice(split)}</span>
|
||||
</>
|
||||
) : undefined,
|
||||
bg: deleting ? themeV2.background.action.destructive.default : undefined,
|
||||
bg: deleting ? theme.background.action.destructive.default : undefined,
|
||||
value: {
|
||||
type: "directory",
|
||||
directory: item.location,
|
||||
@@ -316,7 +316,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
title="Move session"
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Move session
|
||||
</text>
|
||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||
@@ -329,25 +329,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load project directories
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
</box>
|
||||
) : directories.loading || loadedProject.loading ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>Loading project directories…</text>
|
||||
<text fg={theme.text.subdued}>Loading project directories…</text>
|
||||
</box>
|
||||
) : (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No project directories available</text>
|
||||
<text fg={theme.text.subdued}>No project directories available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No project directories found</text>
|
||||
<text fg={theme.text.subdued}>No project directories found</text>
|
||||
</box>
|
||||
}
|
||||
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { useClient } from "../context/client"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
@@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
const [showPassword, setShowPassword] = createSignal(false)
|
||||
const [passwordHover, setPasswordHover] = createSignal(false)
|
||||
@@ -47,17 +47,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
|
||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||
<box>
|
||||
<text fg={themeV2.text.subdued}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
|
||||
<text fg={theme.text.subdued}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={theme.text.default}>{url}</text>}</For>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={themeV2.text.subdued}>Username</text>
|
||||
<text fg={themeV2.text.default}>{value.username}</text>
|
||||
<text fg={theme.text.subdued}>Username</text>
|
||||
<text fg={theme.text.default}>{value.username}</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={themeV2.text.subdued}>Password</text>
|
||||
<text fg={theme.text.subdued}>Password</text>
|
||||
<text
|
||||
fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
|
||||
fg={passwordHover() ? theme.text.default : theme.text.subdued}
|
||||
wrapMode="word"
|
||||
onMouseOver={() => setPasswordHover(true)}
|
||||
onMouseOut={() => setPasswordHover(false)}
|
||||
@@ -67,7 +67,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
|
||||
</text>
|
||||
</Show>
|
||||
@@ -78,7 +78,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
flexShrink={0}
|
||||
alignItems={horizontal() ? "flex-end" : "center"}
|
||||
>
|
||||
<text fg={themeV2.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
<text fg={theme.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
@@ -87,17 +87,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Pair
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={loadError()}
|
||||
fallback={
|
||||
<Show when={info()} fallback={<text fg={themeV2.text.subdued}>Loading server information…</text>}>
|
||||
<Show when={info()} fallback={<text fg={theme.text.subdued}>Loading server information…</text>}>
|
||||
<Show
|
||||
when={dimensions().height >= 36}
|
||||
fallback={
|
||||
@@ -116,11 +116,11 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
>
|
||||
{(error) => (
|
||||
<box>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load server information
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(error())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Pair to try again.</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
|
||||
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
|
||||
let input: InputRenderable
|
||||
@@ -47,10 +47,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Name project copy
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -61,17 +61,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
||||
}}
|
||||
onSubmit={confirm}
|
||||
placeholder="Project copy name"
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={themeV2.text.formfield.default}
|
||||
focusedTextColor={themeV2.text.formfield.default}
|
||||
cursorColor={themeV2.text.formfield.default}
|
||||
placeholderColor={theme.text.subdued}
|
||||
textColor={theme.text.formfield.default}
|
||||
focusedTextColor={theme.text.formfield.default}
|
||||
cursorColor={theme.text.formfield.default}
|
||||
/>
|
||||
<box paddingBottom={1} flexDirection="row" gap={2}>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>submit</span>
|
||||
</text>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
|
||||
<text fg={theme.text.default}>
|
||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.text.subdued }}>generate one</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { Link } from "../ui/link"
|
||||
import { BgPulse } from "./bg-pulse"
|
||||
@@ -38,9 +38,9 @@ function panelOverlay(color: RGBA) {
|
||||
|
||||
export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const showGoTreatment = () => props.link === GO_URL
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background.default) : undefined)
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -85,26 +85,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
) : null}
|
||||
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default} bg={textBg()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={0}>
|
||||
<text fg={themeV2.text.subdued} bg={textBg()}>
|
||||
<text fg={theme.text.subdued} bg={textBg()}>
|
||||
{props.message}
|
||||
</text>
|
||||
</box>
|
||||
{props.link ? (
|
||||
showGoTreatment() ? (
|
||||
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
|
||||
<Link href={props.link} fg={themeV2.markdown.link} bg={textBg()} wrapMode="none" />
|
||||
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
|
||||
</box>
|
||||
) : (
|
||||
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
|
||||
<Link href={props.link} fg={themeV2.markdown.link} wrapMode="none" />
|
||||
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
|
||||
</box>
|
||||
)
|
||||
) : (
|
||||
@@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "dismiss" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("dismiss")}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "dismiss" ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
bg={selected() === "dismiss" ? undefined : textBg()}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
@@ -132,13 +132,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "action" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("action")}
|
||||
onMouseUp={() => runAction(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "action" ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
fg={selected() === "action" ? theme.text.action.primary.focused : theme.text.default}
|
||||
bg={selected() === "action" ? undefined : textBg()}
|
||||
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
@@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [store, setStore] = createStore({
|
||||
active: "delete" as "delete" | "restore",
|
||||
})
|
||||
@@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Failed to Delete Session
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
Choose how you want to recover this broken workspace session.
|
||||
</text>
|
||||
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||
@@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={item.id === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item.id)
|
||||
void confirm()
|
||||
@@ -94,12 +94,12 @@ export function DialogSessionDeleteFailed(props: {
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
|
||||
>
|
||||
{item.title}
|
||||
</text>
|
||||
<text
|
||||
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="word"
|
||||
>
|
||||
{item.description}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRoute } from "../context/route"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useClient } from "../context/client"
|
||||
import { useLocal } from "../context/local"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
@@ -15,14 +15,18 @@ import { useToast } from "../ui/toast"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||
const themes = useThemes()
|
||||
const theme = themes.contextual("elevated")
|
||||
const mode = themes.mode
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
@@ -77,6 +81,7 @@ export function DialogSessionList() {
|
||||
})
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
if (sessionTabs.enabled()) return
|
||||
const first = shortcuts.get("session.quick_switch.1")
|
||||
const last = shortcuts.get("session.quick_switch.9")
|
||||
if (!first || !last) return
|
||||
@@ -94,7 +99,7 @@ export function DialogSessionList() {
|
||||
.filter((session) => !session.parentID)
|
||||
.map((session) => [session.id, session]),
|
||||
)
|
||||
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
||||
const pinned = sessionTabs.enabled() ? [] : local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
||||
|
||||
@@ -102,20 +107,20 @@ export function DialogSessionList() {
|
||||
const directory = session.location.directory
|
||||
const footer =
|
||||
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
|
||||
const slot = slotByID.get(session.id)
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
|
||||
value: session.id,
|
||||
category,
|
||||
footer,
|
||||
bg: deleting ? themeV2.background.action.destructive.focused : undefined,
|
||||
fg: deleting ? themeV2.text.action.destructive.focused : undefined,
|
||||
bg: deleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: deleting ? theme.text.action.destructive.focused : undefined,
|
||||
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={themeV2.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,12 +148,12 @@ export function DialogSessionList() {
|
||||
}}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No sessions available</text>
|
||||
<text fg={theme.text.subdued}>No sessions available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={searchState().error ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
|
||||
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
|
||||
{searchState().message}
|
||||
</text>
|
||||
</box>
|
||||
@@ -162,7 +167,8 @@ export function DialogSessionList() {
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
|
||||
hidden: sessionTabs.enabled(),
|
||||
onTrigger: (option) => local.session.togglePin(option.value),
|
||||
},
|
||||
{
|
||||
command: "session.delete",
|
||||
|
||||
@@ -15,7 +15,7 @@ export type DialogSkillProps = {
|
||||
export function DialogSkill(props: DialogSkillProps) {
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
dialog.setSize("large")
|
||||
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
@@ -63,29 +63,29 @@ export function DialogSkill(props: DialogSkillProps) {
|
||||
<Switch
|
||||
fallback={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No skills available</text>
|
||||
<text fg={theme.text.subdued}>No skills available</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Match when={showError()}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load skills
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
|
||||
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.text.subdued}>Close and reopen Skills to try again.</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={skills.loading}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>Loading skills…</text>
|
||||
<text fg={theme.text.subdued}>Loading skills…</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued}>No skills found</text>
|
||||
<text fg={theme.text.subdued}>No skills found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
@@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string {
|
||||
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const dialog = useDialog()
|
||||
const stash = usePromptStash()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
@@ -45,8 +45,8 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
title: isDeleting
|
||||
? `Press ${shortcuts.get("stash.delete")} again to confirm`
|
||||
: getStashPreview(entry.prompt.text),
|
||||
bg: isDeleting ? themeV2.background.action.destructive.focused : undefined,
|
||||
fg: isDeleting ? themeV2.text.action.destructive.focused : undefined,
|
||||
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: isDeleting ? theme.text.action.destructive.focused : undefined,
|
||||
value: index,
|
||||
description: getRelativeTime(entry.timestamp),
|
||||
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useData } from "../context/data"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
@@ -8,30 +8,30 @@ export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const data = useData()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
|
||||
const color = (status: string) => {
|
||||
if (status === "connected") return themeV2.text.feedback.success.default
|
||||
if (status === "failed") return themeV2.text.feedback.error.default
|
||||
if (status === "needs_auth") return themeV2.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
|
||||
return themeV2.text.subdued
|
||||
if (status === "connected") return theme.text.feedback.success.default
|
||||
if (status === "failed") return theme.text.feedback.error.default
|
||||
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||
return theme.text.subdued
|
||||
}
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Status
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={themeV2.text.default}>No MCP servers</text>}>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={theme.text.default}>No MCP servers</text>}>
|
||||
<box>
|
||||
<text fg={themeV2.text.default}>
|
||||
<text fg={theme.text.default}>
|
||||
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
|
||||
</text>
|
||||
<For each={mcp()}>
|
||||
@@ -40,9 +40,9 @@ export function DialogStatus() {
|
||||
<text flexShrink={0} style={{ fg: color(item.status.status) }}>
|
||||
•
|
||||
</text>
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
<text fg={theme.text.default} wrapMode="word">
|
||||
<b>{item.name}</b>{" "}
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
<Switch fallback={item.status.status}>
|
||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
export function DialogThemeList() {
|
||||
const theme = useTheme()
|
||||
const options = Object.keys(theme.all())
|
||||
const themes = useThemes()
|
||||
const options = Object.keys(themes.all())
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
.map((value) => ({
|
||||
title: value,
|
||||
@@ -14,10 +14,10 @@ export function DialogThemeList() {
|
||||
const dialog = useDialog()
|
||||
let confirmed = false
|
||||
let ref: DialogSelectRef<string>
|
||||
const initial = theme.selected
|
||||
const initial = themes.selected
|
||||
|
||||
onCleanup(() => {
|
||||
if (!confirmed) theme.set(initial)
|
||||
if (!confirmed) themes.set(initial)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -26,10 +26,10 @@ export function DialogThemeList() {
|
||||
options={options}
|
||||
current={initial}
|
||||
onMove={(opt) => {
|
||||
theme.set(opt.value)
|
||||
themes.set(opt.value)
|
||||
}}
|
||||
onSelect={(opt) => {
|
||||
theme.set(opt.value)
|
||||
themes.set(opt.value)
|
||||
confirmed = true
|
||||
dialog.clear()
|
||||
}}
|
||||
@@ -38,12 +38,12 @@ export function DialogThemeList() {
|
||||
}}
|
||||
onFilter={(query) => {
|
||||
if (query.length === 0) {
|
||||
theme.set(initial)
|
||||
themes.set(initial)
|
||||
return
|
||||
}
|
||||
|
||||
const first = ref.filtered[0]
|
||||
if (first) theme.set(first.value)
|
||||
if (first) themes.set(first.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { VcsFileStatus } from "@opencode-ai/client"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FilePath } from "../ui/file-path"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useConfig } from "../config"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
message?: string
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const overlayTheme = useThemes().contextual("overlay")
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
@@ -72,15 +72,15 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title ?? "File Changes Found"}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{props.message ?? "Do you want to move these changes with the session?"}
|
||||
</text>
|
||||
</box>
|
||||
@@ -118,16 +118,14 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={item === store.active ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
props.onSelect(item)
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
|
||||
{item}
|
||||
</text>
|
||||
<text fg={item === store.active ? theme.text.action.primary.focused : theme.text.subdued}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -5,10 +5,10 @@ import { tint } from "../theme/color"
|
||||
import { logo } from "../logo"
|
||||
|
||||
export function Logo() {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
|
||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
||||
const shadow = tint(themeV2.background.default, fg, 0.25)
|
||||
const shadow = tint(theme.background.default, fg, 0.25)
|
||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
return Array.from(line).map((char) => {
|
||||
if (char === "_") {
|
||||
@@ -52,8 +52,8 @@ export function Logo() {
|
||||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, themeV2.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
|
||||
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
Unknown plugin route: {props.id}/{props.name}
|
||||
</text>
|
||||
<box
|
||||
onMouseUp={props.onHome}
|
||||
backgroundColor={themeV2.background.action.primary.hovered}
|
||||
backgroundColor={theme.background.action.primary.hovered}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={themeV2.text.action.primary.hovered}>go home</text>
|
||||
<text fg={theme.text.action.primary.hovered}>go home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useConfig } from "../../config"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Locale } from "../../util/locale"
|
||||
@@ -57,7 +57,7 @@ export function Autocomplete(props: {
|
||||
const data = useData()
|
||||
const keymap = Keymap.use()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const { themeV2 } = useTheme().contextual("overlay")
|
||||
const theme = useThemes().contextual("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const config = useConfig().data
|
||||
@@ -698,11 +698,11 @@ export function Autocomplete(props: {
|
||||
width={position().width}
|
||||
zIndex={100}
|
||||
{...SplitBorder}
|
||||
borderColor={themeV2.border.default}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
@@ -711,9 +711,7 @@ export function Autocomplete(props: {
|
||||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={emptyError() ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
|
||||
{emptyMessage()}
|
||||
</text>
|
||||
<text fg={emptyError() ? theme.text.feedback.error.default : theme.text.subdued}>{emptyMessage()}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
@@ -721,7 +719,7 @@ export function Autocomplete(props: {
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index === store.selected ? themeV2.background.action.primary.focused : undefined}
|
||||
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
|
||||
flexDirection="row"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
@@ -737,14 +735,14 @@ export function Autocomplete(props: {
|
||||
onMouseUp={() => select()}
|
||||
>
|
||||
<text
|
||||
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
{option().display}
|
||||
</text>
|
||||
<Show when={option().description}>
|
||||
<text
|
||||
fg={index === store.selected ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
>
|
||||
{" " + option().description?.trimStart()}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { registerOpencodeSpinner } from "../register-spinner"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { EmptyBorder, SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
@@ -189,7 +189,8 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2, syntax } = useTheme()
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
@@ -296,8 +297,8 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.disabled) input.cursorColor = themeV2.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = themeV2.text.default
|
||||
if (props.disabled) input.cursorColor = theme.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = theme.text.default
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
@@ -1299,10 +1300,10 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
if (leader()) return themeV2.border.default
|
||||
if (store.mode === "shell") return themeV2.text.action.primary.selected
|
||||
if (leader()) return theme.border.default
|
||||
if (store.mode === "shell") return theme.text.action.primary.selected
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return themeV2.border.default
|
||||
if (!agent) return theme.border.default
|
||||
return local.agent.color(agent.id)
|
||||
})
|
||||
const agentLabel = createMemo(() => {
|
||||
@@ -1324,7 +1325,7 @@ export function Prompt(props: PromptProps) {
|
||||
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
|
||||
animationsEnabled,
|
||||
)
|
||||
const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
|
||||
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
@@ -1344,7 +1345,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.id) : themeV2.border.default
|
||||
const color = agent ? local.agent.color(agent.id) : theme.border.default
|
||||
return {
|
||||
frames: createFrames({
|
||||
color,
|
||||
@@ -1364,7 +1365,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
|
||||
const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset))
|
||||
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1390,9 +1391,9 @@ export function Prompt(props: PromptProps) {
|
||||
<textarea
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
||||
focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
||||
placeholderColor={theme.text.subdued}
|
||||
textColor={leader() ? theme.text.subdued : theme.text.default}
|
||||
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
|
||||
minHeight={1}
|
||||
maxHeight={maxHeight()}
|
||||
onContentChange={() => {
|
||||
@@ -1452,7 +1453,7 @@ export function Prompt(props: PromptProps) {
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorColor = themeV2.text.default
|
||||
input.cursorColor = theme.text.default
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
@@ -1460,7 +1461,7 @@ export function Prompt(props: PromptProps) {
|
||||
r.target?.focus()
|
||||
}}
|
||||
focusedBackgroundColor="transparent"
|
||||
cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
|
||||
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
@@ -1470,24 +1471,24 @@ export function Prompt(props: PromptProps) {
|
||||
<>
|
||||
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
|
||||
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
|
||||
<text fg={fadeColor(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
|
||||
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
|
||||
</Show>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
|
||||
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={fadeColor(leader() ? themeV2.text.subdued : themeV2.text.default, modelMetaAlpha())}
|
||||
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
|
||||
>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text>
|
||||
<span
|
||||
style={{
|
||||
fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
|
||||
fg: fadeColor(theme.text.feedback.warning.default, variantMetaAlpha()),
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
@@ -1541,12 +1542,12 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued}>[⋯]</text>}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<text
|
||||
fg={store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.default}
|
||||
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
@@ -1554,7 +1555,7 @@ export function Prompt(props: PromptProps) {
|
||||
esc{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.subdued,
|
||||
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
@@ -1565,16 +1566,16 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<Spinner color={themeV2.hue.accent[500]}>
|
||||
<Spinner color={theme.hue.accent[500]}>
|
||||
{progress()}
|
||||
<span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<text fg={themeV2.hue.accent[500]} wrapMode="none" truncate>
|
||||
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
|
||||
(new working copy)
|
||||
</text>
|
||||
</box>
|
||||
@@ -1582,7 +1583,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
@@ -1596,7 +1597,7 @@ export function Prompt(props: PromptProps) {
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
|
||||
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
|
||||
>
|
||||
{file()}
|
||||
</text>
|
||||
@@ -1606,40 +1607,40 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Switch>
|
||||
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
|
||||
{(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
|
||||
{(shortcut) => <span style={{ fg: theme.text.default }}>{shortcut()} </span>}
|
||||
</Show>
|
||||
<Show when={subagentStatusLabel()}>
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
||||
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
|
||||
</Show>
|
||||
<Show when={subagentStatusLabel() && shellStatusLabel()}>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
||||
<span style={{ fg: theme.text.subdued }}> · </span>
|
||||
</Show>
|
||||
<Show when={shellStatusLabel()}>
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
||||
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
|
||||
</Show>
|
||||
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
||||
<span style={{ fg: theme.text.subdued }}> · </span>
|
||||
</Show>
|
||||
<Show when={statusItems().length > 0}>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{statusItems().join(" · ")}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
{agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
{agentShortcut()} <span style={{ fg: theme.text.subdued }}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
{paletteShortcut()} <span style={{ fg: theme.text.subdued }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
|
||||
<text fg={theme.text.default} flexShrink={0}>
|
||||
esc <span style={{ fg: theme.text.subdued }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function Reconnecting() {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -21,15 +21,15 @@ export function Reconnecting() {
|
||||
width={48}
|
||||
maxWidth="90%"
|
||||
flexDirection="column"
|
||||
backgroundColor={themeV2.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
>
|
||||
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
|
||||
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
|
||||
<Spinner color={theme.text.default}>Restarting service...</Spinner>
|
||||
<text fg={theme.text.subdued}>Your session will resume automatically.</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
sessionTabComplete,
|
||||
SESSION_TAB_OVERFLOW_WIDTH,
|
||||
type SessionTabUnread,
|
||||
} from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring } from "../ui/animation"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
|
||||
type ContextController = ReturnType<typeof useSessionTabs>
|
||||
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
|
||||
unread: SessionTabUnread | undefined
|
||||
}
|
||||
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
|
||||
status(sessionID: string): SessionTabsStatus
|
||||
}
|
||||
|
||||
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25)
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const activeID = createMemo(tabs.current)
|
||||
const items = tabs.tabs
|
||||
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||
)
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
layout().tabs.map((tab) => {
|
||||
const status = tabs.status(tab.sessionID)
|
||||
return [
|
||||
tab.sessionID,
|
||||
{
|
||||
...status,
|
||||
complete: sessionTabComplete(status.unread, status.busy),
|
||||
},
|
||||
] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
const targets = createMemo(() => ({
|
||||
widths: layout().widths,
|
||||
selections: layout().tabs.map((tab) => Number(tab.sessionID === activeID())),
|
||||
activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)),
|
||||
}))
|
||||
const motion = createAnimatable(targets(), {
|
||||
enabled: animations,
|
||||
transition: spring({ visualDuration: 0.1 }),
|
||||
})
|
||||
const identity = createMemo(() =>
|
||||
layout()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
.join(":"),
|
||||
)
|
||||
let signature = ""
|
||||
let total = 0
|
||||
|
||||
createEffect(() => {
|
||||
const next = targets()
|
||||
const nextSignature = identity()
|
||||
const reset = (signature && signature !== nextSignature) || (total && total !== layout().total)
|
||||
signature = nextSignature
|
||||
total = layout().total
|
||||
if (reset) return motion.jump(next)
|
||||
motion.animate(next)
|
||||
})
|
||||
|
||||
const visuals = createMemo(() => {
|
||||
const current = signature === identity() && total === layout().total ? motion.value() : targets()
|
||||
const widths = current.widths.map((width) => Math.max(1, Math.round(width)))
|
||||
const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID())
|
||||
if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0)
|
||||
return new Map(
|
||||
layout().tabs.map((tab, index) => [
|
||||
tab.sessionID,
|
||||
{
|
||||
width: widths[index]!,
|
||||
selection: current.selections[index] ?? Number(tab.sessionID === activeID()),
|
||||
activity: current.activities[index] ?? Number(statuses().get(tab.sessionID)!.complete),
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
zIndex={1}
|
||||
renderAfter={function (buffer) {
|
||||
const x = Math.max(0, this.screenX)
|
||||
const y = this.screenY + this.height
|
||||
const width = Math.min(this.width, buffer.width - x)
|
||||
if (y < 0 || y >= buffer.height || width <= 0) return
|
||||
buffer.fillRect(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
1,
|
||||
RGBA.fromValues(
|
||||
theme.background.default.r,
|
||||
theme.background.default.g,
|
||||
theme.background.default.b,
|
||||
mode() === "light" ? 0.14 : 0.28,
|
||||
),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Show when={layout().before > 0}>
|
||||
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
|
||||
‹{layout().before}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={layout().tabs}>
|
||||
{(tab) => {
|
||||
const selected = () => activeID() === tab.sessionID
|
||||
const status = () => statuses().get(tab.sessionID)!
|
||||
const width = () => visuals().get(tab.sessionID)?.width ?? 1
|
||||
const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected())
|
||||
const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete)
|
||||
const background = () => {
|
||||
const base =
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? theme.background.action.primary.hovered
|
||||
: theme.background.default
|
||||
return tint(base, theme.raise(theme.background.surface.offset), selection())
|
||||
}
|
||||
const pulseBackground = () => background()
|
||||
const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45)
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const availableTitleWidth = () => Math.max(1, width() - 3)
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4)
|
||||
const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth()))
|
||||
const titleFades = createMemo(
|
||||
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > fadeWidth(),
|
||||
)
|
||||
const foreground = () => {
|
||||
if (hovered() === tab.sessionID) return theme.text.default
|
||||
return tint(theme.text.subdued, theme.text.default, selection())
|
||||
}
|
||||
const numberColor = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
const base =
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), activeNumber(), selection())
|
||||
return tint(base, accent(), activity())
|
||||
}
|
||||
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
|
||||
return (
|
||||
<box
|
||||
width={width()}
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseUp={() => tabs.select(tab.sessionID)}
|
||||
>
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
active={status().busy}
|
||||
complete={status().complete}
|
||||
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
|
||||
color={pulseColor()}
|
||||
glowColor={accent()}
|
||||
completionColor={accent()}
|
||||
backgroundColor={pulseBackground()}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={1}> </text>
|
||||
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
|
||||
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
|
||||
</text>
|
||||
<Show
|
||||
when={titleFades()}
|
||||
fallback={
|
||||
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||
{visibleTitle()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
|
||||
<For each={fadedTitleParts()}>
|
||||
{(character, index) => (
|
||||
<span
|
||||
style={{
|
||||
fg: tint(
|
||||
foreground(),
|
||||
pulseBackground(),
|
||||
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</text>
|
||||
</Show>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={closeColor()}
|
||||
onMouseUp={(event) => {
|
||||
event.stopPropagation()
|
||||
tabs.close(tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={layout().after > 0}>
|
||||
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
|
||||
{layout().after}›
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,9 @@ export { SPINNER_FRAMES } from "./spinner-frames"
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const theme = useTheme()
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? themeV2.text.subdued
|
||||
const color = () => props.color ?? theme.text.subdued
|
||||
return (
|
||||
<Show
|
||||
when={config.animations ?? true}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function StartupLoading(props: { ready: () => boolean }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const theme = useThemes().contextual("elevated")
|
||||
const [show, setShow] = createSignal(false)
|
||||
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
|
||||
let wait: NodeJS.Timeout | undefined
|
||||
@@ -54,8 +54,8 @@ export function StartupLoading(props: { ready: () => boolean }) {
|
||||
return (
|
||||
<Show when={show()}>
|
||||
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
||||
<box backgroundColor={themeV2.background.default} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={themeV2.text.subdued}>{text()}</Spinner>
|
||||
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={theme.text.subdued}>{text()}</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
|
||||
import { extend } from "@opentui/solid"
|
||||
|
||||
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
enabled?: boolean
|
||||
active?: boolean
|
||||
complete?: boolean
|
||||
glow?: boolean
|
||||
color?: RGBA
|
||||
glowColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
backgroundColor?: RGBA
|
||||
}
|
||||
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
|
||||
const RUN_DURATION = 2_800
|
||||
const RUN_HEAD = 4
|
||||
const RUN_TAIL = 18
|
||||
const RUN_FADE_OUT = 500
|
||||
const COMPLETION_DURATION = 900
|
||||
const COMPLETION_ATTACK = 0.16
|
||||
const GLOW_TAIL = 12
|
||||
const GLOW_OPACITY = 0.16
|
||||
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
|
||||
const intensityAt = (index: number, front: number, head: number, tail: number) => {
|
||||
const distance = front - index
|
||||
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
|
||||
}
|
||||
const coast = (value: number) => {
|
||||
const ramp = 0.2
|
||||
if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp))
|
||||
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
|
||||
return (value - ramp / 2) / (1 - ramp)
|
||||
}
|
||||
export const completionPulseOpacity = (progress: number) =>
|
||||
progress < COMPLETION_ATTACK
|
||||
? smootherstep(clamp(progress / COMPLETION_ATTACK))
|
||||
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
|
||||
export const unreadGlowIntensity = (index: number, width: number) => {
|
||||
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
|
||||
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||
}
|
||||
export function blendTabPulseColor(
|
||||
output: RGBA,
|
||||
background: RGBA,
|
||||
glowColor: RGBA,
|
||||
runningColor: RGBA,
|
||||
completionColor: RGBA,
|
||||
glow: number,
|
||||
running: number,
|
||||
completion: number,
|
||||
) {
|
||||
output.r = background.r + (glowColor.r - background.r) * glow
|
||||
output.g = background.g + (glowColor.g - background.g) * glow
|
||||
output.b = background.b + (glowColor.b - background.b) * glow
|
||||
output.r += (runningColor.r - output.r) * running
|
||||
output.g += (runningColor.g - output.g) * running
|
||||
output.b += (runningColor.b - output.b) * running
|
||||
output.r += (completionColor.r - output.r) * completion
|
||||
output.g += (completionColor.g - output.g) * completion
|
||||
output.b += (completionColor.b - output.b) * completion
|
||||
}
|
||||
class TabPulseRenderable extends Renderable {
|
||||
private _enabled: boolean
|
||||
private _active: boolean
|
||||
private _complete: boolean
|
||||
private _glow: boolean
|
||||
private _color: RGBA
|
||||
private _glowColor: RGBA
|
||||
private _completionColor: RGBA
|
||||
private _backgroundColor: RGBA
|
||||
private clock = 0
|
||||
private fadeClock: number | undefined
|
||||
private completionClock: number | undefined
|
||||
private completionPending = false
|
||||
private renderColor = RGBA.fromInts(0, 0, 0)
|
||||
|
||||
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
|
||||
const enabled = options.enabled ?? true
|
||||
const active = options.active ?? false
|
||||
super(ctx, { ...options, height: 1, live: enabled && active })
|
||||
this._enabled = enabled
|
||||
this._active = active
|
||||
this._complete = options.complete ?? false
|
||||
this._glow = options.glow ?? false
|
||||
this._color = options.color ?? RGBA.defaultForeground()
|
||||
this._glowColor = options.glowColor ?? this._color
|
||||
this._completionColor = options.completionColor ?? this._color
|
||||
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
if (value === this._enabled) return
|
||||
this._enabled = value
|
||||
if (!value) {
|
||||
this.fadeClock = undefined
|
||||
this.completionClock = undefined
|
||||
this.completionPending = false
|
||||
this.live = false
|
||||
} else if (this._active) {
|
||||
this.live = true
|
||||
}
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set active(value: boolean) {
|
||||
if (value === this._active) return
|
||||
this._active = value
|
||||
if (!this._enabled) return
|
||||
if (value) {
|
||||
this.fadeClock = undefined
|
||||
this.completionClock = undefined
|
||||
this.completionPending = false
|
||||
this.live = true
|
||||
} else {
|
||||
this.fadeClock = 0
|
||||
this.completionPending = true
|
||||
this.live = true
|
||||
}
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set complete(value: boolean) {
|
||||
if (value === this._complete) return
|
||||
this._complete = value
|
||||
if (!value) {
|
||||
this.completionClock = undefined
|
||||
this.completionPending = false
|
||||
}
|
||||
if (value && this.completionPending) {
|
||||
this.completionClock = 0
|
||||
this.completionPending = false
|
||||
this.live = this._enabled
|
||||
}
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set glow(value: boolean) {
|
||||
if (value === this._glow) return
|
||||
this._glow = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set color(value: RGBA) {
|
||||
if (value.equals(this._color)) return
|
||||
this._color = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set glowColor(value: RGBA) {
|
||||
if (value.equals(this._glowColor)) return
|
||||
this._glowColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set completionColor(value: RGBA) {
|
||||
if (value.equals(this._completionColor)) return
|
||||
this._completionColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set backgroundColor(value: RGBA) {
|
||||
if (value.equals(this._backgroundColor)) return
|
||||
this._backgroundColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
protected override onUpdate(deltaTime: number): void {
|
||||
if (!this._enabled) return
|
||||
if (this._active || this.fadeClock !== undefined) this.clock += deltaTime
|
||||
if (this.fadeClock !== undefined) {
|
||||
this.fadeClock += deltaTime
|
||||
if (this.fadeClock >= RUN_FADE_OUT) this.fadeClock = undefined
|
||||
}
|
||||
if (this.completionPending) {
|
||||
if (this._complete) {
|
||||
this.completionClock = 0
|
||||
this.completionPending = false
|
||||
} else if (this.fadeClock === undefined) {
|
||||
this.completionPending = false
|
||||
}
|
||||
}
|
||||
if (this.completionClock !== undefined) {
|
||||
this.completionClock += deltaTime
|
||||
if (this.completionClock >= COMPLETION_DURATION) this.completionClock = undefined
|
||||
}
|
||||
this.live = this._active || this.fadeClock !== undefined || this.completionClock !== undefined
|
||||
}
|
||||
|
||||
protected override renderSelf(buffer: OptimizedBuffer): void {
|
||||
if (!this.visible || this.isDestroyed || this.width <= 0) return
|
||||
const runningOpacity = !this._enabled
|
||||
? 0
|
||||
: this._active
|
||||
? 1
|
||||
: this.fadeClock === undefined
|
||||
? 0
|
||||
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
|
||||
const completionOpacity =
|
||||
!this._enabled || this.completionClock === undefined
|
||||
? 0
|
||||
: completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
|
||||
if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return
|
||||
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
|
||||
const start = -RUN_HEAD
|
||||
const end = this.width - 1 + RUN_TAIL
|
||||
const front = start + coast(progress) * (end - start)
|
||||
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
|
||||
for (let index = 0; index < this.width; index++) {
|
||||
const intensity = Math.max(
|
||||
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
|
||||
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
|
||||
)
|
||||
const glow = this._glow ? unreadGlowIntensity(index, this.width) * GLOW_OPACITY : 0
|
||||
const running = intensity * 0.14 * runningOpacity
|
||||
const completion = completionOpacity * 0.18
|
||||
blendTabPulseColor(
|
||||
this.renderColor,
|
||||
this._backgroundColor,
|
||||
this._glowColor,
|
||||
this._color,
|
||||
this._completionColor,
|
||||
glow,
|
||||
running,
|
||||
completion,
|
||||
)
|
||||
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
tab_pulse: typeof TabPulseRenderable
|
||||
}
|
||||
}
|
||||
|
||||
extend({ tab_pulse: TabPulseRenderable })
|
||||
|
||||
export function TabPulse(props: {
|
||||
enabled?: boolean
|
||||
active: boolean
|
||||
complete?: boolean
|
||||
glow?: boolean
|
||||
color: RGBA
|
||||
glowColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
backgroundColor: RGBA
|
||||
}) {
|
||||
return (
|
||||
<tab_pulse
|
||||
position="absolute"
|
||||
zIndex={0}
|
||||
width="100%"
|
||||
enabled={props.enabled ?? true}
|
||||
active={props.active}
|
||||
complete={props.complete ?? false}
|
||||
glow={props.glow ?? false}
|
||||
color={props.color}
|
||||
glowColor={props.glowColor ?? props.color}
|
||||
completionColor={props.completionColor ?? props.color}
|
||||
backgroundColor={props.backgroundColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function ThemeErrorToast() {
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const toast = useToast()
|
||||
|
||||
onCleanup(
|
||||
theme.onError(({ name, error }) =>
|
||||
themes.onError(({ name, error }) =>
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: `Failed to load theme: ${name}`,
|
||||
|
||||
@@ -122,6 +122,13 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session transcript presentation settings" }),
|
||||
tabs: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session tab settings" }),
|
||||
mini: Schema.optional(
|
||||
Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
|
||||
@@ -87,6 +87,13 @@ export const Definitions = {
|
||||
session_move: keybind("none", "Move session"),
|
||||
session_new: keybind("<leader>n", "Create a new session"),
|
||||
session_list: keybind("<leader>l", "List all sessions"),
|
||||
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
|
||||
session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
|
||||
session_tab_history_back: keybind("ctrl+o", "Go back in session tab history"),
|
||||
session_tab_history_forward: keybind("ctrl+i", "Go forward in session tab history"),
|
||||
session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
|
||||
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
|
||||
session_tab_close: keybind("<leader>w", "Close current session tab"),
|
||||
session_timeline: keybind("<leader>g", "Show session timeline"),
|
||||
session_fork: keybind("none", "Fork session from message"),
|
||||
session_rename: keybind("ctrl+r", "Rename session"),
|
||||
@@ -97,7 +104,7 @@ export const Definitions = {
|
||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
|
||||
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||
session_child_cycle: keybind("right", "Go to next child session"),
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
session_parent: keybind("up", "Go to parent session"),
|
||||
@@ -111,6 +118,15 @@ export const Definitions = {
|
||||
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
|
||||
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
|
||||
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
|
||||
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
|
||||
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
|
||||
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
|
||||
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
|
||||
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
|
||||
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
|
||||
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
|
||||
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
|
||||
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
|
||||
|
||||
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||
@@ -214,24 +230,9 @@ export const Definitions = {
|
||||
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
||||
"plugins.toggle": keybind("space", "Toggle plugin"),
|
||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
|
||||
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
|
||||
|
||||
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
|
||||
terminal_title_toggle: keybind("none", "Toggle terminal title"),
|
||||
plugin_manager: keybind("none", "Open plugin manager dialog"),
|
||||
plugin_install: keybind("none", "Install plugin"),
|
||||
|
||||
which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"),
|
||||
which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"),
|
||||
which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
|
||||
which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
|
||||
which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
|
||||
which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
|
||||
which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
|
||||
which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"),
|
||||
which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"),
|
||||
which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"),
|
||||
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
|
||||
} satisfies Record<string, Definition>
|
||||
|
||||
type KeybindName = keyof typeof Definitions
|
||||
@@ -289,6 +290,13 @@ export const CommandMap = {
|
||||
session_move: "session.move",
|
||||
session_new: "session.new",
|
||||
session_list: "session.list",
|
||||
session_tab_next: "session.tab.next",
|
||||
session_tab_previous: "session.tab.previous",
|
||||
session_tab_history_back: "session.tab.history.back",
|
||||
session_tab_history_forward: "session.tab.history.forward",
|
||||
session_tab_next_unread: "session.tab.next_unread",
|
||||
session_tab_previous_unread: "session.tab.previous_unread",
|
||||
session_tab_close: "session.tab.close",
|
||||
session_timeline: "session.timeline",
|
||||
session_fork: "session.fork",
|
||||
session_rename: "session.rename",
|
||||
@@ -313,6 +321,15 @@ export const CommandMap = {
|
||||
session_quick_switch_7: "session.quick_switch.7",
|
||||
session_quick_switch_8: "session.quick_switch.8",
|
||||
session_quick_switch_9: "session.quick_switch.9",
|
||||
session_tab_select_1: "session.tab.select.1",
|
||||
session_tab_select_2: "session.tab.select.2",
|
||||
session_tab_select_3: "session.tab.select.3",
|
||||
session_tab_select_4: "session.tab.select.4",
|
||||
session_tab_select_5: "session.tab.select.5",
|
||||
session_tab_select_6: "session.tab.select.6",
|
||||
session_tab_select_7: "session.tab.select.7",
|
||||
session_tab_select_8: "session.tab.select.8",
|
||||
session_tab_select_9: "session.tab.select.9",
|
||||
stash_delete: "stash.delete",
|
||||
model_provider_list: "model.dialog.provider",
|
||||
model_favorite_toggle: "model.dialog.favorite",
|
||||
@@ -393,19 +410,6 @@ export const CommandMap = {
|
||||
history_next: "prompt.history.next",
|
||||
terminal_suspend: "terminal.suspend",
|
||||
terminal_title_toggle: "terminal.title.toggle",
|
||||
plugin_manager: "plugins.list",
|
||||
plugin_install: "plugins.install",
|
||||
which_key_toggle: "which-key.toggle",
|
||||
which_key_layout_toggle: "which-key.layout.toggle",
|
||||
which_key_pending_toggle: "which-key.pending.toggle",
|
||||
which_key_group_previous: "which-key.group.previous",
|
||||
which_key_group_next: "which-key.group.next",
|
||||
which_key_scroll_up: "which-key.scroll.up",
|
||||
which_key_scroll_down: "which-key.scroll.down",
|
||||
which_key_page_up: "which-key.page.up",
|
||||
which_key_page_down: "which-key.page.down",
|
||||
which_key_home: "which-key.home",
|
||||
which_key_end: "which-key.end",
|
||||
} satisfies BindingCommandMap
|
||||
const CommandDescriptions = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ModelPreference,
|
||||
type ModelPreferenceModel,
|
||||
} from "../model-preference"
|
||||
import { useTheme } from "./theme"
|
||||
import { useTheme, useThemes } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
import { useData } from "./data"
|
||||
@@ -50,7 +50,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2, mode } = useTheme()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const args = useArgs()
|
||||
@@ -82,7 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const colors = createMemo(() => {
|
||||
const step = mode() === "light" ? 800 : 200
|
||||
return dedupeWith(
|
||||
themeV2.categorical.map((scale) => scale[step]),
|
||||
theme.categorical.map((scale) => scale[step]),
|
||||
(first, second) => first.equals(second),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
export type SessionTab = {
|
||||
sessionID: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
export type SessionTabHistory = {
|
||||
entries: readonly string[]
|
||||
index: number
|
||||
}
|
||||
|
||||
export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: boolean) {
|
||||
return unread === "activity" && !busy
|
||||
}
|
||||
|
||||
export const SESSION_TAB_WIDTH = 22
|
||||
export const SESSION_TAB_MAX_WIDTH = 32
|
||||
export const SESSION_TAB_MIN_WIDTH = 8
|
||||
export const SESSION_TAB_OVERFLOW_WIDTH = 3
|
||||
|
||||
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
|
||||
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
|
||||
if (index === -1) return [...tabs, tab]
|
||||
if (!tab.title || tabs[index]?.title === tab.title) return tabs
|
||||
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
|
||||
}
|
||||
|
||||
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
|
||||
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
||||
if (index === -1) return { tabs: [...tabs], next: undefined }
|
||||
return {
|
||||
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
|
||||
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
|
||||
}
|
||||
}
|
||||
|
||||
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
|
||||
if (tabs.length === 0) return
|
||||
const index = tabs.findIndex((tab) => tab.sessionID === active)
|
||||
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
|
||||
return tabs[(start + direction + tabs.length) % tabs.length]
|
||||
}
|
||||
|
||||
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
|
||||
if (history.entries[history.index] === sessionID) return history
|
||||
const entries = [...history.entries.slice(0, history.index + 1), sessionID]
|
||||
return { entries, index: entries.length - 1 }
|
||||
}
|
||||
|
||||
export function moveSessionTabHistory(
|
||||
history: SessionTabHistory,
|
||||
tabs: readonly SessionTab[],
|
||||
active: string | undefined,
|
||||
direction: 1 | -1,
|
||||
) {
|
||||
if (!active) {
|
||||
const sessionID = history.entries[history.index]
|
||||
return tabs.some((tab) => tab.sessionID === sessionID) ? { history, sessionID } : { history, sessionID: undefined }
|
||||
}
|
||||
const entries = history.entries.map((sessionID, index) => ({ sessionID, index }))
|
||||
const candidates = direction === -1 ? entries.slice(0, history.index).reverse() : entries.slice(history.index + 1)
|
||||
const target = candidates.find(
|
||||
(entry) => entry.sessionID !== active && tabs.some((tab) => tab.sessionID === entry.sessionID),
|
||||
)
|
||||
if (!target) return { history, sessionID: undefined }
|
||||
return { history: { ...history, index: target.index }, sessionID: target.sessionID }
|
||||
}
|
||||
|
||||
export function adaptiveSessionTabLayout(
|
||||
tabs: readonly SessionTab[],
|
||||
active: string | undefined,
|
||||
available: number,
|
||||
previousStart = 0,
|
||||
) {
|
||||
if (tabs.length === 0) return { tabs: [], widths: [], before: 0, after: 0, start: 0, total: 0 }
|
||||
|
||||
const activeIndex = tabs.findIndex((tab) => tab.sessionID === active)
|
||||
const fit = (width: number) =>
|
||||
Math.min(
|
||||
tabs.length,
|
||||
Math.max(
|
||||
1,
|
||||
activeIndex === -1
|
||||
? Math.floor(Math.max(0, width) / SESSION_TAB_MIN_WIDTH)
|
||||
: 1 + Math.floor((Math.max(0, width) - SESSION_TAB_WIDTH) / SESSION_TAB_MIN_WIDTH),
|
||||
),
|
||||
)
|
||||
const solve = (count: number, start: number, attempts: number): { count: number; start: number } => {
|
||||
const boundedStart = Math.min(Math.max(0, start), tabs.length - count)
|
||||
const nextStart = Math.min(
|
||||
Math.max(
|
||||
0,
|
||||
activeIndex === -1
|
||||
? boundedStart
|
||||
: activeIndex < boundedStart
|
||||
? activeIndex
|
||||
: activeIndex >= boundedStart + count
|
||||
? activeIndex - count + 1
|
||||
: boundedStart,
|
||||
),
|
||||
tabs.length - count,
|
||||
)
|
||||
const markers =
|
||||
(nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) +
|
||||
(nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0)
|
||||
const nextCount = fit(available - markers)
|
||||
if (nextCount === count || attempts === 0) return { count, start: nextStart }
|
||||
return solve(nextCount, nextStart, attempts - 1)
|
||||
}
|
||||
const solved = solve(fit(available), previousStart, 3)
|
||||
const visible = tabs.slice(solved.start, solved.start + solved.count)
|
||||
const before = solved.start
|
||||
const after = tabs.length - solved.start - solved.count
|
||||
const contentWidth = Math.max(
|
||||
1,
|
||||
available - (before > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) - (after > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0),
|
||||
)
|
||||
const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length
|
||||
const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth
|
||||
if (roomy || activeIndex === -1) {
|
||||
const width = Math.floor(total / visible.length)
|
||||
const remainder = total - width * visible.length
|
||||
return {
|
||||
tabs: visible,
|
||||
widths: visible.map((_, index) => width + Number(index < remainder)),
|
||||
before,
|
||||
after,
|
||||
start: solved.start,
|
||||
total,
|
||||
}
|
||||
}
|
||||
const inactiveWidth =
|
||||
visible.length === 1
|
||||
? 0
|
||||
: Math.min(
|
||||
SESSION_TAB_WIDTH,
|
||||
Math.max(
|
||||
SESSION_TAB_MIN_WIDTH,
|
||||
Math.floor((total - Math.min(SESSION_TAB_WIDTH, total)) / (visible.length - 1)),
|
||||
),
|
||||
)
|
||||
const activeWidth = visible.length === 1 ? total : total - inactiveWidth * (visible.length - 1)
|
||||
|
||||
return {
|
||||
tabs: visible,
|
||||
widths: visible.map((tab) => (tab.sessionID === active ? activeWidth : inactiveWidth)),
|
||||
before,
|
||||
after,
|
||||
start: solved.start,
|
||||
total,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { batch, createEffect, onCleanup, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import path from "path"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useData } from "./data"
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import { useConfig } from "../config"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { isRecord } from "../util/record"
|
||||
import {
|
||||
closeSessionTab,
|
||||
cycleSessionTab,
|
||||
moveSessionTabHistory,
|
||||
openSessionTab,
|
||||
recordSessionTabHistory,
|
||||
type SessionTab,
|
||||
type SessionTabHistory,
|
||||
type SessionTabUnread,
|
||||
} from "./session-tabs-model"
|
||||
|
||||
type PersistedState = {
|
||||
tabs: SessionTab[]
|
||||
unread: Record<string, SessionTabUnread>
|
||||
}
|
||||
|
||||
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
|
||||
name: "SessionTabs",
|
||||
init: () => {
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const event = useEvent()
|
||||
const config = useConfig().data
|
||||
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
|
||||
const enabled = () => config.tabs?.enabled ?? false
|
||||
const state: {
|
||||
pending: boolean
|
||||
saving: boolean
|
||||
snapshot: string
|
||||
value?: PersistedState
|
||||
} = { pending: false, saving: false, snapshot: "" }
|
||||
const [store, setStore] = createStore<PersistedState & { ready: boolean }>({
|
||||
ready: false,
|
||||
tabs: [],
|
||||
unread: {},
|
||||
})
|
||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
|
||||
const root = (sessionID: string) => data.session.root(sessionID)
|
||||
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||
const status = (sessionID: string) => {
|
||||
const session = root(sessionID)
|
||||
const members = data.session.family(session)
|
||||
const family = members.length > 0 ? members : [session]
|
||||
return {
|
||||
unread: store.unread[session],
|
||||
attention: family.some(
|
||||
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||
),
|
||||
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!store.ready) {
|
||||
state.pending = true
|
||||
return
|
||||
}
|
||||
const value = { tabs: [...store.tabs], unread: { ...store.unread } }
|
||||
const snapshot = JSON.stringify(value)
|
||||
if (snapshot === state.snapshot && !state.saving) return
|
||||
state.value = value
|
||||
state.pending = true
|
||||
flush()
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (state.saving || !state.pending || !state.value) return
|
||||
const value = state.value
|
||||
const snapshot = JSON.stringify(value)
|
||||
state.pending = false
|
||||
if (snapshot === state.snapshot) return
|
||||
state.saving = true
|
||||
void writeJsonAtomic(filePath, value)
|
||||
.then(() => {
|
||||
state.snapshot = snapshot
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
state.saving = false
|
||||
flush()
|
||||
})
|
||||
}
|
||||
|
||||
function open(sessionID: string) {
|
||||
const session = root(sessionID)
|
||||
const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title })
|
||||
if (next === store.tabs) return { sessionID: session, changed: false }
|
||||
setStore("tabs", reconcile(next))
|
||||
return { sessionID: session, changed: true }
|
||||
}
|
||||
|
||||
function clearUnread(sessionID: string) {
|
||||
const session = root(sessionID)
|
||||
if (!store.unread[session]) return false
|
||||
setStore(
|
||||
"unread",
|
||||
produce((draft) => {
|
||||
delete draft[session]
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||
if (!enabled()) return
|
||||
const session = root(sessionID)
|
||||
if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return
|
||||
if (store.unread[session] === unread) return
|
||||
setStore("unread", session, unread)
|
||||
save()
|
||||
}
|
||||
|
||||
readJson<unknown>(filePath)
|
||||
.then((value) => {
|
||||
if (!isRecord(value)) return
|
||||
const persisted = value
|
||||
if (Array.isArray(persisted.tabs))
|
||||
setStore(
|
||||
"tabs",
|
||||
persisted.tabs.flatMap((tab) => {
|
||||
if (!isRecord(tab) || typeof tab.sessionID !== "string") return []
|
||||
if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return []
|
||||
return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }]
|
||||
}),
|
||||
)
|
||||
if (persisted.unread && typeof persisted.unread === "object")
|
||||
setStore(
|
||||
"unread",
|
||||
Object.fromEntries(
|
||||
Object.entries(persisted.unread).filter(
|
||||
(entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error",
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setStore("ready", true)
|
||||
if (state.pending) save()
|
||||
else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
const routeSessionID = route.data.sessionID
|
||||
batch(() => {
|
||||
const opened = open(routeSessionID)
|
||||
history = recordSessionTabHistory(history, opened.sessionID)
|
||||
const changed = clearUnread(opened.sessionID)
|
||||
if (opened.changed || changed) untrack(save)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled() || !store.ready) return
|
||||
const next = store.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
|
||||
}, [])
|
||||
const unread = Object.entries(store.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||
return result
|
||||
}, {})
|
||||
if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return
|
||||
batch(() => {
|
||||
setStore("tabs", reconcile(next))
|
||||
setStore("unread", reconcile(unread))
|
||||
})
|
||||
save()
|
||||
})
|
||||
|
||||
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
|
||||
onCleanup(
|
||||
event.on("session.error", (evt) => {
|
||||
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
|
||||
}),
|
||||
)
|
||||
onCleanup(
|
||||
event.on("session.deleted", (evt) => {
|
||||
remove(evt.data.sessionID, enabled())
|
||||
}),
|
||||
)
|
||||
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
const closed = closeSessionTab(store.tabs, target)
|
||||
if (closed.tabs.length === store.tabs.length) return
|
||||
const selected = navigate && current() === target
|
||||
const previous = selected
|
||||
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
||||
: { history, sessionID: undefined }
|
||||
const next = previous.sessionID ?? closed.next
|
||||
history = previous.history
|
||||
batch(() => {
|
||||
setStore("tabs", reconcile(closed.tabs))
|
||||
clearUnread(target)
|
||||
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
|
||||
})
|
||||
save()
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
tabs() {
|
||||
return store.tabs
|
||||
},
|
||||
current,
|
||||
status,
|
||||
select(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
route.navigate({ type: "session", sessionID: root(sessionID) })
|
||||
},
|
||||
close(sessionID?: string) {
|
||||
if (!enabled()) return
|
||||
const target = sessionID ? root(sessionID) : current()
|
||||
if (!target) {
|
||||
const previous = store.tabs.at(-1)
|
||||
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
|
||||
return
|
||||
}
|
||||
remove(target, true)
|
||||
},
|
||||
cycle(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(store.tabs, current(), direction)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
cycleUnread(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(
|
||||
store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||
current(),
|
||||
direction,
|
||||
)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
history(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const next = moveSessionTabHistory(history, store.tabs, current(), direction)
|
||||
history = next.history
|
||||
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
|
||||
},
|
||||
selectIndex(index: number) {
|
||||
if (!enabled()) return
|
||||
const tab = store.tabs[index]
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui"
|
||||
import {
|
||||
DEFAULT_THEMES,
|
||||
addTheme,
|
||||
@@ -14,12 +15,9 @@ import {
|
||||
type Theme,
|
||||
type ThemeDocumentSource,
|
||||
} from "../theme"
|
||||
import { generateSyntax } from "../theme/v2/syntax"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes, themeDirectories } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
||||
import { resolveThemeDocument } from "../theme/v2/resolve"
|
||||
import { themeModes } from "../theme/v2/select"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -98,13 +96,13 @@ type State = {
|
||||
}
|
||||
|
||||
type ContextName = "elevated" | "overlay"
|
||||
type ThemeService = {
|
||||
themeV2: ComponentTheme
|
||||
contextual(context: ContextName): ThemeService
|
||||
type Themes = {
|
||||
current: ComponentTheme
|
||||
contextual(context: ContextName): ComponentTheme
|
||||
readonly selected: string
|
||||
all: typeof allThemes
|
||||
has: typeof hasTheme
|
||||
syntax: Accessor<SyntaxStyle>
|
||||
currentSyntax: Accessor<SyntaxStyle>
|
||||
mode: Accessor<"dark" | "light">
|
||||
modes: Accessor<readonly ("dark" | "light")[]>
|
||||
supports(mode: "dark" | "light"): boolean
|
||||
@@ -310,7 +308,7 @@ const themeContext = createSimpleContext({
|
||||
const valuesV2 = () => selected().theme
|
||||
valuesV2()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||
const current = createComponentTheme(valuesV2, mode)
|
||||
const contextsV2 = {
|
||||
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
|
||||
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
|
||||
@@ -318,19 +316,19 @@ const themeContext = createSimpleContext({
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
||||
|
||||
const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
function contextual(context: ContextName) {
|
||||
return contextualServices[context]
|
||||
return contextsV2[context]
|
||||
}
|
||||
const service: ThemeService = {
|
||||
themeV2,
|
||||
const service: Themes = {
|
||||
current,
|
||||
currentSyntax,
|
||||
contextual,
|
||||
get selected() {
|
||||
return store.active
|
||||
},
|
||||
all: allThemes,
|
||||
has: hasTheme,
|
||||
syntax,
|
||||
mode,
|
||||
modes,
|
||||
supports: (requested) => modes().includes(requested),
|
||||
@@ -357,21 +355,28 @@ const themeContext = createSimpleContext({
|
||||
return store.ready
|
||||
},
|
||||
}
|
||||
const contextualServices = {
|
||||
elevated: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.elevated }),
|
||||
overlay: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.overlay }),
|
||||
return {
|
||||
current,
|
||||
themes: service,
|
||||
get ready() {
|
||||
return service.ready
|
||||
},
|
||||
}
|
||||
return service
|
||||
},
|
||||
})
|
||||
|
||||
export const useTheme = themeContext.use
|
||||
export function useThemes() {
|
||||
return themeContext.use().themes
|
||||
}
|
||||
export function useTheme() {
|
||||
return themeContext.use().current
|
||||
}
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
return (
|
||||
<themeContext.context.Provider value={theme.contextual(props.context)}>
|
||||
<themeContext.context.Provider value={{ current: themes.contextual(props.context), themes, ready: themes.ready }}>
|
||||
{props.children}
|
||||
</themeContext.context.Provider>
|
||||
)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui"
|
||||
import type { PluginRuntime } from "../plugin/runtime"
|
||||
import PluginManager from "./system/plugins"
|
||||
import WhichKey from "./system/which-key"
|
||||
|
||||
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||
id: string
|
||||
tui: TuiPlugin
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
|
||||
return [PluginManager, WhichKey]
|
||||
}
|
||||
|
||||
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
|
||||
const slots = runtime.setupSlots(api)
|
||||
const dispose: Array<() => void> = []
|
||||
|
||||
for (const plugin of createBuiltinPlugins()) {
|
||||
if (plugin.enabled === false) continue
|
||||
const scoped = Object.assign(Object.create(api), {
|
||||
slots: {
|
||||
register(input: Parameters<typeof slots.register>[0]) {
|
||||
dispose.push(slots.register({ ...input, id: plugin.id }))
|
||||
return plugin.id
|
||||
},
|
||||
},
|
||||
}) as TuiPluginApi
|
||||
const now = Date.now()
|
||||
await plugin.tui(scoped, undefined, {
|
||||
id: plugin.id,
|
||||
source: "internal",
|
||||
spec: plugin.id,
|
||||
target: plugin.id,
|
||||
first_time: now,
|
||||
last_time: now,
|
||||
time_changed: now,
|
||||
load_count: 1,
|
||||
fingerprint: plugin.id,
|
||||
state: "first",
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const fn of dispose.reverse()) fn()
|
||||
slots.dispose()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user