mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 02:56:18 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f781da7b | ||
|
|
50a8539e4b | ||
|
|
7b47589225 | ||
|
|
84275c6e9d | ||
|
|
793ea52fa7 | ||
|
|
40380ad9b5 | ||
|
|
156cb7dd10 | ||
|
|
0a60910208 | ||
|
|
11e2bde399 | ||
|
|
ea85118516 | ||
|
|
2e1d7c84ab | ||
|
|
3b8949b1ee | ||
|
|
97daae9b77 | ||
|
|
3dd84d629d | ||
|
|
279062d3c9 | ||
|
|
0a410e404b | ||
|
|
5cd40ad66f | ||
|
|
46d1f1fed1 | ||
|
|
e282066cf8 | ||
|
|
6704f34cad | ||
|
|
e63aba48bc | ||
|
|
297f5298cc | ||
|
|
7102c487c9 | ||
|
|
d8f59d312b | ||
|
|
b23e4c3118 | ||
|
|
1c03f08512 | ||
|
|
3eb07e38ae | ||
|
|
299e314a28 | ||
|
|
76027fe3da | ||
|
|
8b65bd53bf |
@@ -89,6 +89,12 @@ jobs:
|
||||
working-directory: packages/codemode
|
||||
run: bun run script/publish.ts --dry-run
|
||||
|
||||
- name: Verify packed workerd SDK
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/sdk
|
||||
run: bun run verify:package
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -595,8 +595,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.7",
|
||||
"@opentui/solid": ">=0.5.7",
|
||||
"@opentui/core": ">=0.5.8",
|
||||
"@opentui/solid": ">=0.5.8",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1090,9 +1090,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@opentui/core": "0.5.8",
|
||||
"@opentui/keymap": "0.5.8",
|
||||
"@opentui/solid": "0.5.8",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2218,27 +2218,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.7", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.7", "@opentui/core-darwin-x64": "0.5.7", "@opentui/core-linux-arm64": "0.5.7", "@opentui/core-linux-arm64-musl": "0.5.7", "@opentui/core-linux-x64": "0.5.7", "@opentui/core-linux-x64-musl": "0.5.7", "@opentui/core-win32-arm64": "0.5.7", "@opentui/core-win32-x64": "0.5.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-/XDabTkfBs2Wy2FhlC4jvzbpphYAs4SlnCRKYEvi+metlXNTSAshSs43wqZ9O4IPd0E4EcQqdfeQ0sdc3yPpYw=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.8", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.8", "@opentui/core-darwin-x64": "0.5.8", "@opentui/core-linux-arm64": "0.5.8", "@opentui/core-linux-arm64-musl": "0.5.8", "@opentui/core-linux-x64": "0.5.8", "@opentui/core-linux-x64-musl": "0.5.8", "@opentui/core-win32-arm64": "0.5.8", "@opentui/core-win32-x64": "0.5.8" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-GbZ+nSLYZqxj2Z5TU19Mx6IsAvVsn2+7WEXz+6OlMGoootvt3TxP3vWwfGI0jWk9qp1ftRlML1JPNEkzy+9I8g=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-75TDJgFD6hDoCElIX35Yg3TIRF/jhrtVQO/9snhjGuTsZ7bd0W88jUlvSXSNhqB3CX431rwgi47B+asWPDI1lQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c9Y1FBrSnA4sKUCMETsrLYOmsMTyJae8mU9cE6M4o9rXcr3ZLPA7o9AkPA3S0+kH+kZ0o1Fn0wLejgNxvEp5mg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-j+Dwu2yV8zahBFjnVIssYdPrHz/+pkd7xWxI+nHjV41V3c+scvH6vJY1U85VCVaoQs/zCH5xAJbmAAn7Sgeh8g=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-oZ/6Iz1KN+4volMFKmmvziYJhMgyyJ99LfK1S+uPRyIRqzT3CESoJ58D4h04M1g0dHeHE4PvkU1p3uQKrXiP3g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWO9NWaivXRPc0XhEbxehj3ApyC1SW8Bf1cnHnME1zhw/EZNCNE3TQHgKwOnnNKtvlbhzLQjOI4OqyPYV8UcEQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-N6i/ocrsTjIq9aUQyrfJkqUo+tc4P5ZS6xz38Cm1MhtDzwBE+CrNIJdHfoJfmRLhJkYHlw6Z0ToICn8ZNj4Bpw=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RumSHTasIAWU7jPBKiTuEZG/m+prfQKCHhL3JAQnBLgp7eAB20XZGfSLVWDBgxkyKq1rHCUgLtZktC5ZAyKfTA=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-eFMB41AWODaYf8PsCx3vtTMX33tFgtSpR9tKNnUErCXlcnZ+WCRHVaJ9F6DrVNX3ir6HmgbwAmaWXvaVF+kLtg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-fXDmrIlfp9xaoVkk3BNn3yUO/b7plBSOfUh2oWOezRA6E/g5z+hTO1alGFQCy+VV+1kfA6iuYadAww0xNtfACg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-/2QM7/wMnML/sxchzbwgoU5tUu/7k836/kSOKMti8opjuecv1K+WWNKGXufhTNeRcXFZaba5rsCdYrf3VqnVsQ=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-1opDV+W7C1F4iWivCNKuYl5Hen+IeFXAuUPm3hVyN0UtdcP8LESpFJTr8QRwHpGSE9Jz1K2g2S2ipzI3u5mhgw=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YXo+qUHYmep2uvv3ECvTeqr10aD7+lBsavYmsTLBzS5hHabbzlQ10oX/99nIRPC3Au1BMWD6d6zQP5czPx23eg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-gbiZUyttjq8s+bsnsaopygWN7LTS/RyzD6GOmpgOB2TVitvo0P6mxM+AebBGuu/7xri17Rqaxf3uqrgdKiJAeg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-7qBdhEAlh4tLFzW7nWLPlREtNiF6NZMDMi+4uDpUlAMhRavLr6wjcIcgfhNAF/puq06DjuZlPTwaMCgB3qOuwA=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-bDwon45lUxbV3rMcekTCg4mXcBu9uhdG6HLeLK5TVZf05/h+Ibkf2UeZ8A2jjQt+MK6jpYmxWwDCVCTzZ7EU7Q=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Z76YaTKnmRDSHKdKa7iTBCXBQdInQLq7UG3qIE84nvyHCDtkhYtPWOaCzDisSgXC8Y6hJt6NiFbaNjQyxPkfpQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.7", "", { "dependencies": { "@opentui/core": "0.5.7" }, "peerDependencies": { "@opentui/react": "0.5.7", "@opentui/solid": "0.5.7", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BqfSbjlLuctnew2aoPPdK3wpzJfEPp7ttLBkTu1wJTkps9AcfUgXqwloqvY4DUKjUBNFYYEpU/0CPxI4Blj4MA=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.8", "", { "dependencies": { "@opentui/core": "0.5.8" }, "peerDependencies": { "@opentui/react": "0.5.8", "@opentui/solid": "0.5.8", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-KQHKRnLZroSZIbHmGmSeDPsXi7Yykdu9x9ACmDzBFkgPGx0EIuXHqRcmrqmUaX485MQEZuCD9t6FdDsOMGyIFg=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qrKAZd9xt4D67LXZUARg1Aw0dwEWi29GyOxpM6abqmyExGPMS7jih5Z9oMQJ9EXsouDKpW7Mejjq7WbX2AaecQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.8", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.8", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-L0NxuAU8XT+jlE5G90oA3kspqkof48b0hmzi5XLw+1gxnkxrkTb+YfKys+GzVK4UqhgwY9aW+TeDfrKbnfwCMw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
|
||||
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
|
||||
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
|
||||
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
|
||||
"x86_64-linux": "sha256-phyTF0/jQZ3L0B66PSLdpH//kyPc1M6j5a40wCSx7TA=",
|
||||
"aarch64-linux": "sha256-1Zb/Is0ujIslCbPPusAVhcuzAPyIauQyeIIRRGtzpAk=",
|
||||
"aarch64-darwin": "sha256-DDsVm7z+PSDry6QqrwVDFSmEnq6jIKb709Y4ymAv9f8=",
|
||||
"x86_64-darwin": "sha256-S+5LI2J+WRhRP7jp2PAv6AesXk238wEYoyIO1oKdF3w="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@opentui/core": "0.5.8",
|
||||
"@opentui/keymap": "0.5.8",
|
||||
"@opentui/solid": "0.5.8",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
@@ -63,6 +64,19 @@ export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
readonly service_tier?: "auto" | "standard_only"
|
||||
readonly serviceTier?: "auto" | "standard_only"
|
||||
// SDK Metadata:2649 {user_id?: string | null}
|
||||
readonly metadata?: { readonly user_id?: string | null }
|
||||
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
|
||||
readonly container?: string | { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly inference_geo?: string | null
|
||||
readonly inferenceGeo?: string | null
|
||||
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
|
||||
readonly output_config?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
|
||||
readonly outputConfig?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
|
||||
}
|
||||
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
@@ -82,25 +96,61 @@ const AnthropicTextBlock = Schema.Struct({
|
||||
})
|
||||
type AnthropicTextBlock = Schema.Schema.Type<typeof AnthropicTextBlock>
|
||||
|
||||
// SDK: Base64ImageSource:201 {type:"base64", media_type:"image/jpeg"|... , data}, URLImageSource:3817 {type:"url", url}, FileImageSource:2350 {type:"file", file_id}
|
||||
// SDK: ImageBlockParam:2356 {source: Base64|URL|File, cache_control, transformations:2381 {oversized_image?}}
|
||||
const AnthropicBase64ImageSource = Schema.Struct({
|
||||
type: Schema.tag("base64"),
|
||||
media_type: Schema.String,
|
||||
data: Schema.String,
|
||||
})
|
||||
const AnthropicURLImageSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
|
||||
const AnthropicFileImageSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
|
||||
const AnthropicImageSource = Schema.Union([
|
||||
AnthropicBase64ImageSource,
|
||||
AnthropicURLImageSource,
|
||||
AnthropicFileImageSource,
|
||||
])
|
||||
const AnthropicImageTransformations = Schema.Struct({
|
||||
oversized_image: Schema.optional(Schema.Literals(["downsize", "error"])),
|
||||
})
|
||||
|
||||
const AnthropicImageBlock = Schema.Struct({
|
||||
type: Schema.tag("image"),
|
||||
source: Schema.Struct({
|
||||
type: Schema.tag("base64"),
|
||||
media_type: Schema.String,
|
||||
data: Schema.String,
|
||||
}),
|
||||
source: AnthropicImageSource,
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
transformations: Schema.optional(AnthropicImageTransformations),
|
||||
})
|
||||
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
|
||||
|
||||
// SDK: Base64PDFSource:209 {type:"base64", media_type:"application/pdf", data}, PlainTextSource:2716 {type:"text", media_type:"text/plain", data},
|
||||
// SDK: URLPDFSource:3823 {type:"url", url}, FileDocumentSource:2344 {type:"file", file_id}, ContentBlockSource:2266 {type:"content", content}
|
||||
// SDK: DocumentBlockParam:2297 {source: 5-way union, cache_control, citations, context, title}
|
||||
const AnthropicBase64PDFSource = Schema.Struct({
|
||||
type: Schema.tag("base64"),
|
||||
media_type: Schema.Literal("application/pdf"),
|
||||
data: Schema.String,
|
||||
})
|
||||
const AnthropicPlainTextSource = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
media_type: Schema.Literal("text/plain"),
|
||||
data: Schema.String,
|
||||
})
|
||||
const AnthropicURLPDFSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
|
||||
const AnthropicFileDocumentSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
|
||||
const AnthropicDocumentSource = Schema.Union([
|
||||
AnthropicBase64PDFSource,
|
||||
AnthropicPlainTextSource,
|
||||
AnthropicURLPDFSource,
|
||||
AnthropicFileDocumentSource,
|
||||
])
|
||||
|
||||
const AnthropicDocumentBlock = Schema.Struct({
|
||||
type: Schema.tag("document"),
|
||||
source: Schema.Struct({
|
||||
type: Schema.tag("base64"),
|
||||
media_type: Schema.Literal("application/pdf"),
|
||||
data: Schema.String,
|
||||
}),
|
||||
source: AnthropicDocumentSource,
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
title: Schema.optional(Schema.String),
|
||||
context: Schema.optional(Schema.String),
|
||||
citations: Schema.optional(Schema.Struct({ enabled: Schema.Boolean })),
|
||||
})
|
||||
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
|
||||
|
||||
@@ -227,10 +277,28 @@ const AnthropicThinking = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
|
||||
const AnthropicJsonOutputFormat = Schema.Struct({
|
||||
type: Schema.Literal("json_schema"),
|
||||
schema: JsonObject,
|
||||
})
|
||||
const AnthropicOutputConfig = Schema.Struct({
|
||||
effort: Schema.optional(Schema.String),
|
||||
format: Schema.optional(Schema.NullOr(AnthropicJsonOutputFormat)),
|
||||
})
|
||||
|
||||
// SDK Metadata:2649 {user_id?: string|null}
|
||||
const AnthropicMetadata = Schema.Struct({ user_id: optionalNull(Schema.String) })
|
||||
|
||||
// SDK MessageCreateParamsContainer:2596 ContainerParams|string; ContainerParams:2172 {id?, skills?}
|
||||
const AnthropicContainer = Schema.Union([
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
id: optionalNull(Schema.String),
|
||||
skills: optionalNull(Schema.Array(JsonObject)),
|
||||
}),
|
||||
])
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
model: Schema.String,
|
||||
system: optionalArray(AnthropicTextBlock),
|
||||
@@ -245,6 +313,12 @@ const AnthropicBodyFields = {
|
||||
stop_sequences: optionalArray(Schema.String),
|
||||
thinking: Schema.optional(AnthropicThinking),
|
||||
output_config: Schema.optional(AnthropicOutputConfig),
|
||||
// SDK top-level passthrough: cache_control:4638, container:4643, inference_geo:4649, metadata:4654, service_tier:4670
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
|
||||
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
|
||||
}
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -431,7 +505,152 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const anthropic = metadata.anthropic
|
||||
if (ProviderShared.isRecord(anthropic)) {
|
||||
if (typeof anthropic.file_id === "string") return anthropic.file_id
|
||||
if (typeof anthropic.fileId === "string") return anthropic.fileId
|
||||
}
|
||||
if (typeof metadata.file_id === "string") return metadata.file_id
|
||||
if (typeof metadata.fileId === "string") return metadata.fileId
|
||||
return undefined
|
||||
}
|
||||
|
||||
const transformationsFromMetadata = (
|
||||
metadata: MediaPart["metadata"],
|
||||
): AnthropicImageBlock["transformations"] | undefined => {
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
|
||||
const raw = anthropic?.transformations ?? metadata.transformations
|
||||
if (ProviderShared.isRecord(raw)) {
|
||||
const value = raw.oversized_image
|
||||
if (value === "downsize" || value === "error") return { oversized_image: value }
|
||||
}
|
||||
if (anthropic && (anthropic.oversized_image === "downsize" || anthropic.oversized_image === "error"))
|
||||
return { oversized_image: anthropic.oversized_image }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const documentTitleFromPart = (part: MediaPart): string | undefined => {
|
||||
if (ProviderShared.isRecord(part.metadata)) {
|
||||
const anthropic = part.metadata.anthropic
|
||||
if (ProviderShared.isRecord(anthropic) && typeof anthropic.title === "string") return anthropic.title
|
||||
if (typeof part.metadata.title === "string") return part.metadata.title
|
||||
}
|
||||
if (typeof part.filename === "string" && part.filename.length > 0) return part.filename
|
||||
return undefined
|
||||
}
|
||||
|
||||
const documentContextFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
|
||||
if (anthropic && typeof anthropic.context === "string") return anthropic.context
|
||||
if (typeof metadata.context === "string") return metadata.context
|
||||
return undefined
|
||||
}
|
||||
|
||||
const citationsFromMetadata = (
|
||||
metadata: MediaPart["metadata"],
|
||||
): AnthropicDocumentBlock["citations"] | undefined => {
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const raw = ProviderShared.isRecord(metadata.anthropic)
|
||||
? (metadata.anthropic.citations ?? metadata.citations)
|
||||
: metadata.citations
|
||||
if (ProviderShared.isRecord(raw) && typeof raw.enabled === "boolean") return { enabled: raw.enabled }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value.trim())
|
||||
|
||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
breakpoints?: Cache.Breakpoints,
|
||||
) {
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const cacheControlValue = breakpoints ? cacheControl(breakpoints, part.cache) : undefined
|
||||
const fileId = fileIdFromMetadata(part.metadata)
|
||||
|
||||
// SDK file sources: FileImageSource:2350 / FileDocumentSource:2344 {type:"file", file_id}
|
||||
if (fileId) {
|
||||
if (mime.startsWith("image/"))
|
||||
return {
|
||||
type: "image" as const,
|
||||
source: { type: "file" as const, file_id: fileId },
|
||||
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
|
||||
...(transformationsFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { transformations: transformationsFromMetadata(part.metadata)! }),
|
||||
} satisfies AnthropicImageBlock
|
||||
return {
|
||||
type: "document" as const,
|
||||
source: { type: "file" as const, file_id: fileId },
|
||||
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
|
||||
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
|
||||
...(documentContextFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { context: documentContextFromMetadata(part.metadata)! }),
|
||||
...(citationsFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { citations: citationsFromMetadata(part.metadata)! }),
|
||||
} satisfies AnthropicDocumentBlock
|
||||
}
|
||||
|
||||
const rawString = typeof part.data === "string" ? part.data.trim() : undefined
|
||||
// SDK URL sources: URLImageSource:3817 / URLPDFSource:3823 {type:"url", url}
|
||||
if (rawString && isHttpUrl(rawString) && !rawString.startsWith("data:")) {
|
||||
if (mime.startsWith("image/"))
|
||||
return {
|
||||
type: "image" as const,
|
||||
source: { type: "url" as const, url: rawString },
|
||||
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
|
||||
...(transformationsFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { transformations: transformationsFromMetadata(part.metadata)! }),
|
||||
} satisfies AnthropicImageBlock
|
||||
if (mime === "application/pdf")
|
||||
return {
|
||||
type: "document" as const,
|
||||
source: { type: "url" as const, url: rawString },
|
||||
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
|
||||
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
|
||||
...(documentContextFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { context: documentContextFromMetadata(part.metadata)! }),
|
||||
...(citationsFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { citations: citationsFromMetadata(part.metadata)! }),
|
||||
} satisfies AnthropicDocumentBlock
|
||||
}
|
||||
|
||||
// SDK PlainTextSource:2716 {type:"text", media_type:"text/plain", data}
|
||||
if (mime === "text/plain") {
|
||||
const textData =
|
||||
typeof part.data !== "string"
|
||||
? Buffer.from(part.data).toString("utf8")
|
||||
: part.data.startsWith("data:")
|
||||
? (() => {
|
||||
const comma = part.data.indexOf(",")
|
||||
const payload = comma >= 0 ? part.data.slice(comma + 1) : part.data
|
||||
return part.data.includes(";base64")
|
||||
? Buffer.from(payload, "base64").toString("utf8")
|
||||
: decodeURIComponent(payload)
|
||||
})()
|
||||
: part.data
|
||||
return {
|
||||
type: "document" as const,
|
||||
source: { type: "text" as const, media_type: "text/plain" as const, data: textData },
|
||||
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
|
||||
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
|
||||
...(documentContextFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { context: documentContextFromMetadata(part.metadata)! }),
|
||||
...(citationsFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { citations: citationsFromMetadata(part.metadata)! }),
|
||||
} satisfies AnthropicDocumentBlock
|
||||
}
|
||||
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
if (media.mime === "application/pdf")
|
||||
return {
|
||||
@@ -441,6 +660,14 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
|
||||
media_type: "application/pdf" as const,
|
||||
data: media.base64,
|
||||
},
|
||||
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
|
||||
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
|
||||
...(documentContextFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { context: documentContextFromMetadata(part.metadata)! }),
|
||||
...(citationsFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { citations: citationsFromMetadata(part.metadata)! }),
|
||||
} satisfies AnthropicDocumentBlock
|
||||
if (!media.mime.startsWith("image/"))
|
||||
return yield* invalid(`Anthropic Messages does not support media type ${part.mediaType}`)
|
||||
@@ -451,6 +678,10 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
|
||||
media_type: media.mime,
|
||||
data: media.base64,
|
||||
},
|
||||
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
|
||||
...(transformationsFromMetadata(part.metadata) === undefined
|
||||
? {}
|
||||
: { transformations: transformationsFromMetadata(part.metadata)! }),
|
||||
} satisfies AnthropicImageBlock
|
||||
})
|
||||
|
||||
@@ -559,7 +790,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
content.push(yield* lowerMedia(part))
|
||||
content.push(yield* lowerMedia(part, breakpoints))
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
||||
@@ -626,10 +857,63 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
})
|
||||
|
||||
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||
const input = request.providerOptions
|
||||
const input = request.providerOptions as Record<string, unknown> | undefined
|
||||
const rawServiceTier = (input as Record<string, unknown> | undefined)?.service_tier ?? (input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const service_tier =
|
||||
rawServiceTier === "auto" || rawServiceTier === "standard_only"
|
||||
? (rawServiceTier as "auto" | "standard_only")
|
||||
: undefined
|
||||
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
|
||||
const metadata =
|
||||
ProviderShared.isRecord(rawMetadata) &&
|
||||
(typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
? { user_id: rawMetadata.user_id as string | null }
|
||||
: undefined
|
||||
const container =
|
||||
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
|
||||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
|
||||
? ((input as Record<string, unknown>).container as string | { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
: undefined
|
||||
const rawInferenceGeo =
|
||||
(input as Record<string, unknown> | undefined)?.inference_geo ??
|
||||
(input as Record<string, unknown> | undefined)?.inferenceGeo
|
||||
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
|
||||
const rawCacheControl =
|
||||
(input as Record<string, unknown> | undefined)?.cache_control ??
|
||||
(input as Record<string, unknown> | undefined)?.cacheControl
|
||||
const cache_control =
|
||||
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
|
||||
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
|
||||
: undefined
|
||||
const rawOutputConfig =
|
||||
(input as Record<string, unknown> | undefined)?.output_config ??
|
||||
(input as Record<string, unknown> | undefined)?.outputConfig
|
||||
const outputConfigEffort =
|
||||
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
|
||||
? ((input as Record<string, unknown>).effort as string)
|
||||
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
|
||||
? (rawOutputConfig.effort as string)
|
||||
: undefined
|
||||
const outputConfigFormat =
|
||||
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
|
||||
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
|
||||
: undefined
|
||||
const output_config =
|
||||
outputConfigEffort === undefined && outputConfigFormat === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
|
||||
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
|
||||
}
|
||||
return {
|
||||
thinking: yield* resolveThinking(input?.thinking),
|
||||
effort: typeof input?.effort === "string" ? input.effort : undefined,
|
||||
effort: outputConfigEffort,
|
||||
output_config,
|
||||
service_tier,
|
||||
metadata,
|
||||
container,
|
||||
inference_geo,
|
||||
cache_control,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -701,7 +985,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: options.thinking,
|
||||
output_config: options.effort === undefined ? undefined : { effort: options.effort },
|
||||
output_config: options.output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control,
|
||||
container: options.container,
|
||||
inference_geo: options.inference_geo,
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -313,6 +313,9 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
// Which lowered input item a persisted item id is about to be attached to.
|
||||
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
|
||||
|
||||
export interface Extension {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
@@ -321,6 +324,10 @@ export interface Extension {
|
||||
readonly media: ProviderShared.NormalizedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
// Optional grammar check applied before a persisted item id is resent as
|
||||
// part of replayed history. Returning false drops the id; every lowered
|
||||
// item treats a dropped id the same as an absent one.
|
||||
readonly acceptsItemID?: (kind: ItemKind, id: string) => boolean
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
@@ -346,6 +353,10 @@ interface ReasoningStreamItem {
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
// and matches the wire field.
|
||||
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
|
||||
// Summary indexes that received at least one streamed delta. The `:0` block
|
||||
// is started eagerly when the item opens, so block existence cannot tell
|
||||
// whether a `.done` final would duplicate streamed text.
|
||||
readonly deltaIndexes: ReadonlySet<number>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -376,28 +387,46 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
|
||||
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
||||
})
|
||||
|
||||
// Servers validate item ids on replayed history, and a malformed or oversized
|
||||
// id can fail an otherwise valid request. Only server-issued tokens are worth
|
||||
// resending; anything else is treated as absent so the item is resent without
|
||||
// an id (or skipped, for items that cannot be expressed without one).
|
||||
const ITEM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
|
||||
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
|
||||
const metadata = providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
||||
return ProviderShared.isRecord(metadata) &&
|
||||
typeof metadata.itemId === "string" &&
|
||||
ITEM_ID_PATTERN.test(metadata.itemId)
|
||||
? metadata.itemId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||
const acceptsItemID = (extension: Extension, kind: ItemKind, id: string | undefined): id is string =>
|
||||
id !== undefined && (extension.acceptsItemID?.(kind, id) ?? true)
|
||||
|
||||
const lowerToolCall = (
|
||||
part: ToolCallPart,
|
||||
providerMetadataKey: string,
|
||||
extension: Extension,
|
||||
): OpenResponsesInputItem => {
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
return {
|
||||
type: "function_call",
|
||||
...(id ? { id } : {}),
|
||||
...(acceptsItemID(extension, "function-call", id) ? { id } : {}),
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
}
|
||||
}
|
||||
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const lowerReasoning = (
|
||||
part: ReasoningPart,
|
||||
providerMetadataKey: string,
|
||||
extension: Extension,
|
||||
): OpenResponsesReasoningInput | undefined => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (!ProviderShared.isRecord(metadata) || !id) return undefined
|
||||
if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined
|
||||
const encryptedContent =
|
||||
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||
? metadata.reasoningEncryptedContent
|
||||
@@ -529,7 +558,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
|
||||
>((groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const rawID = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||
@@ -554,7 +584,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
|
||||
if (!reasoning) continue
|
||||
if (store !== false) {
|
||||
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||
@@ -575,13 +605,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (part.type === "tool-call") {
|
||||
flushText()
|
||||
if (part.providerExecuted === true) continue
|
||||
input.push(lowerToolCall(part, providerMetadataKey))
|
||||
input.push(lowerToolCall(part, providerMetadataKey, extension))
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
|
||||
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
|
||||
if (store !== false && reference && !hostedToolReferences.has(reference))
|
||||
input.push({ type: "item_reference", id: reference })
|
||||
if (store === false) {
|
||||
// The server is not storing this exchange, so the tool outcome has to
|
||||
// travel in the input. Non-content results degrade to their text form.
|
||||
@@ -596,7 +628,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
),
|
||||
})
|
||||
}
|
||||
if (id) hostedToolReferences.add(id)
|
||||
if (reference) hostedToolReferences.add(reference)
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
@@ -777,19 +809,33 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
if (!event.delta || !state.reasoningItems[itemID]) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item) return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
const events: LLMEvent[] = []
|
||||
const id = `${itemID}:${event.summary_index ?? 0}`
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
|
||||
// Some compatible gateways emit a reasoning final without streaming any
|
||||
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
|
||||
// as a single delta unless that summary index already streamed one.
|
||||
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
}
|
||||
|
||||
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
|
||||
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
@@ -827,7 +873,11 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
||||
[item.id]: {
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "active" },
|
||||
deltaIndexes: new Set(),
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
@@ -1133,6 +1183,15 @@ export const step = (state: ParserState, event: Event) => {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (
|
||||
event.type === "response.reasoning.done" ||
|
||||
event.type === "response.reasoning_summary_text.done" ||
|
||||
event.type === "response.reasoning_summary.done" ||
|
||||
event.type === "response.reasoning_text.done"
|
||||
) {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
|
||||
@@ -51,9 +51,28 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
// Replayed items are paired with stored server state by id, so a foreign or
|
||||
// synthetic token can fail request validation even when `call_id` pairing is
|
||||
// intact. Only resend ids in each item kind's own grammar; hosted tool
|
||||
// references keep generic validation because every hosted tool mints its own
|
||||
// prefix. The same allowlist approach codex uses before resending history
|
||||
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
|
||||
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
|
||||
message: ["msg_"],
|
||||
reasoning: ["rs_"],
|
||||
"function-call": ["fc_"],
|
||||
// Every hosted tool mints its own id prefix, so references keep generic
|
||||
// validation only.
|
||||
reference: [],
|
||||
}
|
||||
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
|
||||
const prefixes = ITEM_ID_PREFIXES[kind]
|
||||
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
|
||||
},
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
@@ -147,10 +166,6 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
return OpenResponses.step(state, event)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
const ADAPTER = "xai-responses"
|
||||
@@ -27,15 +25,9 @@ const HOSTED_TOOLS = {
|
||||
},
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
|
||||
// handled by the baseline); only its hosted tool vocabulary differs.
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
return OpenResponses.step(state, event)
|
||||
|
||||
@@ -51,6 +51,7 @@ export const MediaPart = Schema.Struct({
|
||||
mediaType: Schema.String,
|
||||
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||
filename: Schema.optional(Schema.String),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).annotate({ identifier: "LLM.Content.Media" })
|
||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
|
||||
@@ -132,6 +132,78 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
// The baseline does not enforce a provider id grammar, so a
|
||||
// non-OpenAI but well-formed token is resent as-is.
|
||||
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
|
||||
// Shape violations are dropped even without a grammar policy.
|
||||
{
|
||||
type: "text",
|
||||
text: "Dropped.",
|
||||
providerMetadata: { openresponses: { itemId: `m${"a".repeat(64)}` } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "history_1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Kept." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Dropped." }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
|
||||
},
|
||||
// Raw reasoning finals carry no summary index; they reconcile
|
||||
// into the item's first block.
|
||||
{ type: "response.reasoning.done", item_id: "rs_raw", text: "Raw chain of thought." },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Raw chain of thought.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -2196,6 +2196,105 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
// No `.delta` events at all: the gateway sends the complete
|
||||
// summary text in the `.done` final.
|
||||
{
|
||||
type: "response.reasoning_summary_text.done",
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
text: "Checked the diff.",
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
encrypted_content: "encrypted-state",
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Checked the diff.")
|
||||
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [response.message],
|
||||
providerOptions: { store: false, include: ["reasoning.encrypted_content"] },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
encrypted_content: "encrypted-state",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not duplicate reasoning finals after streamed deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Streamed" },
|
||||
// Repeats the complete text, as the spec allows.
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Streamed" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Streamed")
|
||||
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "Streamed", providerMetadata: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
|
||||
@@ -2419,12 +2518,102 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops replayed item ids outside the server's grammar", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
// Fails the message id prefix.
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
providerMetadata: { openai: { itemId: "history_1" } },
|
||||
},
|
||||
// Oversized for the Responses item id limit.
|
||||
{
|
||||
type: "text",
|
||||
text: "World",
|
||||
providerMetadata: { openai: { itemId: `m${"a".repeat(64)}` } },
|
||||
},
|
||||
// Fails the reasoning id prefix, so the whole item is unreplayable
|
||||
// statelessly and is skipped rather than sent malformed.
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "thinking_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { openai: { itemId: "toolu_01A" } },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "output_text", text: "Hello" },
|
||||
{ type: "output_text", text: "World" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps well-formed hosted references and drops malformed ones under storage", () =>
|
||||
Effect.gen(function* () {
|
||||
const hostedResult = (itemId: string) => [
|
||||
ToolCallPart.make({
|
||||
id: itemId,
|
||||
name: "web_search",
|
||||
input: { query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId } },
|
||||
}),
|
||||
{
|
||||
type: "tool-result" as const,
|
||||
id: itemId,
|
||||
name: "web_search",
|
||||
result: { type: "json" as const, value: { status: "completed" } },
|
||||
providerExecuted: true as const,
|
||||
providerMetadata: { openai: { itemId } },
|
||||
},
|
||||
]
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.assistant(hostedResult("ws_1")), Message.assistant(hostedResult("bad ref"))],
|
||||
providerOptions: { store: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "ws_1" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stateless hosted image generation with the generated image", () =>
|
||||
Effect.gen(function* () {
|
||||
const imageTool = OpenAI.imageGeneration({ action: "edit" })
|
||||
@@ -2548,15 +2737,15 @@ describe("OpenAI Responses route", () => {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' },
|
||||
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' },
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query"' },
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: ':"weather"}' },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
@@ -2585,7 +2774,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
providerMetadata: { openai: { itemId: "fc_item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
@@ -2603,7 +2792,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
providerMetadata: { openai: { itemId: "fc_item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
@@ -2611,7 +2800,7 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
providerMetadata: { openai: { itemId: "fc_item_1" } },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
@@ -2632,7 +2821,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
@@ -2646,7 +2835,7 @@ describe("OpenAI Responses route", () => {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
@@ -2658,7 +2847,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
providerMetadata: { openai: { itemId: "fc_item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
@@ -2666,7 +2855,7 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
input: {},
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
providerMetadata: { openai: { itemId: "fc_item_1" } },
|
||||
},
|
||||
],
|
||||
)
|
||||
@@ -2679,14 +2868,14 @@ describe("OpenAI Responses route", () => {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"partial',
|
||||
@@ -2718,7 +2907,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"partial',
|
||||
@@ -2747,7 +2936,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
@@ -2761,7 +2950,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
id: "call_1",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
providerMetadata: { openai: { itemId: "fc_item_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI reasoning text events", () =>
|
||||
it.effect("parses xAI reasoning summaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
|
||||
Effect.provide(
|
||||
@@ -34,8 +34,13 @@ describe("xAI Responses route", () => {
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "reasoning_1" },
|
||||
},
|
||||
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
|
||||
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
|
||||
// Grok streams reasoning with the standard summary event name.
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
item_id: "reasoning_1",
|
||||
summary_index: 0,
|
||||
delta: "Considering.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
|
||||
|
||||
@@ -13,9 +13,14 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
|
||||
|
||||
test("session settings use the remote server context", async ({ page }) => {
|
||||
const permissionRequests: string[] = []
|
||||
const permissionResponses: PermissionResponse[] = []
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await mockServers(page, permissionRequests)
|
||||
// Server A has no tab and is never visited: a pending request there proves
|
||||
// one toggle sweeps every connected server, not just the focused one.
|
||||
await mockServers(page, permissionRequests, permissionResponses, {
|
||||
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
|
||||
})
|
||||
await configureServers(page)
|
||||
|
||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
@@ -38,7 +43,17 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-pending-a",
|
||||
body: { reply: "once" },
|
||||
},
|
||||
])
|
||||
|
||||
await dialog.getByRole("tab", { name: "Models" }).click()
|
||||
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||
@@ -143,6 +158,99 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
])
|
||||
})
|
||||
|
||||
test("auto-accept sweeps again after a reconnect", async ({ page }) => {
|
||||
const permissionRequests: string[] = []
|
||||
const permissionResponses: PermissionResponse[] = []
|
||||
const pendingA: MockPermission[] = []
|
||||
const listFailures: Record<string, number> = {}
|
||||
const sessionGets: string[] = []
|
||||
await installSseTransport(page, { server: serverB })
|
||||
const transport = await installSseTransport(page, { server: serverA, retry: 20 })
|
||||
await mockServers(page, permissionRequests, permissionResponses, {
|
||||
pending: { [serverA]: pendingA },
|
||||
listFailures,
|
||||
sessionGets,
|
||||
})
|
||||
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
|
||||
|
||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
|
||||
const first = await transport.waitForConnection()
|
||||
|
||||
await page.keyboard.press("Control+,")
|
||||
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
await expect
|
||||
.poll(() =>
|
||||
permissionRequests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
// This request is asked while the client is disconnected, so it is never
|
||||
// delivered as an event and only a reconnect sweep can find it. The first
|
||||
// listing after the reconnect fails, so only the bounded sweep retry can
|
||||
// deliver the reply.
|
||||
pendingA.push(pendingPermission("permission-offline-a", sessionA.id))
|
||||
listFailures[serverA] = 1
|
||||
const syncsBeforeReconnect = sessionGets.length
|
||||
await transport.disconnect()
|
||||
await transport.waitForConnection({ after: first.id })
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-offline-a",
|
||||
body: { reply: "once" },
|
||||
},
|
||||
])
|
||||
// The reconnect sweep must resync active sessions instead of trusting
|
||||
// cached locations, since another client may have moved them meanwhile.
|
||||
expect(sessionGets.slice(syncsBeforeReconnect)).toContain(sessionA.id)
|
||||
})
|
||||
|
||||
test("auto-accept approves a request discovered by opening a session", async ({ page }) => {
|
||||
const permissionRequests: string[] = []
|
||||
const permissionResponses: PermissionResponse[] = []
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
// The request is only served from the per-session permission list, so it
|
||||
// reaches the client through the store sync when the session view opens,
|
||||
// never through a location sweep or an event.
|
||||
await mockServers(page, permissionRequests, permissionResponses, {
|
||||
sessionPending: { [sessionA.id]: [pendingPermission("permission-synced-a", sessionA.id)] },
|
||||
})
|
||||
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
|
||||
|
||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Control+,")
|
||||
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
|
||||
await expect
|
||||
.poll(() => permissionResponses)
|
||||
.toEqual([
|
||||
{
|
||||
origin: serverA,
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-synced-a",
|
||||
body: { reply: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
type PermissionResponse = {
|
||||
origin: string
|
||||
directory?: string
|
||||
@@ -151,6 +259,19 @@ type PermissionResponse = {
|
||||
body: unknown
|
||||
}
|
||||
|
||||
type MockPermission = {
|
||||
id: string
|
||||
sessionID: string
|
||||
action: string
|
||||
resources: string[]
|
||||
metadata: Record<string, unknown>
|
||||
save: unknown[]
|
||||
}
|
||||
|
||||
function pendingPermission(id: string, sessionID: string): MockPermission {
|
||||
return { id, sessionID, action: "shell", resources: ["git status"], metadata: {}, save: [] }
|
||||
}
|
||||
|
||||
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
||||
await page.addInitScript(
|
||||
({ serverB, tabs }) => {
|
||||
@@ -161,7 +282,23 @@ async function configureServers(page: Page, tabs: { type: "session"; server: str
|
||||
)
|
||||
}
|
||||
|
||||
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
|
||||
type MockServerOptions = {
|
||||
// Pending requests served from /api/permission/request, keyed by origin.
|
||||
pending?: Record<string, MockPermission[]>
|
||||
// Pending requests served from /api/session/:id/permission, keyed by session ID.
|
||||
sessionPending?: Record<string, MockPermission[]>
|
||||
// Counts of /api/permission/request calls to fail with a 500, keyed by origin.
|
||||
listFailures?: Record<string, number>
|
||||
// Records /api/session/:id GETs so tests can assert session resyncs.
|
||||
sessionGets?: string[]
|
||||
}
|
||||
|
||||
async function mockServers(
|
||||
page: Page,
|
||||
permissionRequests: string[],
|
||||
permissionResponses: PermissionResponse[] = [],
|
||||
options: MockServerOptions = {},
|
||||
) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
@@ -178,8 +315,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
permissionID: response[2]!,
|
||||
body: route.request().postDataJSON(),
|
||||
})
|
||||
return json(route, true)
|
||||
// The generated client requires exactly 204 for a successful reply.
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
const sessionPermission = url.pathname.match(/^\/api\/session\/([^/]+)\/permission$/)
|
||||
if (route.request().method() === "GET" && sessionPermission)
|
||||
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/provider")
|
||||
return json(route, {
|
||||
@@ -197,7 +338,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/permission/request") {
|
||||
permissionRequests.push(url.toString())
|
||||
return json(route, { location: { directory }, data: [] })
|
||||
const failures = options.listFailures?.[url.origin] ?? 0
|
||||
if (failures > 0) {
|
||||
options.listFailures![url.origin] = failures - 1
|
||||
return json(route, { name: "Internal" }, 500)
|
||||
}
|
||||
return json(route, { location: { directory }, data: options.pending?.[url.origin] ?? [] })
|
||||
}
|
||||
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
|
||||
return json(route, { location: { directory }, data: [] })
|
||||
@@ -219,9 +365,13 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: Object.fromEntries(sessions.map((session) => [session.id, { type: "running" }])) })
|
||||
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (currentSessionInfo) {
|
||||
options.sessionGets?.push(currentSessionInfo.id)
|
||||
return json(route, { data: currentSession(currentSessionInfo) })
|
||||
}
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
|
||||
import { createEffect, createMemo, startTransition } from "solid-js"
|
||||
@@ -8,12 +8,11 @@ import { createEffect, createMemo, startTransition } from "solid-js"
|
||||
export function createHomeController() {
|
||||
const layout = useLayout()
|
||||
const global = useGlobal()
|
||||
const servers = useServers()
|
||||
const tabs = useTabs()
|
||||
const selection = layout.home.selection
|
||||
const focusedServer = createMemo<ServerConnection.Any | undefined>(
|
||||
() =>
|
||||
global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ??
|
||||
global.servers.list()[0],
|
||||
() => servers.visible.find((conn) => ServerConnection.key(conn) === selection().server) ?? servers.visible[0],
|
||||
)
|
||||
const focusedServerCtx = useServerCtx(focusedServer)
|
||||
const focusedSync = () => focusedServerCtx()?.sync
|
||||
@@ -29,7 +28,7 @@ export function createHomeController() {
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const list = global.servers.list()
|
||||
const list = servers.visible
|
||||
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
|
||||
const conn = list[0]
|
||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||
@@ -54,7 +53,7 @@ export function createHomeController() {
|
||||
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
|
||||
},
|
||||
server: {
|
||||
list: global.servers.list,
|
||||
list: () => servers.visible,
|
||||
health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
|
||||
context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn),
|
||||
focused: focusedServer,
|
||||
|
||||
@@ -61,6 +61,8 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
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)),
|
||||
canHide: (conn: ServerConnection.Any) => serverManagement.connection.canHide(ServerConnection.key(conn)),
|
||||
hide: (conn: ServerConnection.Any) => serverManagement.connection.setHidden(ServerConnection.key(conn), true),
|
||||
edit: (conn: ServerConnection.Http) => {
|
||||
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
|
||||
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
|
||||
|
||||
@@ -26,6 +26,8 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
|
||||
onSetDefaultServer={props.projects.server.setDefault}
|
||||
canRemoveServer={props.projects.server.canRemove}
|
||||
onRemoveServer={props.projects.server.remove}
|
||||
canHideServer={props.projects.server.canHide}
|
||||
onHideServer={props.projects.server.hide}
|
||||
onMoveProject={props.projects.project.move}
|
||||
onSelectProject={props.projects.project.select}
|
||||
onAddProjects={props.projects.project.add}
|
||||
|
||||
@@ -49,6 +49,8 @@ export type HomeProjectsViewProps = {
|
||||
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
|
||||
canRemoveServer: (server: ServerConnection.Any) => boolean
|
||||
onRemoveServer: (server: ServerConnection.Any) => void
|
||||
canHideServer: (server: ServerConnection.Any) => boolean
|
||||
onHideServer: (server: ServerConnection.Any) => void
|
||||
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
|
||||
onSelectProject: (server: ServerConnection.Any, directory: string) => void
|
||||
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
|
||||
@@ -188,6 +190,8 @@ function HomeServerRow(props: {
|
||||
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
|
||||
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
|
||||
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
|
||||
canHideServer: HomeProjectsViewProps["canHideServer"]
|
||||
onHideServer: HomeProjectsViewProps["onHideServer"]
|
||||
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
|
||||
onChooseProject: HomeProjectsViewProps["onChooseProject"]
|
||||
server: ServerConnection.Any
|
||||
@@ -212,11 +216,11 @@ function HomeServerRow(props: {
|
||||
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
|
||||
>
|
||||
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="pr-16"
|
||||
classList={{ "opacity-60": !healthy() && !incompatible() }}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="pr-16"
|
||||
classList={{ "opacity-60": !healthy() && !incompatible() }}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
disabled={!healthy()}
|
||||
onClick={() => props.onFocusServer(props.server)}
|
||||
>
|
||||
@@ -284,10 +288,12 @@ function HomeServerRow(props: {
|
||||
canDefault={props.canDefaultServer}
|
||||
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
|
||||
canRemove={props.canRemoveServer(props.server)}
|
||||
canHide={props.canHideServer(props.server)}
|
||||
onEdit={props.onEditServer}
|
||||
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
||||
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
|
||||
onRemove={() => props.onRemoveServer(props.server)}
|
||||
onHide={() => props.onHideServer(props.server)}
|
||||
open={props.contextMenuOpen(contextMenuID())}
|
||||
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
|
||||
/>
|
||||
|
||||
@@ -373,7 +373,6 @@ export function HomeSessionStatusController(props: {
|
||||
}) {
|
||||
const avatar = useSessionTabAvatarState(
|
||||
() => props.server,
|
||||
() => props.record.session.location.directory,
|
||||
() => props.record.session.id,
|
||||
() => true,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ import { createComposerControls, createComposerModelSelection } from "@/composer
|
||||
import { createComposerProjectControls } from "./project/controller"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { usePermission } from "@/session/requests/permission"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
@@ -30,7 +29,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const serverSDK = useServerSDK()
|
||||
const permission = usePermission()
|
||||
const tabs = useTabs()
|
||||
const location = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
@@ -86,9 +84,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
)
|
||||
const cleanupReady = startTransition(() => {
|
||||
tabs.updateDraft(props.draftID, { worktree: undefined })
|
||||
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
|
||||
permission.enableAutoAccept(created.id, sessionDirectory)
|
||||
}
|
||||
local.session.promote(sessionDirectory, created.id, {
|
||||
agent: selection.agent,
|
||||
model: selection.model,
|
||||
|
||||
@@ -18,13 +18,23 @@ describe("new session workspace selection", () => {
|
||||
).toBe("main")
|
||||
})
|
||||
|
||||
test("derives an existing worktree from the current directory", () => {
|
||||
test("uses the saved destination instead of the current worktree", () => {
|
||||
expect(
|
||||
resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }),
|
||||
).toBe("/project/feature")
|
||||
expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe(
|
||||
"main",
|
||||
)
|
||||
resolveNewSessionWorktree({
|
||||
enabled: true,
|
||||
directory: "/project/feature",
|
||||
projectWorktree: "/project",
|
||||
fallback: "create",
|
||||
}),
|
||||
).toBe("create")
|
||||
expect(
|
||||
resolveNewSessionWorktree({
|
||||
enabled: true,
|
||||
directory: "/project/feature",
|
||||
projectWorktree: "/project",
|
||||
fallback: "main",
|
||||
}),
|
||||
).toBe("/project")
|
||||
})
|
||||
|
||||
test("normalizes main to the project root outside the main worktree", () => {
|
||||
@@ -33,9 +43,9 @@ describe("new session workspace selection", () => {
|
||||
})
|
||||
|
||||
test("treats equivalent Windows roots as the main worktree", () => {
|
||||
expect(resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" })).toBe(
|
||||
"main",
|
||||
)
|
||||
expect(
|
||||
resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" }),
|
||||
).toBe("main")
|
||||
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
|
||||
})
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ export function resolveNewSessionWorktree(input: {
|
||||
}) {
|
||||
if (!input.enabled) return "main"
|
||||
if (input.selected) return input.selected
|
||||
if (input.projectWorktree && !sameDirectory(input.directory, input.projectWorktree)) return input.directory
|
||||
return input.fallback ?? "main"
|
||||
return normalizeNewSessionWorktree(input.fallback ?? "main", input.directory, input.projectWorktree)
|
||||
}
|
||||
|
||||
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
||||
@@ -121,7 +120,6 @@ export function createNewSessionWorkspaceController(input: {
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
|
||||
remember(worktree)
|
||||
},
|
||||
},
|
||||
project: {
|
||||
|
||||
@@ -374,6 +374,8 @@ export const dict = {
|
||||
"dialog.server.menu.default": "Set as default",
|
||||
"dialog.server.menu.defaultRemove": "Remove default",
|
||||
"dialog.server.menu.delete": "Delete",
|
||||
"dialog.server.menu.hide": "Hide from project list",
|
||||
"dialog.server.menu.show": "Show in project list",
|
||||
"dialog.server.current": "Current Server",
|
||||
"dialog.server.status.default": "Default",
|
||||
"wsl.server.add": "Add WSL server",
|
||||
|
||||
@@ -114,11 +114,12 @@ export function createBrowserDraftStore(): DraftStore {
|
||||
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
|
||||
return item
|
||||
})
|
||||
const blobs = transaction.objectStore("blobs").openKeyCursor()
|
||||
const store = transaction.objectStore("blobs")
|
||||
const blobs = store.openKeyCursor()
|
||||
blobs.addEventListener("success", () => {
|
||||
const cursor = blobs.result
|
||||
if (!cursor) return
|
||||
if (!used.has(String(cursor.key))) cursor.delete()
|
||||
if (!used.has(String(cursor.key))) store.delete(cursor.key)
|
||||
cursor.continue()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Platform, usePlatform } from "@/runtime/platform/platform"
|
||||
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createResource, type Accessor } from "solid-js"
|
||||
import { createResource, onCleanup, type Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
|
||||
@@ -16,6 +16,7 @@ type PersistedWithReady<T> = [
|
||||
|
||||
type PersistTarget = {
|
||||
draft?: boolean
|
||||
sync?: boolean
|
||||
storage?: string
|
||||
scope?: "window"
|
||||
workspaceStorageAliases?: string[]
|
||||
@@ -637,7 +638,17 @@ export function persisted<T>(
|
||||
return api
|
||||
})()
|
||||
|
||||
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
|
||||
const channel =
|
||||
config.sync && typeof BroadcastChannel !== "undefined"
|
||||
? new BroadcastChannel(`opencode.persist:${config.storage ?? "default"}:${config.key}`)
|
||||
: undefined
|
||||
if (channel) onCleanup(() => channel.close())
|
||||
|
||||
const [state, setState, init] = makePersisted(store, {
|
||||
name: config.key,
|
||||
storage,
|
||||
sync: channel ? messageSync(channel) : undefined,
|
||||
})
|
||||
|
||||
const isAsync = init instanceof Promise
|
||||
const [ready] = createResource(
|
||||
|
||||
@@ -104,6 +104,9 @@ type PlatformBase = {
|
||||
/** Read image from clipboard (desktop only) */
|
||||
readClipboardImage?(): Promise<File | null>
|
||||
|
||||
/** Write text to the native clipboard (desktop only) */
|
||||
writeClipboardText?(text: string): Promise<void>
|
||||
|
||||
/** Export collected diagnostic logs (desktop only) */
|
||||
exportDebugLogs?(): Promise<string>
|
||||
|
||||
|
||||
@@ -264,11 +264,13 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
const [store, setStore, _] = persisted(
|
||||
{
|
||||
...Persist.global("server"),
|
||||
sync: true,
|
||||
previousKey: "server.v3",
|
||||
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
|
||||
},
|
||||
createStore({
|
||||
list: [] as StoredServer[],
|
||||
hidden: {} as Record<string, boolean>,
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
recentlyClosed: {} as Record<string, string[]>,
|
||||
@@ -280,6 +282,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||
return resolveServerList({ stored: store.list, props: props.servers })
|
||||
})
|
||||
const visibleServers = createMemo(() => allServers().filter((conn) => !store.hidden[ServerConnection.key(conn)]))
|
||||
|
||||
function add(input: ServerConnection.Http) {
|
||||
const url_ = normalizeServerUrl(input.http.url)
|
||||
@@ -321,6 +324,15 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
get list() {
|
||||
return allServers()
|
||||
},
|
||||
get visible() {
|
||||
return visibleServers()
|
||||
},
|
||||
isHidden(key: ServerConnection.Key) {
|
||||
return store.hidden[key] ?? false
|
||||
},
|
||||
setHidden(key: ServerConnection.Key, hidden: boolean) {
|
||||
setStore("hidden", key, hidden)
|
||||
},
|
||||
add,
|
||||
remove,
|
||||
canRemove,
|
||||
|
||||
@@ -8,9 +8,8 @@ import { createServerSdkContext } from "./client"
|
||||
import { createServerSyncContext } from "./sync"
|
||||
import { createData } from "@opencode-ai/client/solid"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createServerPermissionState } from "@/session/requests/server-permission"
|
||||
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
|
||||
import { createServerNotificationState } from "@/shell/notifications/notification"
|
||||
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
@@ -27,7 +26,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
})
|
||||
const models = createGlobalModels()
|
||||
const notificationCoordinator = createNotificationCoordinator()
|
||||
|
||||
const settingsServer = createMemo(() => {
|
||||
const list = server.list
|
||||
@@ -52,7 +50,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
if (existing) return existing
|
||||
const serverCtx = createRoot((dispose) => {
|
||||
serverCtxDisposers.set(key, dispose)
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key))
|
||||
}, owner)
|
||||
serverCtxs.set(key, serverCtx)
|
||||
return serverCtx
|
||||
@@ -133,7 +131,6 @@ function createServerController(
|
||||
conn: ServerConnection.Any,
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
|
||||
) {
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
@@ -147,8 +144,8 @@ function createServerController(
|
||||
directory: "",
|
||||
})
|
||||
const sync = createServerSyncContext(sdk, data)
|
||||
const permission = createServerPermissionState({ sdk, sync, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
|
||||
createPermissionAutoApprover({ sdk, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||
@@ -190,7 +187,6 @@ function createServerController(
|
||||
list: projectsList,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
permission,
|
||||
notification,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,19 @@ export function useServerActionsController() {
|
||||
}
|
||||
}
|
||||
|
||||
return { defaults, connection: { canRemove: server.canRemove, remove } }
|
||||
return {
|
||||
defaults,
|
||||
connection: {
|
||||
canRemove: server.canRemove,
|
||||
remove,
|
||||
canHide: (key: ServerConnection.Key) => {
|
||||
const conn = server.list.find((item) => ServerConnection.key(item) === key)
|
||||
return server.visible.length > 1 && !!conn && ServerConnection.builtin(conn)
|
||||
},
|
||||
isHidden: server.isHidden,
|
||||
setHidden: server.setHidden,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
||||
|
||||
@@ -22,10 +22,14 @@ export const ServerRowMenu: Component<{
|
||||
canDefault={props.domain.defaults.available()}
|
||||
isDefault={props.domain.defaults.key() === key}
|
||||
canRemove={props.domain.connection.canRemove(key)}
|
||||
canHide={props.domain.connection.canHide(key)}
|
||||
hidden={props.domain.connection.isHidden(key)}
|
||||
onEdit={props.onEdit}
|
||||
onSetDefault={() => props.domain.defaults.set(key)}
|
||||
onRemoveDefault={() => props.domain.defaults.set(null)}
|
||||
onRemove={() => props.domain.connection.remove(key)}
|
||||
onHide={() => props.domain.connection.setHidden(key, true)}
|
||||
onShow={() => props.domain.connection.setHidden(key, false)}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
@@ -40,6 +44,8 @@ export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
|
||||
default: language.t("dialog.server.menu.default"),
|
||||
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
|
||||
delete: language.t("dialog.server.menu.delete"),
|
||||
hide: language.t("dialog.server.menu.hide"),
|
||||
show: language.t("dialog.server.menu.show"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,10 +55,14 @@ export const ServerRowMenuView: Component<{
|
||||
canDefault: boolean
|
||||
isDefault: boolean
|
||||
canRemove: boolean
|
||||
canHide?: boolean
|
||||
hidden?: boolean
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
onSetDefault: () => void
|
||||
onRemoveDefault: () => void
|
||||
onRemove: () => void
|
||||
onHide?: () => void
|
||||
onShow?: () => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}> = (props) => {
|
||||
@@ -86,6 +96,12 @@ export const ServerRowMenuView: Component<{
|
||||
<Show when={props.canDefault && props.isDefault}>
|
||||
<Menu.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.hidden}>
|
||||
<Menu.Item onSelect={props.onShow}>{props.labels.show}</Menu.Item>
|
||||
</Show>
|
||||
<Show when={!props.hidden && props.canHide}>
|
||||
<Menu.Item onSelect={props.onHide}>{props.labels.hide}</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.canRemove}>
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item>
|
||||
|
||||
@@ -4,14 +4,13 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
|
||||
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { usePermission } from "@/session/requests/permission"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useTerminal } from "@/session/terminal/context"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import type { SessionRevert } from "@/session/revert"
|
||||
|
||||
@@ -47,12 +46,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const dialog = useDialog()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const prompt = useComposerState()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const settings = useSettings()
|
||||
const terminal = useTerminal()
|
||||
const platform = usePlatform()
|
||||
const layout = useLayout()
|
||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||
const owner = actions.session.ownership.capture()
|
||||
@@ -97,11 +95,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const mcpCommand = withCategory(language.t("command.category.mcp"))
|
||||
const permissionsCommand = withCategory(language.t("command.category.permissions"))
|
||||
|
||||
const isAutoAcceptActive = () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
|
||||
return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||
}
|
||||
const exportSession = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
@@ -131,7 +124,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(sessionID)
|
||||
await (platform.writeClipboardText?.(sessionID) ?? navigator.clipboard.writeText(sessionID))
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
@@ -151,7 +144,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const projectID = actions.session.data.info()?.projectID
|
||||
if (!projectID) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(projectID)
|
||||
await (platform.writeClipboardText?.(projectID) ?? navigator.clipboard.writeText(projectID))
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
@@ -221,13 +214,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const toggleAutoAccept = () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
|
||||
else permission.toggleAutoAcceptDirectory(sdk().directory)
|
||||
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk().directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk().directory)
|
||||
const active = !settings.permissions.autoApprove()
|
||||
settings.permissions.setAutoApprove(active)
|
||||
showToast({
|
||||
title: active
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
@@ -452,7 +440,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const permissionsCmds = () => [
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title: isAutoAcceptActive()
|
||||
title: settings.permissions.autoApprove()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import type { PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import { useSettings } from "@/settings/model"
|
||||
|
||||
const respondedLimit = 1000
|
||||
const retryLimit = 2
|
||||
const retryDelayMs = 1000
|
||||
|
||||
// Auto-approves permission requests on one server connection whenever the
|
||||
// app-level auto-approve setting is on. The setting lives in the client-local
|
||||
// settings store, so it applies to every session, tab, and server at once.
|
||||
export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data }) {
|
||||
const enabled = useSettings().permissions.autoApprove
|
||||
const state = { disposed: false, generation: 0, responded: new Set<string>() }
|
||||
|
||||
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
|
||||
if (enabled()) approve(event.data)
|
||||
})
|
||||
onCleanup(() => {
|
||||
state.disposed = true
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
// The event stream does not replay requests asked while this client was
|
||||
// disconnected, and requests may already be pending before the setting turns
|
||||
// on, so sweep on every connect while the setting is on.
|
||||
createEffect(() => {
|
||||
if (!enabled() || input.sdk.connection.status() !== "connected") return
|
||||
const generation = ++state.generation
|
||||
void sweepWithRetry(generation, 0)
|
||||
})
|
||||
|
||||
// Approves pending requests that reach the local store, which is how a
|
||||
// previously unknown idle session's requests surface when its view opens
|
||||
// and syncs them. Store changes cannot re-trigger the network sweep: it
|
||||
// deliberately reads them after an await, outside Solid tracking.
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
for (const session of input.data.session.list()) {
|
||||
for (const request of input.data.session.permission.list(session.id) ?? []) approve(request)
|
||||
}
|
||||
})
|
||||
|
||||
// An incomplete sweep leaves pending requests hidden with no later trigger
|
||||
// to recover them, so retry it a bounded number of times. A newer sweep
|
||||
// supersedes scheduled retries.
|
||||
async function sweepWithRetry(generation: number, attempt: number) {
|
||||
const complete = await sweep()
|
||||
if (complete || attempt >= retryLimit) return
|
||||
setTimeout(() => {
|
||||
if (state.disposed || !enabled() || generation !== state.generation) return
|
||||
void sweepWithRetry(generation, attempt + 1)
|
||||
}, retryDelayMs * (attempt + 1))
|
||||
}
|
||||
|
||||
async function sweep() {
|
||||
const inventory = await sweepLocations()
|
||||
const listed = await Promise.all(
|
||||
inventory.locations.map((location) =>
|
||||
input.sdk.api.permission.request
|
||||
.list({ location: { directory: location.directory, workspace: location.workspaceID } })
|
||||
.then((pending) => {
|
||||
if (!state.disposed) pending.data.forEach((request) => approve(request))
|
||||
return true
|
||||
})
|
||||
.catch(() => false),
|
||||
),
|
||||
)
|
||||
return inventory.complete && listed.every(Boolean)
|
||||
}
|
||||
|
||||
// Active sessions are the primary inventory: session.active is server-wide,
|
||||
// so it covers sessions no tab has loaded, and a request blocking a tool
|
||||
// call always belongs to one (Permission.assert clears its entry when the
|
||||
// awaiting fiber dies). Locally known sessions are swept too because the
|
||||
// external session.permission.create API can park a request on an idle
|
||||
// session. A detached request on a session this client never loaded is the
|
||||
// one case that stays uncovered.
|
||||
async function sweepLocations() {
|
||||
const active = await input.sdk.api.session.active().catch(() => undefined)
|
||||
const ids = Object.keys(active ?? {})
|
||||
// Resync every active session rather than trusting cached info: another
|
||||
// client may have moved one while this client was disconnected, and the
|
||||
// cached location would list permissions from the old location. A failed
|
||||
// resync falls back to the cached location and marks the sweep incomplete.
|
||||
const synced = await Promise.all(
|
||||
ids.map((id) => {
|
||||
input.data.session.invalidate(id)
|
||||
return input.data.session.sync(id).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}),
|
||||
)
|
||||
const locations = [
|
||||
...ids.flatMap((id) => {
|
||||
const location = input.data.session.get(id)?.location
|
||||
return location ? [location] : []
|
||||
}),
|
||||
...input.data.session.list().map((session) => session.location),
|
||||
]
|
||||
return {
|
||||
locations: [
|
||||
...new Map(locations.map((item) => [`${item.directory}\u0000${item.workspaceID ?? ""}`, item])).values(),
|
||||
],
|
||||
complete: active !== undefined && synced.every(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
function approve(permission: PermissionRequest, attempt = 0) {
|
||||
// enabled() guards the retry timer path: the user may disable the setting
|
||||
// between a failed reply and its scheduled retry.
|
||||
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
|
||||
remember(permission.id)
|
||||
input.sdk.api.permission
|
||||
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
|
||||
.catch(() => {
|
||||
// A reply failure leaves the request pending but invisible (the UI
|
||||
// hides prompts while auto-approve is on), so retry a bounded number
|
||||
// of times. Later sweeps retry it after that.
|
||||
state.responded.delete(permission.id)
|
||||
if (state.disposed || attempt >= retryLimit) return
|
||||
setTimeout(() => approve(permission, attempt + 1), retryDelayMs * (attempt + 1))
|
||||
})
|
||||
}
|
||||
|
||||
function remember(id: string) {
|
||||
state.responded.add(id)
|
||||
for (const oldest of state.responded) {
|
||||
if (state.responded.size <= respondedLimit) break
|
||||
state.responded.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting, relocateAutoAccept, sessionAutoAccept } from "./auto-respond"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
}) as SessionInfo
|
||||
|
||||
const permission = (sessionID: string) =>
|
||||
({
|
||||
sessionID,
|
||||
}) as Pick<PermissionRequest, "sessionID">
|
||||
|
||||
describe("autoRespondsPermission", () => {
|
||||
test("uses a parent session's directory-scoped auto-accept", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/root`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("defaults to requiring approval when no lineage override exists", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
||||
const autoAccept = {
|
||||
other: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
|
||||
})
|
||||
|
||||
test("inherits a parent session's false override", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
|
||||
})
|
||||
|
||||
test("prefers a child override over parent override", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
[`${base64Encode(directory)}/child`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back to directory-level auto-accept", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("session-level override takes precedence over directory-level", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||
})
|
||||
|
||||
test("parent false override takes precedence over directory-level auto-accept", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
|
||||
})
|
||||
|
||||
test("parent true override takes precedence over disabled directory fallback", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: false,
|
||||
[`${base64Encode(directory)}/root`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isDirectoryAutoAccepting", () => {
|
||||
test("returns true when directory key is set", () => {
|
||||
const directory = "/tmp/project"
|
||||
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
|
||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when directory key is not set", () => {
|
||||
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when directory key is explicitly false", () => {
|
||||
const directory = "/tmp/project"
|
||||
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
|
||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("relocates bare session settings when the directory becomes known", () => {
|
||||
const directory = "/tmp/project"
|
||||
expect(relocateAutoAccept({ root: true }, [{ id: "root" }], directory)).toEqual({
|
||||
[`${base64Encode(directory)}/root`]: true,
|
||||
})
|
||||
expect(
|
||||
relocateAutoAccept({ root: true, [`${base64Encode(directory)}/root`]: false }, [{ id: "root" }], directory),
|
||||
).toEqual({ [`${base64Encode(directory)}/root`]: false })
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export function acceptKey(sessionID: string, directory?: string) {
|
||||
if (!directory) return sessionID
|
||||
return `${base64Encode(directory)}/${sessionID}`
|
||||
}
|
||||
|
||||
export function directoryAcceptKey(directory: string) {
|
||||
return `${base64Encode(directory)}/*`
|
||||
}
|
||||
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
return autoAccept[acceptKey(sessionID, directory)]
|
||||
}
|
||||
|
||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
return autoAccept[key] ?? false
|
||||
}
|
||||
|
||||
export function relocateAutoAccept(
|
||||
autoAccept: Record<string, boolean>,
|
||||
sessions: readonly { id: string }[],
|
||||
directory: string,
|
||||
) {
|
||||
const moves = sessions.flatMap((session) => {
|
||||
const value = autoAccept[session.id]
|
||||
if (value === undefined) return []
|
||||
return [{ source: session.id, target: acceptKey(session.id, directory), value }]
|
||||
})
|
||||
if (moves.length === 0) return autoAccept
|
||||
|
||||
const next = { ...autoAccept }
|
||||
for (const move of moves) {
|
||||
if (next[move.target] === undefined) next[move.target] = move.value
|
||||
delete next[move.source]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
||||
const parent = session.reduce((acc, item) => {
|
||||
if (item.parentID) acc.set(item.id, item.parentID)
|
||||
return acc
|
||||
}, new Map<string, string>())
|
||||
const seen = new Set([sessionID])
|
||||
const ids = [sessionID]
|
||||
|
||||
for (const id of ids) {
|
||||
const parentID = parent.get(id)
|
||||
if (!parentID || seen.has(parentID)) continue
|
||||
seen.add(parentID)
|
||||
ids.push(parentID)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
export function autoRespondsPermission(
|
||||
autoAccept: Record<string, boolean>,
|
||||
session: { id: string; parentID?: string }[],
|
||||
permission: { sessionID: string },
|
||||
directory?: string,
|
||||
) {
|
||||
const value = sessionAutoAccept(autoAccept, session, permission, directory)
|
||||
if (value !== undefined) return value
|
||||
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
|
||||
}
|
||||
|
||||
export function sessionAutoAccept(
|
||||
autoAccept: Record<string, boolean>,
|
||||
session: { id: string; parentID?: string }[],
|
||||
permission: { sessionID: string },
|
||||
directory?: string,
|
||||
) {
|
||||
return sessionLineage(session, permission.sessionID)
|
||||
.map((id) => accepted(autoAccept, id, directory))
|
||||
.find((item): item is boolean => item !== undefined)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePermission } from "@/session/requests/permission"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
@@ -16,7 +16,7 @@ export function createSessionRequestModel() {
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const settings = useSettings()
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
@@ -32,9 +32,8 @@ export function createSessionRequestModel() {
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk().directory)
|
||||
})
|
||||
if (settings.permissions.autoApprove()) return undefined
|
||||
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id)
|
||||
})
|
||||
|
||||
const blocked = createMemo(() => {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
export const usePermission = () => useServer().ctx.permission
|
||||
@@ -1,348 +0,0 @@
|
||||
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { ServerSync } from "@/runtime/server/sync"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { type DraftTab, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import {
|
||||
acceptKey,
|
||||
directoryAcceptKey,
|
||||
isDirectoryAutoAccepting,
|
||||
autoRespondsPermission,
|
||||
relocateAutoAccept,
|
||||
sessionAutoAccept,
|
||||
} from "./auto-respond"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
response: "once" | "always" | "reject"
|
||||
directory?: string
|
||||
}) => void
|
||||
|
||||
function isNonAllowRule(rule: unknown) {
|
||||
if (!rule) return false
|
||||
if (typeof rule === "string") return rule !== "allow"
|
||||
if (typeof rule !== "object") return false
|
||||
if (Array.isArray(rule)) return false
|
||||
|
||||
for (const action of Object.values(rule)) {
|
||||
if (action !== "allow") return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function hasPermissionPromptRules(permission: unknown) {
|
||||
if (!permission) return false
|
||||
if (typeof permission === "string") return permission !== "allow"
|
||||
if (typeof permission !== "object") return false
|
||||
if (Array.isArray(permission)) return false
|
||||
|
||||
const config = permission as Record<string, unknown>
|
||||
return Object.values(config).some(isNonAllowRule)
|
||||
}
|
||||
|
||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(input.sdk.scope, "permission"),
|
||||
...(input.sdk.scope === ServerScope.local ? { previousKey: "permission.v3" } : {}),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
if (data.autoAccept) return value
|
||||
|
||||
return {
|
||||
...data,
|
||||
autoAccept:
|
||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||
? data.autoAcceptEdits
|
||||
: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
createStore({
|
||||
autoAccept: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
function enableConfiguredDirectory(directory: string) {
|
||||
if (meta.disposed || !ready()) return
|
||||
const [childStore] = input.sync.child(directory)
|
||||
if (childStore.config.permission !== "allow") return
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] !== undefined) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
const enableVersion = new Map<string, number>()
|
||||
const meta = { disposed: false }
|
||||
|
||||
function pruneResponded(now: number) {
|
||||
for (const [id, ts] of responded) {
|
||||
if (now - ts < RESPONDED_TTL_MS) break
|
||||
responded.delete(id)
|
||||
}
|
||||
|
||||
for (const id of responded.keys()) {
|
||||
if (responded.size <= MAX_RESPONDED) break
|
||||
responded.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (request) => {
|
||||
if (meta.disposed) return
|
||||
input.sdk.api.permission
|
||||
.reply({
|
||||
sessionID: request.sessionID,
|
||||
requestID: request.permissionID,
|
||||
reply: request.response,
|
||||
})
|
||||
.catch(() => {
|
||||
responded.delete(request.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
const list = async (directory: string) => {
|
||||
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data)
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
const now = Date.now()
|
||||
const hit = responded.has(permission.id)
|
||||
responded.delete(permission.id)
|
||||
responded.set(permission.id, now)
|
||||
pruneResponded(now)
|
||||
if (hit) return
|
||||
respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "once",
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function sessions(_directory?: string) {
|
||||
return input.data.session.list()
|
||||
}
|
||||
|
||||
function autoAccept(directory?: string) {
|
||||
if (!directory) return store.autoAccept
|
||||
const next = relocateAutoAccept(store.autoAccept, sessions(directory), directory)
|
||||
if (next !== store.autoAccept) setStore("autoAccept", reconcile(next))
|
||||
return next
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
return autoRespondsPermission(autoAccept(directory), sessions(directory), { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
return autoRespondsPermission(autoAccept(directory), sessions(directory), permission, directory)
|
||||
}
|
||||
|
||||
function isPending(permission: PermissionRequest) {
|
||||
const pending = input.data.session.permission.list(permission.sessionID)
|
||||
return pending === undefined || pending.some((item) => item.id === permission.id)
|
||||
}
|
||||
|
||||
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
||||
const override = sessionAutoAccept(autoAccept(directory), sessions(directory), permission, directory)
|
||||
if (override !== undefined) return override
|
||||
const loaded = new Set<string>()
|
||||
while (!loaded.has(input.data.session.root(permission.sessionID))) {
|
||||
const root = input.data.session.root(permission.sessionID)
|
||||
loaded.add(root)
|
||||
if (input.data.session.get(root)) break
|
||||
await input.data.session.sync(root).catch(() => undefined)
|
||||
}
|
||||
if (meta.disposed || !input.data.session.get(permission.sessionID)) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
}
|
||||
|
||||
async function respondPending(
|
||||
permission: PermissionRequest,
|
||||
directory?: string,
|
||||
current: () => boolean = () => true,
|
||||
) {
|
||||
if (!current() || !isPending(permission)) return
|
||||
if (!(await shouldAutoRespondResolved(permission, directory))) return
|
||||
if (meta.disposed || !current() || !isPending(permission)) return
|
||||
respondOnce(permission, directory)
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const next = (enableVersion.get(key) ?? 0) + 1
|
||||
enableVersion.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
|
||||
if (ready()) {
|
||||
void respondPending(event.data, event.location?.directory)
|
||||
return
|
||||
}
|
||||
void ready.promise?.then(() => {
|
||||
if (meta.disposed) return
|
||||
void respondPending(event.data, event.location?.directory)
|
||||
})
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
list(directory)
|
||||
.then((permissions) => {
|
||||
if (meta.disposed) return
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const permission of permissions) {
|
||||
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
list(directory)
|
||||
.then((permissions) => {
|
||||
if (meta.disposed) return
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const permission of permissions) {
|
||||
void respondPending(
|
||||
permission,
|
||||
directory,
|
||||
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return
|
||||
bumpEnableVersion(sessionID, directory)
|
||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
if (!directory) return
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const api = {
|
||||
ready: () => !meta.disposed && ready(),
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
if (meta.disposed) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
},
|
||||
isAutoAccepting(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return false
|
||||
return isAutoAccepting(sessionID, directory)
|
||||
},
|
||||
isAutoAcceptingDirectory(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
return isAutoAcceptingDirectory(directory)
|
||||
},
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (meta.disposed) return
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
disableAutoAccept(sessionID: string, directory?: string) {
|
||||
if (meta.disposed) return
|
||||
disable(sessionID, directory)
|
||||
},
|
||||
isPermissionAllowAll(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
const [childStore] = input.sync.child(directory)
|
||||
return childStore.config.permission === "allow"
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...api,
|
||||
api,
|
||||
sync: input.sync,
|
||||
enableConfiguredDirectory,
|
||||
permissionsEnabled(directory: string) {
|
||||
if (meta.disposed) return false
|
||||
const [childStore] = input.sync.child(directory)
|
||||
return hasPermissionPromptRules(childStore.config.permission)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,14 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
input.session.data.info()?.revert?.messageID,
|
||||
)
|
||||
})
|
||||
const pendingUserMessageIDs = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
return new Set(
|
||||
(id ? data.session.pending.list(id) : []).flatMap((item) =>
|
||||
item.type === "user" && item.delivery === "steer" ? [item.id] : [],
|
||||
),
|
||||
)
|
||||
})
|
||||
const titleValue = createMemo(() => input.session.data.info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
|
||||
const parentMessages = createMemo(() => {
|
||||
@@ -97,6 +105,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
sessionMessages: projectedMessages,
|
||||
status: input.session.data.status,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
pendingUserMessageIDs,
|
||||
})
|
||||
const [pending, setPending] = createStore({ rename: false })
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export function createTimelineProjection(input: {
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
|
||||
}) {
|
||||
const sessionMessageByID = createMemo(
|
||||
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
|
||||
@@ -75,7 +76,12 @@ export function createTimelineProjection(input: {
|
||||
return result
|
||||
})
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status()),
|
||||
Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
input.showReasoningSummaries(),
|
||||
input.status(),
|
||||
input.pendingUserMessageIDs(),
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -6,7 +5,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
export function useSettingsDialog(defaultValue?: string) {
|
||||
const dialog = useDialog()
|
||||
const params = useParams<{ id?: string }>()
|
||||
let run = 0
|
||||
let dead = false
|
||||
|
||||
@@ -16,10 +14,9 @@ export function useSettingsDialog(defaultValue?: string) {
|
||||
|
||||
return () => {
|
||||
const current = ++run
|
||||
const sessionID = params.id
|
||||
void import("@/settings/shell").then((module) => {
|
||||
if (dead || run !== current) return
|
||||
void dialog.show(() => <module.DialogSettings sessionID={sessionID} defaultValue={defaultValue} />)
|
||||
void dialog.show(() => <module.DialogSettings defaultValue={defaultValue} />)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
||||
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { usePermission } from "@/session/requests/permission"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -17,43 +16,11 @@ import {
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
|
||||
import { createSoundPreviewController, type ShellOption } from "./behavior"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useServerCtx } from "@/runtime/server/runtime"
|
||||
|
||||
export { createShellOptions, createSoundPreviewController } from "./behavior"
|
||||
export type { ShellOption, ShellSelectOption } from "./behavior"
|
||||
|
||||
export function createPermissionScopeController(
|
||||
server: Accessor<ServerConnection.Any | undefined>,
|
||||
sessionID: Accessor<string | undefined>,
|
||||
) {
|
||||
const serverCtx = useServerCtx(server)
|
||||
const permission = () => serverCtx()?.permission
|
||||
|
||||
const directory = createMemo(() => {
|
||||
const s = server()
|
||||
const id = sessionID()
|
||||
if (!s || !id) return undefined
|
||||
return serverCtx()?.data.session.get(id)?.location.directory
|
||||
})
|
||||
|
||||
return {
|
||||
accepting: createMemo(() => {
|
||||
const id = sessionID()
|
||||
const dir = directory()
|
||||
if (!id || !dir) return false
|
||||
return permission()?.isAutoAccepting(id, dir)
|
||||
}),
|
||||
enabled: createMemo(() => !!directory()),
|
||||
set: (checked: boolean) => {
|
||||
const id = sessionID()
|
||||
const dir = directory()
|
||||
if (!id || !dir) return
|
||||
if (checked) return permission()?.enableAutoAccept(id, dir)
|
||||
permission()?.disableAutoAccept(id, dir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
|
||||
const serverCtx = useServerCtx(server)
|
||||
const [shells] = createResource(
|
||||
@@ -172,7 +139,6 @@ export function createSoundSettingsController() {
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
|
||||
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
|
||||
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
|
||||
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
|
||||
|
||||
@@ -13,11 +13,9 @@ import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import {
|
||||
createAppearanceSettingsController,
|
||||
createPermissionScopeController,
|
||||
createShellOptions,
|
||||
createShellSettingsController,
|
||||
type AppearanceSettingsController,
|
||||
type PermissionScopeController,
|
||||
type ShellSettingsController,
|
||||
} from "./controllers"
|
||||
import "@/settings/settings.css"
|
||||
@@ -47,8 +45,9 @@ const fontSettings = {
|
||||
input: "setTerminal",
|
||||
},
|
||||
} as const
|
||||
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
||||
const AutoApprovePermissionsSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<SettingsRow
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
@@ -56,9 +55,8 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
|
||||
>
|
||||
<div data-action="settings-auto-accept-permissions">
|
||||
<Switch
|
||||
checked={props.controller.accepting()}
|
||||
disabled={!props.controller.enabled()}
|
||||
onChange={props.controller.set}
|
||||
checked={settings.permissions.autoApprove()}
|
||||
onChange={(checked) => settings.permissions.setAutoApprove(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
@@ -262,7 +260,6 @@ const LanguageSetting = () => {
|
||||
}
|
||||
|
||||
export const SettingsGeneral: Component<{
|
||||
sessionID?: string
|
||||
server?: ServerConnection.Any
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
@@ -270,10 +267,6 @@ export const SettingsGeneral: Component<{
|
||||
const settings = useSettings()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const updater = useUpdaterAction()
|
||||
const permissionScope = createPermissionScopeController(
|
||||
() => props.server,
|
||||
() => props.sessionID,
|
||||
)
|
||||
const shell = createShellSettingsController(() => props.server)
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
@@ -297,7 +290,7 @@ export const SettingsGeneral: Component<{
|
||||
<LanguageSetting />
|
||||
|
||||
<WorkspaceDestinationSetting />
|
||||
<PermissionScopeSetting controller={permissionScope} />
|
||||
<AutoApprovePermissionsSetting />
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
<TerminalPlacementSetting />
|
||||
|
||||
@@ -724,6 +724,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
@@ -885,6 +887,8 @@
|
||||
}
|
||||
|
||||
.settings-workspaces-inventory [data-component="settings-list"] {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export const DialogSettings: Component<{
|
||||
sessionID?: string
|
||||
defaultValue?: string
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
@@ -68,7 +67,7 @@ export const DialogSettings: Component<{
|
||||
})
|
||||
|
||||
const showProviders = () => {
|
||||
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
|
||||
void dialog.show(() => <DialogSettings defaultValue="providers" />)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -144,7 +143,7 @@ export const DialogSettings: Component<{
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="settings-panel">
|
||||
<SettingsGeneral server={server()} sessionID={props.sessionID} />
|
||||
<SettingsGeneral server={server()} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="appearance" class="settings-panel">
|
||||
<SettingsAppearance />
|
||||
|
||||
@@ -2,14 +2,15 @@ import { createMemo, type Accessor } from "solid-js"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useSettings } from "@/settings/model"
|
||||
|
||||
export function useSessionTabAvatarState(
|
||||
server: Accessor<ServerConnection.Key>,
|
||||
directory: Accessor<string>,
|
||||
sessionId: Accessor<string>,
|
||||
root?: Accessor<boolean>,
|
||||
) {
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||
const serverCtx = useServerCtx(connection)
|
||||
const sessions = createMemo(() => {
|
||||
@@ -23,12 +24,10 @@ export function useSessionTabAvatarState(
|
||||
})
|
||||
})
|
||||
const hasPermissions = createMemo(() => {
|
||||
if (settings.permissions.autoApprove()) return false
|
||||
const ctx = serverCtx()
|
||||
if (!ctx) return false
|
||||
const permission = ctx.permission
|
||||
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId(), (item) => {
|
||||
return !permission.autoResponds(item, directory())
|
||||
})
|
||||
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
|
||||
})
|
||||
const hasQuestions = createMemo(() => {
|
||||
const data = serverCtx()?.data
|
||||
|
||||
@@ -15,7 +15,6 @@ export function SessionTabAvatar(props: {
|
||||
}) {
|
||||
const state = useSessionTabAvatarState(
|
||||
() => props.server,
|
||||
() => props.directory,
|
||||
() => props.sessionId,
|
||||
)
|
||||
return (
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
const FOCUS_LOCK = "opencode:notification-focus"
|
||||
const MAX_CLAIMED = 500
|
||||
|
||||
export function createNotificationCoordinator() {
|
||||
const locks = typeof navigator === "undefined" ? undefined : navigator.locks
|
||||
const claimed = new Set<string>()
|
||||
const focus = { pending: false, release: undefined as (() => void) | undefined }
|
||||
|
||||
const updateFocus = () => {
|
||||
if (typeof document === "undefined" || !document.hasFocus()) {
|
||||
focus.release?.()
|
||||
return
|
||||
}
|
||||
if (!locks || focus.pending || focus.release) return
|
||||
|
||||
focus.pending = true
|
||||
void locks
|
||||
.request(FOCUS_LOCK, { mode: "shared" }, async () => {
|
||||
focus.pending = false
|
||||
if (!document.hasFocus()) return
|
||||
await new Promise<void>((resolve) => {
|
||||
focus.release = resolve
|
||||
})
|
||||
focus.release = undefined
|
||||
})
|
||||
.catch(() => {
|
||||
focus.pending = false
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("focus", updateFocus)
|
||||
window.addEventListener("blur", updateFocus)
|
||||
document.addEventListener("visibilitychange", updateFocus)
|
||||
updateFocus()
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("focus", updateFocus)
|
||||
window.removeEventListener("blur", updateFocus)
|
||||
document.removeEventListener("visibilitychange", updateFocus)
|
||||
focus.release?.()
|
||||
})
|
||||
}
|
||||
|
||||
const once = async (kind: "sound" | "system", eventID: string, run: () => Promise<unknown> | void) => {
|
||||
const key = `${kind}:${eventID}`
|
||||
const execute = async () => {
|
||||
if (!claim(kind, key, claimed)) return
|
||||
await run()
|
||||
}
|
||||
if (!locks) return execute()
|
||||
await locks.request(`opencode:notification:${key}`, execute)
|
||||
}
|
||||
|
||||
return {
|
||||
sound(eventID: string, run: () => Promise<unknown> | void) {
|
||||
return once("sound", eventID, run)
|
||||
},
|
||||
system(eventID: string, run: () => Promise<unknown> | void) {
|
||||
return once("system", eventID, async () => {
|
||||
if (typeof document !== "undefined" && document.hasFocus()) return
|
||||
if (!locks) return run()
|
||||
await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => {
|
||||
if (!lock) return
|
||||
await run()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function claim(kind: "sound" | "system", eventID: string, claimed: Set<string>) {
|
||||
if (claimed.has(eventID)) return false
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
const storageKey = `opencode:notification-${kind}`
|
||||
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]")
|
||||
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
||||
if (events.includes(eventID)) {
|
||||
claimed.add(eventID)
|
||||
return false
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
|
||||
} catch {
|
||||
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
claimed.add(eventID)
|
||||
return true
|
||||
}
|
||||
@@ -9,8 +9,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { playSoundById } from "@/shell/notifications/sound"
|
||||
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { playSoundByIdOnce } from "@/shell/notifications/sound"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
|
||||
@@ -109,12 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
return index
|
||||
}
|
||||
|
||||
export function createServerNotificationState(input: {
|
||||
sdk: ServerSDK
|
||||
data: Data
|
||||
key: ServerConnection.Key
|
||||
coordinator: ReturnType<typeof createNotificationCoordinator>
|
||||
}) {
|
||||
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
@@ -232,7 +226,7 @@ export function createServerNotificationState(input: {
|
||||
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
|
||||
settings.sounds.agentEnabled()
|
||||
) {
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
|
||||
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -245,10 +239,8 @@ export function createServerNotificationState(input: {
|
||||
|
||||
const href = sessionHref(input.key, sessionID)
|
||||
if (settings.notifications.agent()) {
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
navigate(href),
|
||||
),
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
navigate(href),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -268,7 +260,7 @@ export function createServerNotificationState(input: {
|
||||
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
|
||||
settings.sounds.errorsEnabled()
|
||||
) {
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
|
||||
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -284,9 +276,7 @@ export function createServerNotificationState(input: {
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionHref(input.key, sessionID)
|
||||
if (settings.notifications.errors()) {
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.error.title"), description, () => navigate(href)),
|
||||
)
|
||||
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ function getLoads() {
|
||||
}
|
||||
|
||||
const cache = new Map<SoundID, Promise<string | undefined>>()
|
||||
const claimed = new Set<string>()
|
||||
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
|
||||
const MAX_CLAIMED = 500
|
||||
|
||||
export function soundSrc(id: string | undefined) {
|
||||
const loads = getLoads()
|
||||
@@ -100,3 +103,34 @@ export function playSound(src: string | undefined) {
|
||||
export function playSoundById(id: string | undefined) {
|
||||
return soundSrc(id).then((src) => playSound(src))
|
||||
}
|
||||
|
||||
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
|
||||
const play = async () => {
|
||||
if (!claim(eventID)) return
|
||||
await playSoundById(id)
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined" || !navigator.locks) return play()
|
||||
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
|
||||
}
|
||||
|
||||
function claim(eventID: string) {
|
||||
if (claimed.has(eventID)) return false
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
|
||||
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
||||
if (events.includes(eventID)) {
|
||||
claimed.add(eventID)
|
||||
return false
|
||||
}
|
||||
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
|
||||
} catch {
|
||||
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
claimed.add(eventID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { windowsMenuAccelerator } from "./windows-menu"
|
||||
|
||||
describe("Windows app menu", () => {
|
||||
test("resolves the new window accelerator", () => {
|
||||
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N", ctrlKey: true, shiftKey: true }))).toBe(
|
||||
"window.new",
|
||||
)
|
||||
})
|
||||
|
||||
test("ignores the accelerator without its modifiers", () => {
|
||||
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N" }))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
import { For, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { For, onMount, type JSX } from "solid-js"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { matchKeybind, parseKeybind, useCommand } from "@/shell/commands/command"
|
||||
import {
|
||||
DESKTOP_MENU,
|
||||
desktopMenuVisible,
|
||||
@@ -13,6 +14,15 @@ import {
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
const accelerators = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).flatMap((entry) => {
|
||||
if (entry.type === "separator" || !entry.action || !entry.accelerator?.windows) return []
|
||||
return [{ action: entry.action, keybind: parseKeybind(entry.accelerator.windows) }]
|
||||
})
|
||||
|
||||
export function windowsMenuAccelerator(event: KeyboardEvent) {
|
||||
return accelerators.find((entry) => matchKeybind(entry.keybind, event))?.action
|
||||
}
|
||||
|
||||
export function WindowsAppMenu(props: {
|
||||
command: ReturnType<typeof useCommand>
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
@@ -50,6 +60,22 @@ export function WindowsAppMenu(props: {
|
||||
if (entry.href) props.platform.openExternal(entry.href)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(
|
||||
document,
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
const action = windowsMenuAccelerator(event)
|
||||
if (!action) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
runAction(action)
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Menu appearance="standard" gutter={4} modal={false} placement="bottom-start">
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Argument, Flag } from "effect/unstable/cli"
|
||||
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
|
||||
import { Schema } from "effect"
|
||||
import { Spec } from "../framework/spec"
|
||||
|
||||
export const PrintLogs = GlobalFlag.setting("print-logs")({
|
||||
flag: Flag.boolean("print-logs").pipe(
|
||||
Flag.withDescription("Print logs to stderr (server logs require --standalone)"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
})
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
const ServerParams = {
|
||||
@@ -187,6 +195,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "List all available models",
|
||||
params: ServerParams,
|
||||
}),
|
||||
Spec.make("stats", {
|
||||
description: "Show shareable usage statistics",
|
||||
params: {
|
||||
...ServerParams,
|
||||
days: Flag.integer("days").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
|
||||
Flag.withDescription("Show the last N days; 0 means today"),
|
||||
Flag.optional,
|
||||
),
|
||||
year: Flag.integer("year").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1970, maximum: 9_999 }))),
|
||||
Flag.withDescription("Show a calendar year"),
|
||||
Flag.optional,
|
||||
),
|
||||
all: Flag.boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)),
|
||||
project: Flag.string("project").pipe(
|
||||
Flag.withDescription('Filter by project ID, or use "." for the current project'),
|
||||
Flag.optional,
|
||||
),
|
||||
models: Flag.boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)),
|
||||
tools: Flag.boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)),
|
||||
cost: Flag.boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)),
|
||||
full: Flag.boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)),
|
||||
limit: Flag.integer("limit").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
||||
Flag.withDescription("Number of rows in detailed sections"),
|
||||
Flag.withDefault(5),
|
||||
),
|
||||
json: Flag.boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
Spec.make("export", {
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL } from "node:os"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
||||
const handler = Effect.fn("cli.stats")(function* (input: Runtime.Input<typeof Commands.commands.stats>) {
|
||||
const days = Option.getOrUndefined(input.days)
|
||||
const year = Option.getOrUndefined(input.year)
|
||||
const project = Option.getOrUndefined(input.project)
|
||||
if ([days !== undefined, year !== undefined, input.all].filter(Boolean).length > 1)
|
||||
yield* Effect.fail(new Error("--days, --year, and --all cannot be combined"))
|
||||
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
|
||||
const range = statsRange({ days, year, all: input.all })
|
||||
const projectID =
|
||||
project === "."
|
||||
? yield* request(server.endpoint.url, (signal) =>
|
||||
client.location
|
||||
.get({ location: { directory: process.cwd() } }, { signal })
|
||||
.then((location) => location.project.id),
|
||||
)
|
||||
: project
|
||||
const details = input.models || input.tools || input.cost || input.full
|
||||
const stats = yield* request(server.endpoint.url, (signal) =>
|
||||
client.session.stats(
|
||||
{
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
project: projectID,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
tools: input.json || input.tools || input.full ? "detail" : details ? "none" : "summary",
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
const output = input.json
|
||||
? JSON.stringify(stats, null, 2)
|
||||
: renderStats(stats, {
|
||||
label: range.label,
|
||||
scope: project === undefined ? "all projects" : project === "." ? "current project" : "selected project",
|
||||
models: input.models || input.full,
|
||||
tools: input.tools || input.full,
|
||||
cost: input.cost || input.full,
|
||||
limit: input.limit,
|
||||
color: process.stdout.isTTY && process.env.NO_COLOR === undefined,
|
||||
width: process.stdout.columns ?? 80,
|
||||
})
|
||||
process.stdout.write(output + EOL)
|
||||
})
|
||||
|
||||
export default Runtime.handler(Commands.commands.stats, (input) =>
|
||||
handler(input).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export function request<A>(url: string, run: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({
|
||||
try: () => run(AbortSignal.timeout(30_000)),
|
||||
catch: (cause) =>
|
||||
cause instanceof ClientError && cause.reason === "Transport"
|
||||
? new Error(`Could not reach server at ${url}`, { cause })
|
||||
: cause,
|
||||
})
|
||||
}
|
||||
|
||||
type RenderOptions = {
|
||||
label: string
|
||||
scope: string
|
||||
models: boolean
|
||||
tools: boolean
|
||||
cost: boolean
|
||||
limit: number
|
||||
color: boolean
|
||||
width: number
|
||||
}
|
||||
|
||||
const colors = terminalPalette()
|
||||
|
||||
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
|
||||
const totalTokens = tokenTotal(stats.tokens)
|
||||
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
|
||||
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
|
||||
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
|
||||
const primary = `1;${colors.primary}`
|
||||
const sessionLine = [
|
||||
metricCount(stats.sessions, "session", options.color),
|
||||
stats.subagents > 0 ? metricCount(stats.subagents, "subagent", options.color) : undefined,
|
||||
]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(" · ")
|
||||
const toolSummary = !toolTotals
|
||||
? "tool stats unavailable"
|
||||
: toolRate === undefined
|
||||
? "no tool calls"
|
||||
: `${style(formatPercent(toolRate), primary, options.color)} tool success`
|
||||
const details = options.models || options.tools || options.cost
|
||||
const empty = stats.sessions === 0 && stats.prompts === 0 && stats.steps === 0
|
||||
const heading = `${style("opencode stats", primary, options.color)} ${style(`· ${options.label} · ${options.scope}`, "2", options.color)}`
|
||||
const lines = details
|
||||
? [style(`${options.label} · ${options.scope}`, "2", options.color)]
|
||||
: empty
|
||||
? [
|
||||
heading,
|
||||
"",
|
||||
style("no activity in this range", "2", options.color),
|
||||
"",
|
||||
style("opencode.ai", "2", options.color),
|
||||
]
|
||||
: [
|
||||
heading,
|
||||
"",
|
||||
...renderActivity(stats.activity, stats.range.from, stats.range.to, options.color, options.width),
|
||||
"",
|
||||
sessionLine,
|
||||
`${metricCount(stats.prompts, "prompt", options.color)} · ${metricCount(stats.steps, "step", options.color)} · ${metricCount(totalTokens, "token", options.color)}`,
|
||||
`${toolSummary} · ${metricCount(stats.activeDays, "active day", options.color)} · best streak ${style(stats.streak.toString(), primary, options.color)} day${stats.streak === 1 ? "" : "s"}`,
|
||||
"",
|
||||
style("opencode.ai", "2", options.color),
|
||||
]
|
||||
|
||||
if (options.cost) lines.push(...(lines.length > 0 ? [""] : []), ...renderCost(stats))
|
||||
if (options.models)
|
||||
lines.push(...(lines.length > 0 ? [""] : []), ...renderModels(stats, options.limit, options.width))
|
||||
if (options.tools) lines.push(...(lines.length > 0 ? [""] : []), ...renderTools(stats, options.limit, options.width))
|
||||
return lines.join(EOL)
|
||||
}
|
||||
|
||||
function statsRange(input: { days?: number; year?: number; all: boolean }) {
|
||||
const now = new Date()
|
||||
const to = now.getTime() + 1
|
||||
if (input.all) return { from: undefined, to, label: "all time" }
|
||||
if (input.days !== undefined) {
|
||||
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
from.setDate(from.getDate() - Math.max(0, input.days - 1))
|
||||
return {
|
||||
from: from.getTime(),
|
||||
to,
|
||||
label: input.days === 0 || input.days === 1 ? "today" : `last ${input.days} days`,
|
||||
}
|
||||
}
|
||||
const year = input.year ?? now.getFullYear()
|
||||
return {
|
||||
from: new Date(year, 0, 1).getTime(),
|
||||
to: year === now.getFullYear() ? to : new Date(year + 1, 0, 1).getTime(),
|
||||
label: year === now.getFullYear() ? `${year} so far` : year.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
function renderActivity(
|
||||
activity: SessionStatsInfo["activity"],
|
||||
from: number,
|
||||
to: number,
|
||||
color: boolean,
|
||||
width: number,
|
||||
) {
|
||||
const values = new Map(activity.map((day) => [day.date, day.steps]))
|
||||
const rangeStart = dateOrdinal(new Date(from))
|
||||
const rangeEnd = dateOrdinal(new Date(to - 1))
|
||||
const end = new Date(to - 1)
|
||||
end.setHours(12, 0, 0, 0)
|
||||
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
|
||||
const start = new Date(from)
|
||||
start.setHours(12, 0, 0, 0)
|
||||
start.setDate(start.getDate() - mondayIndex(start))
|
||||
const maxWeeks = Math.max(1, Math.min(53, width - 4))
|
||||
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
|
||||
const latest = new Date(end)
|
||||
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
|
||||
if (start < latest) start.setTime(latest.getTime())
|
||||
|
||||
const active = [...values.values()].filter((value) => value > 0)
|
||||
const levels = [...new Set(active)].sort((a, b) => a - b)
|
||||
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
|
||||
const date = new Date(start)
|
||||
date.setDate(date.getDate() + week * 7)
|
||||
return date
|
||||
})
|
||||
const weeks = weekStarts.map((week) =>
|
||||
Array.from({ length: 7 }, (_, day) => {
|
||||
const date = new Date(week)
|
||||
date.setDate(date.getDate() + day)
|
||||
const ordinal = dateOrdinal(date)
|
||||
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
|
||||
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
|
||||
}),
|
||||
)
|
||||
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
|
||||
return [
|
||||
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
|
||||
` ${style(monthLabels(weekStarts), "2", color)}`,
|
||||
...weekdays.flatMap((label, day) => [
|
||||
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
|
||||
...(day === weekdays.length - 1 ? [] : [""]),
|
||||
]),
|
||||
"",
|
||||
` ${style("less", "2", color)} ${[0, 1, 2, 3, 4].map((level) => paintActivity(level, color)).join("")} ${style("more", "2", color)}`,
|
||||
]
|
||||
}
|
||||
|
||||
function renderCost(stats: SessionStatsInfo) {
|
||||
const input = stats.tokens.input + stats.tokens.cache.read + stats.tokens.cache.write
|
||||
const cached = input === 0 ? 0 : (stats.tokens.cache.read / input) * 100
|
||||
return [
|
||||
"COST & TOKENS",
|
||||
row("cost", `$${stats.cost.toFixed(2)}`),
|
||||
row("input", formatNumber(stats.tokens.input)),
|
||||
row("output", formatNumber(stats.tokens.output)),
|
||||
row("reasoning", formatNumber(stats.tokens.reasoning)),
|
||||
row("cache read", formatNumber(stats.tokens.cache.read)),
|
||||
row("cache write", formatNumber(stats.tokens.cache.write)),
|
||||
row("cached input", formatPercent(cached)),
|
||||
]
|
||||
}
|
||||
|
||||
function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
|
||||
if (stats.models.length === 0) return ["MODELS", " no model usage"]
|
||||
const models = stats.models.slice(0, limit)
|
||||
const more = stats.models.length - models.length
|
||||
if (width < 68)
|
||||
return [
|
||||
"MODELS",
|
||||
...models.flatMap((item) => [
|
||||
truncate(
|
||||
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
|
||||
width,
|
||||
),
|
||||
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
|
||||
]),
|
||||
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
|
||||
]
|
||||
return [
|
||||
"MODELS",
|
||||
tableHeader("model", "tokens", "steps", "cost"),
|
||||
...models.map((item) =>
|
||||
tableRow(
|
||||
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
|
||||
formatNumber(tokenTotal(item.tokens)),
|
||||
formatNumber(item.steps),
|
||||
`$${item.cost.toFixed(2)}`,
|
||||
),
|
||||
),
|
||||
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
|
||||
]
|
||||
}
|
||||
|
||||
function renderTools(stats: SessionStatsInfo, limit: number, width: number) {
|
||||
if (stats.tools.mode !== "detail") return ["TOOL RELIABILITY", " tool details unavailable"]
|
||||
if (stats.tools.usage.length === 0) return ["TOOL RELIABILITY", " no tool calls"]
|
||||
const tools = stats.tools.usage.slice(0, limit)
|
||||
const more = stats.tools.usage.length - tools.length
|
||||
if (width < 68)
|
||||
return [
|
||||
"TOOL RELIABILITY",
|
||||
...tools.flatMap((tool) => {
|
||||
const terminal = tool.succeeded + tool.failed
|
||||
return [
|
||||
truncate(tool.name, width),
|
||||
` ${formatNumber(tool.calls)} calls · ${terminal === 0 ? "-" : formatPercent((tool.failed / terminal) * 100)} error · ${tool.durationP50 === undefined ? "-" : formatDuration(tool.durationP50)} p50`,
|
||||
]
|
||||
}),
|
||||
"",
|
||||
`${formatNumber(stats.tools.totals.succeeded + stats.tools.totals.failed)} finished calls · ${formatNumber(stats.tools.totals.unfinished)} unfinished`,
|
||||
...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []),
|
||||
]
|
||||
return [
|
||||
"TOOL RELIABILITY",
|
||||
tableHeader("tool", "calls", "error", "p50"),
|
||||
...tools.map((tool) => {
|
||||
const terminal = tool.succeeded + tool.failed
|
||||
return tableRow(
|
||||
tool.name,
|
||||
formatNumber(tool.calls),
|
||||
terminal === 0 ? "-" : formatPercent((tool.failed / terminal) * 100),
|
||||
tool.durationP50 === undefined ? "-" : formatDuration(tool.durationP50),
|
||||
)
|
||||
}),
|
||||
"",
|
||||
`${formatNumber(stats.tools.totals.succeeded + stats.tools.totals.failed)} finished calls · ${formatNumber(stats.tools.totals.unfinished)} unfinished`,
|
||||
...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []),
|
||||
]
|
||||
}
|
||||
|
||||
function row(label: string, value: string) {
|
||||
return ` ${label.padEnd(20)}${value}`
|
||||
}
|
||||
|
||||
function tableHeader(label: string, second: string, third: string, fourth: string) {
|
||||
return tableRow(label, second, third, fourth)
|
||||
}
|
||||
|
||||
function tableRow(label: string, second: string, third: string, fourth: string) {
|
||||
return `${truncate(label, 34).padEnd(34)}${second.padStart(10)}${third.padStart(12)}${fourth.padStart(12)}`
|
||||
}
|
||||
|
||||
function truncate(value: string, width: number) {
|
||||
return value.length <= width ? value : value.slice(0, width - 1) + "…"
|
||||
}
|
||||
|
||||
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
|
||||
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
|
||||
if (value >= 1_000) return `${trimDecimal(value / 1_000)}k`
|
||||
return Math.round(value).toLocaleString("en-US")
|
||||
}
|
||||
|
||||
function trimDecimal(value: number) {
|
||||
return value.toFixed(1).replace(/\.0$/, "")
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)}%`
|
||||
}
|
||||
|
||||
function formatDuration(value: number) {
|
||||
if (value < 1_000) return `${Math.round(value)}ms`
|
||||
return `${trimDecimal(value / 1_000)}s`
|
||||
}
|
||||
|
||||
function metricCount(value: number, noun: string, color: boolean) {
|
||||
return `${style(formatNumber(value), `1;${colors.primary}`, color)} ${noun}${value === 1 ? "" : "s"}`
|
||||
}
|
||||
|
||||
function style(value: string, code: string, color: boolean) {
|
||||
return color ? `\x1b[${code}m${value}\x1b[0m` : value
|
||||
}
|
||||
|
||||
function activityGlyph(value: number, levels: number[], color: boolean) {
|
||||
if (value === 0) return paintActivity(0, color)
|
||||
const index = levels.indexOf(value)
|
||||
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
|
||||
return paintActivity(level, color)
|
||||
}
|
||||
|
||||
function paintActivity(level: number, color: boolean) {
|
||||
const glyph = ["·", "░", "▒", "▓", "█"][level]
|
||||
if (!color) return glyph
|
||||
if (level === 0) return `\x1b[2m${glyph}\x1b[22m`
|
||||
return `\x1b[${colors.activity[level - 1]}m${glyph}\x1b[39m`
|
||||
}
|
||||
|
||||
function terminalPalette() {
|
||||
const background = Number(process.env.COLORFGBG?.split(";").at(-1))
|
||||
if (Number.isFinite(background) && background >= 7)
|
||||
return {
|
||||
primary: "38;2;59;125;216",
|
||||
activity: ["38;2;153;169;192", "38;2;122;155;200", "38;2;90;140;208", "38;2;59;125;216"],
|
||||
}
|
||||
if (Number.isFinite(background))
|
||||
return {
|
||||
primary: "38;2;250;178;131",
|
||||
activity: ["38;2;117;99;87", "38;2;161;125;102", "38;2;206;152;116", "38;2;250;178;131"],
|
||||
}
|
||||
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
|
||||
}
|
||||
|
||||
function monthLabels(weeks: Date[]) {
|
||||
const line: string[] = []
|
||||
weeks.reduce((previous, week, index) => {
|
||||
const middle = new Date(week)
|
||||
middle.setDate(middle.getDate() + 3)
|
||||
const month = middle.getMonth()
|
||||
if (month === previous) return previous
|
||||
Intl.DateTimeFormat("en-US", { month: "short" })
|
||||
.format(middle)
|
||||
.split("")
|
||||
.forEach((character, offset) => {
|
||||
line[index + offset] = character
|
||||
})
|
||||
return month
|
||||
}, -1)
|
||||
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
|
||||
.join("")
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
function mondayIndex(date: Date) {
|
||||
return (date.getDay() + 6) % 7
|
||||
}
|
||||
|
||||
function dateKey(date: Date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0"),
|
||||
].join("-")
|
||||
}
|
||||
|
||||
function dateOrdinal(date: Date) {
|
||||
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Effect, FileSystem, Scope } from "effect"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
import { PrintLogs } from "../commands/commands"
|
||||
import { Spec } from "./spec"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Updater } from "../services/updater"
|
||||
@@ -77,7 +78,11 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
|
||||
}
|
||||
|
||||
export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
|
||||
return Command.run(provide(commands, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
|
||||
return Command.run(provide(commands, handlers).pipe(Command.withGlobalFlags([PrintLogs])), options) as Effect.Effect<
|
||||
void,
|
||||
unknown,
|
||||
Command.Environment
|
||||
>
|
||||
}
|
||||
|
||||
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
|
||||
@@ -86,6 +91,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
|
||||
? node.spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
if (yield* PrintLogs) process.env.OPENCODE_PRINT_LOGS = "1"
|
||||
const module = yield* Effect.promise(handler.load)
|
||||
return yield* module.default(input)
|
||||
}),
|
||||
|
||||
@@ -43,6 +43,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
remove: () => import("./commands/handlers/plugin/remove"),
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
stats: () => import("./commands/handlers/stats"),
|
||||
export: () => import("./commands/handlers/export"),
|
||||
import: () => import("./commands/handlers/import"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
|
||||
@@ -27,7 +27,7 @@ function command(password: string, options: Options) {
|
||||
// The server treats EOF on this pipe as the end of its ownership lease.
|
||||
// The OS closes it even when the TUI is killed before Effect finalizers run.
|
||||
stdin: "pipe",
|
||||
stderr: "ignore",
|
||||
stderr: process.env.OPENCODE_PRINT_LOGS === "1" ? "inherit" : "ignore",
|
||||
killSignal: "SIGTERM",
|
||||
forceKillAfter: "3 seconds",
|
||||
})
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ClientError, type SessionStatsInfo } from "@opencode-ai/client"
|
||||
import { Effect } from "effect"
|
||||
import { renderStats, request } from "../src/commands/handlers/stats"
|
||||
|
||||
const tools = {
|
||||
mode: "detail",
|
||||
totals: { calls: 10, succeeded: 8, failed: 2, unfinished: 0 },
|
||||
usage: [{ name: "private_tool", calls: 10, succeeded: 8, failed: 2, unfinished: 0, durationP50: 250 }],
|
||||
} satisfies SessionStatsInfo["tools"]
|
||||
|
||||
const stats: SessionStatsInfo = {
|
||||
range: { from: Date.UTC(2026, 0, 1), to: Date.UTC(2026, 0, 8) },
|
||||
sessions: 2,
|
||||
subagents: 1,
|
||||
prompts: 4,
|
||||
steps: 6,
|
||||
tokens: { input: 10_000, output: 2_000, reasoning: 1_000, cache: { read: 5_000, write: 500 } },
|
||||
cost: 12.34,
|
||||
tools,
|
||||
activeDays: 2,
|
||||
streak: 2,
|
||||
activity: [
|
||||
{ date: "2026-01-02", steps: 2 },
|
||||
{ date: "2026-01-03", steps: 4 },
|
||||
],
|
||||
models: [
|
||||
{
|
||||
model: { providerID: "anthropic", id: "sonnet" },
|
||||
steps: 6,
|
||||
tokens: { input: 10_000, output: 2_000, reasoning: 1_000, cache: { read: 5_000, write: 500 } },
|
||||
cost: 12.34,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe("stats rendering", () => {
|
||||
test("keeps the default card shareable", () => {
|
||||
const output = renderStats(stats, options())
|
||||
expect(output).toContain("opencode stats · 2026 so far · all projects")
|
||||
expect(output).toContain("activity")
|
||||
expect(output).toMatch(/Mo .*(?:\r?\n){2}Tu/)
|
||||
expect(output).toMatch(/Su .*(?:\r?\n){2} less/)
|
||||
expect(output).toContain("less ·░▒▓█ more")
|
||||
expect(output).toContain("2 sessions · 1 subagent")
|
||||
expect(output).toContain("80.0% tool success · 2 active days · best streak 2 days")
|
||||
expect(output).not.toContain("private_tool")
|
||||
expect(output).not.toContain("$12.34")
|
||||
})
|
||||
|
||||
test("renders only requested detail tables", () => {
|
||||
const output = renderStats(stats, options({ tools: true, cost: true }))
|
||||
expect(output).toContain("COST & TOKENS")
|
||||
expect(output).toContain("TOOL RELIABILITY")
|
||||
expect(output).toContain("private_tool")
|
||||
expect(output).toContain("tool")
|
||||
expect(output).toContain("calls")
|
||||
expect(output).toContain("cached input 32.3%")
|
||||
expect(output).not.toContain("opencode stats")
|
||||
expect(output).not.toContain("activity")
|
||||
})
|
||||
|
||||
test("shows when detail tables omit rows", () => {
|
||||
const output = renderStats(
|
||||
{
|
||||
...stats,
|
||||
models: [
|
||||
...stats.models,
|
||||
{
|
||||
model: { providerID: "anthropic", id: "haiku" },
|
||||
steps: 2,
|
||||
tokens: { input: 2_000, output: 500, reasoning: 0, cache: { read: 1_000, write: 0 } },
|
||||
cost: 1.25,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
mode: "detail",
|
||||
totals: tools.totals,
|
||||
usage: [...tools.usage, { name: "grep", calls: 4, succeeded: 4, failed: 0, unfinished: 0, durationP50: 20 }],
|
||||
},
|
||||
},
|
||||
options({ models: true, tools: true, limit: 1 }),
|
||||
)
|
||||
expect(output).toContain("+1 more model")
|
||||
expect(output).toContain("+1 more tool")
|
||||
})
|
||||
|
||||
test("uses the OpenCode palette in color mode", () => {
|
||||
const output = renderStats(stats, options({ color: true }))
|
||||
expect(output).toContain("\x1b[1;36m")
|
||||
expect(output).not.toContain("38;5;45")
|
||||
})
|
||||
|
||||
test("uses compact layouts in narrow terminals", () => {
|
||||
const output = renderStats(stats, options({ models: true, width: 48 }))
|
||||
expect(output).toContain("anthropic/sonnet")
|
||||
expect(output).toContain("18.5k tokens · 6 steps · $12.34")
|
||||
expect(output.split(/\r?\n/).every((line) => line.length <= 48)).toBe(true)
|
||||
})
|
||||
|
||||
test("renders a concise empty state", () => {
|
||||
const output = renderStats(
|
||||
{ ...stats, sessions: 0, subagents: 0, prompts: 0, steps: 0, activeDays: 0, streak: 0, activity: [] },
|
||||
options(),
|
||||
)
|
||||
expect(output).toContain("no activity in this range")
|
||||
expect(output).not.toContain("less ·░▒▓█ more")
|
||||
})
|
||||
|
||||
test("does not present uncollected tools as zero calls", () => {
|
||||
const output = renderStats({ ...stats, tools: { mode: "none" } }, options())
|
||||
expect(output).toContain("tool stats unavailable")
|
||||
expect(output).not.toContain("no tool calls")
|
||||
})
|
||||
|
||||
test("labels activity when terminal width truncates the requested range", () => {
|
||||
const output = renderStats(
|
||||
{ ...stats, range: { from: Date.UTC(2020, 0, 1), to: Date.UTC(2026, 0, 8) } },
|
||||
options({ width: 20 }),
|
||||
)
|
||||
expect(output).toContain("activity · last 16 weeks")
|
||||
})
|
||||
})
|
||||
|
||||
describe("stats requests", () => {
|
||||
test("maps transport failures to the server URL", async () => {
|
||||
const cause = new ClientError("Transport")
|
||||
const error = await Effect.runPromise(Effect.flip(request("http://localhost:4096", () => Promise.reject(cause))))
|
||||
expect(error).toEqual(new Error("Could not reach server at http://localhost:4096", { cause }))
|
||||
})
|
||||
|
||||
test("preserves declared API errors", async () => {
|
||||
const cause = { _tag: "InvalidRequestError", message: "Stats range must end after it starts" } as const
|
||||
const error = await Effect.runPromise(Effect.flip(request("http://localhost:4096", () => Promise.reject(cause))))
|
||||
expect(error).toBe(cause)
|
||||
})
|
||||
|
||||
test("preserves unexpected response failures", async () => {
|
||||
const cause = new ClientError("UnexpectedStatus", { cause: { status: 500 } })
|
||||
const error = await Effect.runPromise(Effect.flip(request("http://localhost:4096", () => Promise.reject(cause))))
|
||||
expect(error).toBe(cause)
|
||||
})
|
||||
})
|
||||
|
||||
function options(input: Partial<Parameters<typeof renderStats>[1]> = {}): Parameters<typeof renderStats>[1] {
|
||||
return {
|
||||
label: "2026 so far",
|
||||
scope: "all projects",
|
||||
models: false,
|
||||
tools: false,
|
||||
cost: false,
|
||||
limit: 5,
|
||||
color: false,
|
||||
width: 80,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { Project } from "@opencode-ai/schema/project"
|
||||
import type { RelativePath } from "@opencode-ai/schema/schema"
|
||||
import type { Brand } from "effect"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { DateTime } from "effect"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
@@ -111,6 +112,71 @@ export type SessionListOutput = {
|
||||
}
|
||||
export type SessionListOperation<E = never> = (input?: SessionListInput) => Effect.Effect<SessionListOutput, E>
|
||||
|
||||
export type SessionStatsInput = {
|
||||
readonly from?: number | undefined
|
||||
readonly to?: number | undefined
|
||||
readonly project?: Project.ID | undefined
|
||||
readonly timezone?: string | undefined
|
||||
readonly tools?: "none" | "summary" | "detail" | undefined
|
||||
}
|
||||
export type SessionStatsOutput = {
|
||||
readonly range: { readonly from: DateTime.Utc; readonly to: DateTime.Utc }
|
||||
readonly sessions: number
|
||||
readonly subagents: number
|
||||
readonly prompts: number
|
||||
readonly steps: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly cost: number & Brand.Brand<"Money.USD">
|
||||
readonly tools:
|
||||
| { readonly mode: "none" }
|
||||
| {
|
||||
readonly mode: "summary"
|
||||
readonly totals: {
|
||||
readonly calls: number
|
||||
readonly succeeded: number
|
||||
readonly failed: number
|
||||
readonly unfinished: number
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly mode: "detail"
|
||||
readonly totals: {
|
||||
readonly calls: number
|
||||
readonly succeeded: number
|
||||
readonly failed: number
|
||||
readonly unfinished: number
|
||||
}
|
||||
readonly usage: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly calls: number
|
||||
readonly succeeded: number
|
||||
readonly failed: number
|
||||
readonly unfinished: number
|
||||
readonly durationP50?: number | undefined
|
||||
}>
|
||||
}
|
||||
readonly activeDays: number
|
||||
readonly streak: number
|
||||
readonly activity: ReadonlyArray<{ readonly date: string; readonly steps: number }>
|
||||
readonly models: ReadonlyArray<{
|
||||
readonly model: Model.Ref
|
||||
readonly steps: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly cost: number & Brand.Brand<"Money.USD">
|
||||
}>
|
||||
}
|
||||
export type SessionStatsOperation<E = never> = (input?: SessionStatsInput) => Effect.Effect<SessionStatsOutput, E>
|
||||
|
||||
export type SessionCreateInput = {
|
||||
readonly id?: Session.ID | undefined
|
||||
readonly title?: string | undefined
|
||||
@@ -966,6 +1032,7 @@ export type SessionViewOperation<E = never> = (input: SessionViewInput) => Effec
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
readonly stats: SessionStatsOperation<E>
|
||||
readonly create: SessionCreateOperation<E>
|
||||
readonly import: SessionImportOperation<E>
|
||||
readonly export: SessionExportOperation<E>
|
||||
|
||||
@@ -17,6 +17,8 @@ import type {
|
||||
PluginListOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
SessionStatsOutput,
|
||||
SessionCreateInput,
|
||||
SessionCreateOutput,
|
||||
SessionImportInput,
|
||||
@@ -305,6 +307,22 @@ const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: Sessi
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointSessionStats = (raw: RawClient["server.session"]) => (input?: SessionStatsInput) =>
|
||||
preserveEffect<SessionStatsOutput>()(
|
||||
raw["session.stats"]({
|
||||
query: {
|
||||
from: input?.["from"],
|
||||
to: input?.["to"],
|
||||
project: input?.["project"],
|
||||
timezone: input?.["timezone"],
|
||||
tools: input?.["tools"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: SessionCreateInput) =>
|
||||
preserveEffect<SessionCreateOutput>()(
|
||||
raw["session.create"]({
|
||||
@@ -632,6 +650,7 @@ const EndpointSessionView = (raw: RawClient["server.session"]) => (input: Sessio
|
||||
|
||||
const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
list: EndpointSessionList(raw),
|
||||
stats: EndpointSessionStats(raw),
|
||||
create: EndpointSessionCreate(raw),
|
||||
import: EndpointSessionImport(raw),
|
||||
export: EndpointSessionExport(raw),
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
PluginListOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
SessionStatsOutput,
|
||||
SessionCreateInput,
|
||||
SessionCreateOutput,
|
||||
SessionImportInput,
|
||||
@@ -454,6 +456,24 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
stats: (input?: SessionStatsInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionStatsOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/stats`,
|
||||
query: {
|
||||
from: input?.["from"],
|
||||
to: input?.["to"],
|
||||
project: input?.["project"],
|
||||
timezone: input?.["timezone"],
|
||||
tools: input?.["tools"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionCreateOutput }>(
|
||||
{
|
||||
|
||||
@@ -37,6 +37,19 @@ export type FileDiffInfo = {
|
||||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type SessionStatsToolTotals = { calls: number; succeeded: number; failed: number; unfinished: number }
|
||||
|
||||
export type SessionStatsToolUsage = {
|
||||
name: string
|
||||
calls: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
unfinished: number
|
||||
durationP50?: number
|
||||
}
|
||||
|
||||
export type SessionStatsActivity = { date: string; steps: number }
|
||||
|
||||
export type SessionMessageAgentSelected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
@@ -422,6 +435,13 @@ export type V2EventServerConnected = {
|
||||
|
||||
export type SessionRevert = { messageID: string; partID?: string; snapshot?: string; files?: Array<FileDiffInfo> }
|
||||
|
||||
export type SessionStatsTools =
|
||||
| { mode: "none" }
|
||||
| { mode: "summary"; totals: SessionStatsToolTotals }
|
||||
| { mode: "detail"; totals: SessionStatsToolTotals; usage: Array<SessionStatsToolUsage> }
|
||||
|
||||
export type SessionStatsModelUsage = { model: ModelRef; steps: number; tokens: TokenUsageInfo; cost: MoneyUSD }
|
||||
|
||||
export type SessionMessageModelSelected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
@@ -1538,6 +1558,21 @@ export type SessionRevertStaged = {
|
||||
data: { sessionID: string; revert: SessionRevert }
|
||||
}
|
||||
|
||||
export type SessionStatsInfo = {
|
||||
range: { from: number; to: number }
|
||||
sessions: number
|
||||
subagents: number
|
||||
prompts: number
|
||||
steps: number
|
||||
tokens: TokenUsageInfo
|
||||
cost: MoneyUSD
|
||||
tools: SessionStatsTools
|
||||
activeDays: number
|
||||
streak: number
|
||||
activity: Array<SessionStatsActivity>
|
||||
models: Array<SessionStatsModelUsage>
|
||||
}
|
||||
|
||||
export type SessionMessageUser = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
@@ -2446,6 +2481,46 @@ export type SessionListInput = {
|
||||
|
||||
export type SessionListOutput = SessionsResponse
|
||||
|
||||
export type SessionStatsInput = {
|
||||
readonly from?: {
|
||||
readonly from?: number | undefined
|
||||
readonly to?: number | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly timezone?: string | undefined
|
||||
readonly tools?: "none" | "summary" | "detail" | undefined
|
||||
}["from"]
|
||||
readonly to?: {
|
||||
readonly from?: number | undefined
|
||||
readonly to?: number | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly timezone?: string | undefined
|
||||
readonly tools?: "none" | "summary" | "detail" | undefined
|
||||
}["to"]
|
||||
readonly project?: {
|
||||
readonly from?: number | undefined
|
||||
readonly to?: number | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly timezone?: string | undefined
|
||||
readonly tools?: "none" | "summary" | "detail" | undefined
|
||||
}["project"]
|
||||
readonly timezone?: {
|
||||
readonly from?: number | undefined
|
||||
readonly to?: number | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly timezone?: string | undefined
|
||||
readonly tools?: "none" | "summary" | "detail" | undefined
|
||||
}["timezone"]
|
||||
readonly tools?: {
|
||||
readonly from?: number | undefined
|
||||
readonly to?: number | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly timezone?: string | undefined
|
||||
readonly tools?: "none" | "summary" | "detail" | undefined
|
||||
}["tools"]
|
||||
}
|
||||
|
||||
export type SessionStatsOutput = { data: SessionStatsInfo }["data"]
|
||||
|
||||
export type SessionCreateInput = {
|
||||
readonly id?: {
|
||||
readonly id?: string | null
|
||||
|
||||
@@ -32,3 +32,38 @@ const result = await Bun.build({
|
||||
},
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
|
||||
|
||||
// Bun's Node target eagerly creates its shared require helper, so every split
|
||||
// entry evaluates import.meta.url even when it never requires a module. Keep
|
||||
// the helper lazy until Bun stops hoisting it into workerd-reachable chunks.
|
||||
// https://github.com/oven-sh/bun/issues/12615
|
||||
const eagerRequire = "var __require = /* @__PURE__ */ createRequire(import.meta.url);"
|
||||
const lazyRequire = `var __require = (specifier) => createRequire(import.meta.url ?? "file:///worker.js")(specifier);
|
||||
__require.resolve = (specifier, options) => createRequire(import.meta.url ?? "file:///worker.js").resolve(specifier, options);`
|
||||
const rewritten = await Promise.all(
|
||||
result.outputs.map(async (output) => {
|
||||
if (!output.path.endsWith(".js")) return false
|
||||
const source = await output.text()
|
||||
|
||||
const generatedUses = source
|
||||
.replace(/import\s*\{[^}]*\b__require\b[^}]*\}\s*from\s*["'][^"']+["'];/g, "")
|
||||
.replace(/export\s*\{[^}]*\b__require\b[^}]*\};/g, "")
|
||||
.replace(eagerRequire, "")
|
||||
if (/\bnew\s+__require\s*\(/.test(generatedUses))
|
||||
throw new Error(`Unsupported generated require constructor in ${output.path}`)
|
||||
const unsupported = generatedUses
|
||||
.replace(/\b__require\.resolve\s*\(/g, "")
|
||||
.replace(/\b__require\s*\(/g, "")
|
||||
if (/\b__require\b/.test(unsupported)) throw new Error(`Unsupported generated require usage in ${output.path}`)
|
||||
|
||||
if (!source.includes(eagerRequire)) return false
|
||||
if (source.indexOf(eagerRequire) !== source.lastIndexOf(eagerRequire))
|
||||
throw new Error(`Multiple eager require helpers in ${output.path}`)
|
||||
const rewrittenSource = source.replace(eagerRequire, lazyRequire)
|
||||
if (rewrittenSource.includes(eagerRequire)) throw new Error(`Failed to rewrite eager require helper in ${output.path}`)
|
||||
await Bun.write(output.path, rewrittenSource)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (rewritten.filter(Boolean).length !== 1)
|
||||
throw new Error("Expected exactly one eager require helper; Bun may have fixed #12615 and made this shim removable")
|
||||
|
||||
@@ -572,7 +572,7 @@ function cacheKey(source: string) {
|
||||
}
|
||||
|
||||
export function bodyDigest(text: string) {
|
||||
return new Bun.CryptoHasher("sha256").update(text).digest("hex")
|
||||
return Hash.sha256(text)
|
||||
}
|
||||
|
||||
export const layer = (options?: Options) =>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * as SessionMessage from "./message.js"
|
||||
export * as SessionMessage from "@opencode-ai/schema/session-message"
|
||||
export * from "@opencode-ai/schema/session-message"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as SessionSchema from "./schema.js"
|
||||
export * as SessionSchema from "@opencode-ai/schema/session"
|
||||
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
export * as SessionStats from "./stats.js"
|
||||
|
||||
import { DateTime, Effect, Option, Schema } from "effect"
|
||||
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { ToolMode } from "@opencode-ai/schema/session-stats"
|
||||
import { Database } from "../database/database.js"
|
||||
import { EventTable } from "../event/sql.js"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
|
||||
type Input = {
|
||||
readonly from?: number
|
||||
readonly to?: number
|
||||
readonly projectID?: Project.ID
|
||||
readonly timezone?: string
|
||||
readonly tools?: ToolMode
|
||||
}
|
||||
|
||||
type Tokens = {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
|
||||
type MessageRow = {
|
||||
sessionID: string
|
||||
parentID: string | null
|
||||
type: "user" | "assistant"
|
||||
timeCreated: number
|
||||
providerID: string | null
|
||||
modelID: string | null
|
||||
variant: string | null
|
||||
input: number | null
|
||||
output: number | null
|
||||
reasoning: number | null
|
||||
cacheRead: number | null
|
||||
cacheWrite: number | null
|
||||
cost: number | null
|
||||
}
|
||||
|
||||
type ToolRow = {
|
||||
name: string | null
|
||||
status: string | null
|
||||
duration: number | null
|
||||
}
|
||||
|
||||
type ToolSummaryRow = {
|
||||
calls: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
unfinished: number
|
||||
}
|
||||
|
||||
type ModelAggregate = {
|
||||
model: Model.Ref
|
||||
steps: number
|
||||
tokens: Tokens
|
||||
cost: number
|
||||
}
|
||||
|
||||
type ToolAggregate = {
|
||||
name: string
|
||||
calls: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
unfinished: number
|
||||
durations: number[]
|
||||
}
|
||||
|
||||
const decodeUsage = Schema.decodeUnknownOption(SessionEvent.UsageRecorded.data)
|
||||
const Window = 31 * 24 * 60 * 60 * 1_000
|
||||
|
||||
export class InvalidRangeError extends Schema.TaggedError<InvalidRangeError>()("SessionStats.InvalidRangeError", {
|
||||
from: Schema.Finite,
|
||||
to: Schema.Finite,
|
||||
}) {}
|
||||
|
||||
export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
const db = (yield* Database.Service).db
|
||||
const to = input.to ?? Date.now()
|
||||
if (input.from !== undefined && input.from >= to) return yield* new InvalidRangeError({ from: input.from, to })
|
||||
const project = input.projectID === undefined ? sql`` : sql`AND session.project_id = ${input.projectID}`
|
||||
const from =
|
||||
input.from ??
|
||||
(yield* db
|
||||
.get<{ time: number | null }>(
|
||||
sql`
|
||||
SELECT min(message.time_created) AS time
|
||||
FROM ${SessionMessageTable} AS message
|
||||
JOIN ${SessionTable} AS session ON session.id = message.session_id
|
||||
WHERE message.type IN ('user', 'assistant')
|
||||
AND message.time_created < ${to}
|
||||
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
|
||||
${project}
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.orDie))?.time ??
|
||||
to
|
||||
const ranges = windows(from, to)
|
||||
const toolMode = input.tools ?? "summary"
|
||||
const sessions = new Set<string>()
|
||||
const subagents = new Set<string>()
|
||||
const activity = new Map<string, number>()
|
||||
const models = new Map<string, ModelAggregate>()
|
||||
const tools = new Map<string, ToolAggregate>()
|
||||
const totals = {
|
||||
prompts: 0,
|
||||
steps: 0,
|
||||
tokens: emptyTokens(),
|
||||
cost: 0,
|
||||
}
|
||||
const toolTotals = { calls: 0, succeeded: 0, failed: 0, unfinished: 0 }
|
||||
const dateKey = makeDateKey(input.timezone)
|
||||
|
||||
yield* Effect.forEach(
|
||||
ranges,
|
||||
(range) =>
|
||||
db
|
||||
.all<MessageRow>(
|
||||
sql`
|
||||
SELECT
|
||||
message.session_id AS sessionID,
|
||||
session.parent_id AS parentID,
|
||||
message.type AS type,
|
||||
message.time_created AS timeCreated,
|
||||
json_extract(message.data, '$.model.providerID') AS providerID,
|
||||
json_extract(message.data, '$.model.id') AS modelID,
|
||||
json_extract(message.data, '$.model.variant') AS variant,
|
||||
json_extract(message.data, '$.tokens.input') AS input,
|
||||
json_extract(message.data, '$.tokens.output') AS output,
|
||||
json_extract(message.data, '$.tokens.reasoning') AS reasoning,
|
||||
json_extract(message.data, '$.tokens.cache.read') AS cacheRead,
|
||||
json_extract(message.data, '$.tokens.cache.write') AS cacheWrite,
|
||||
json_extract(message.data, '$.cost') AS cost
|
||||
FROM ${SessionMessageTable} AS message
|
||||
JOIN ${SessionTable} AS session ON session.id = message.session_id
|
||||
WHERE message.type IN ('user', 'assistant')
|
||||
AND message.time_created >= ${range.from}
|
||||
AND message.time_created < ${range.to}
|
||||
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
|
||||
${project}
|
||||
`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.tap((rows) =>
|
||||
Effect.sync(() => {
|
||||
rows.forEach((row) => {
|
||||
if (row.parentID === null) sessions.add(row.sessionID)
|
||||
else subagents.add(row.sessionID)
|
||||
if (row.type === "user") {
|
||||
if (row.parentID === null) totals.prompts++
|
||||
return
|
||||
}
|
||||
|
||||
totals.steps++
|
||||
const tokens = rowTokens(row)
|
||||
addTokens(totals.tokens, tokens)
|
||||
totals.cost += row.cost ?? 0
|
||||
const day = dateKey(row.timeCreated)
|
||||
activity.set(day, (activity.get(day) ?? 0) + 1)
|
||||
if (!row.providerID || !row.modelID) return
|
||||
const key = `${row.providerID}/${row.modelID}#${row.variant ?? ""}`
|
||||
const model = models.get(key) ?? {
|
||||
model: {
|
||||
providerID: Provider.ID.make(row.providerID),
|
||||
id: Model.ID.make(row.modelID),
|
||||
variant: row.variant ? Model.VariantID.make(row.variant) : undefined,
|
||||
},
|
||||
steps: 0,
|
||||
tokens: emptyTokens(),
|
||||
cost: 0,
|
||||
}
|
||||
models.set(key, model)
|
||||
model.steps++
|
||||
model.cost += row.cost ?? 0
|
||||
addTokens(model.tokens, tokens)
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 1, discard: true },
|
||||
)
|
||||
|
||||
if (toolMode !== "none")
|
||||
yield* Effect.forEach(
|
||||
ranges,
|
||||
(range) => {
|
||||
if (toolMode === "summary")
|
||||
return db
|
||||
.get<ToolSummaryRow>(
|
||||
sql`
|
||||
WITH calls AS MATERIALIZED (
|
||||
SELECT json_extract(content.value, '$.state.status') AS status
|
||||
FROM ${SessionMessageTable} AS message
|
||||
JOIN ${SessionTable} AS session ON session.id = message.session_id,
|
||||
json_each(message.data, '$.content') AS content
|
||||
WHERE message.type = 'assistant'
|
||||
AND message.time_created >= ${range.from}
|
||||
AND message.time_created < ${range.to}
|
||||
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
|
||||
AND json_extract(content.value, '$.type') = 'tool'
|
||||
${project}
|
||||
)
|
||||
SELECT
|
||||
count(*) AS calls,
|
||||
count(*) FILTER (WHERE status = 'completed') AS succeeded,
|
||||
count(*) FILTER (WHERE status = 'error') AS failed,
|
||||
count(*) FILTER (WHERE status IS NULL OR status NOT IN ('completed', 'error')) AS unfinished
|
||||
FROM calls
|
||||
`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.tap((row) =>
|
||||
Effect.sync(() => {
|
||||
if (!row) return
|
||||
toolTotals.calls += row.calls
|
||||
toolTotals.succeeded += row.succeeded
|
||||
toolTotals.failed += row.failed
|
||||
toolTotals.unfinished += row.unfinished
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
return db
|
||||
.all<ToolRow>(
|
||||
sql`
|
||||
SELECT
|
||||
json_extract(content.value, '$.name') AS name,
|
||||
json_extract(content.value, '$.state.status') AS status,
|
||||
CASE
|
||||
WHEN json_extract(content.value, '$.time.completed') IS NULL THEN NULL
|
||||
ELSE json_extract(content.value, '$.time.completed')
|
||||
- coalesce(json_extract(content.value, '$.time.ran'), json_extract(content.value, '$.time.created'))
|
||||
END AS duration
|
||||
FROM ${SessionMessageTable} AS message
|
||||
JOIN ${SessionTable} AS session ON session.id = message.session_id,
|
||||
json_each(message.data, '$.content') AS content
|
||||
WHERE message.type = 'assistant'
|
||||
AND message.time_created >= ${range.from}
|
||||
AND message.time_created < ${range.to}
|
||||
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
|
||||
AND json_extract(content.value, '$.type') = 'tool'
|
||||
${project}
|
||||
`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.tap((rows) =>
|
||||
Effect.sync(() => {
|
||||
rows.forEach((row) => {
|
||||
addToolStatus(toolTotals, row.status, 1)
|
||||
if (!row.name) return
|
||||
const tool = tools.get(row.name) ?? {
|
||||
name: row.name,
|
||||
calls: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
unfinished: 0,
|
||||
durations: [],
|
||||
}
|
||||
tools.set(row.name, tool)
|
||||
addToolStatus(tool, row.status, 1)
|
||||
if (row.duration !== null) tool.durations.push(row.duration)
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
},
|
||||
{ concurrency: 1, discard: true },
|
||||
)
|
||||
|
||||
const ids = yield* db
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(input.projectID === undefined ? undefined : eq(SessionTable.project_id, input.projectID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const events = (yield* Effect.forEach(
|
||||
batches(ids.map((row) => row.id)),
|
||||
(batch) =>
|
||||
db
|
||||
.select({ created: EventTable.created, data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(EventTable.aggregate_id, batch),
|
||||
eq(EventTable.type, SessionEvent.UsageRecorded.type),
|
||||
sql`json_extract(${EventTable.data}, '$.source') = 'compaction'`,
|
||||
gte(EventTable.created, from),
|
||||
lt(EventTable.created, to),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
{ concurrency: 4 },
|
||||
)).flat()
|
||||
events.forEach((row) => {
|
||||
const decoded = decodeUsage(row.data)
|
||||
if (Option.isNone(decoded)) return
|
||||
addTokens(totals.tokens, decoded.value.tokens)
|
||||
totals.cost += decoded.value.cost
|
||||
})
|
||||
|
||||
const days = [...activity.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||
return {
|
||||
range: { from: DateTime.makeUnsafe(from), to: DateTime.makeUnsafe(to) },
|
||||
sessions: sessions.size,
|
||||
subagents: subagents.size,
|
||||
prompts: totals.prompts,
|
||||
steps: totals.steps,
|
||||
tokens: totals.tokens,
|
||||
cost: Money.USD.make(totals.cost),
|
||||
tools:
|
||||
toolMode === "none"
|
||||
? { mode: toolMode }
|
||||
: toolMode === "summary"
|
||||
? { mode: toolMode, totals: toolTotals }
|
||||
: {
|
||||
mode: toolMode,
|
||||
totals: toolTotals,
|
||||
usage: [...tools.values()]
|
||||
.sort((a, b) => b.calls - a.calls)
|
||||
.map((tool) => ({
|
||||
name: tool.name,
|
||||
calls: tool.calls,
|
||||
succeeded: tool.succeeded,
|
||||
failed: tool.failed,
|
||||
unfinished: tool.unfinished,
|
||||
durationP50: median(tool.durations),
|
||||
})),
|
||||
},
|
||||
activeDays: days.length,
|
||||
streak: longestStreak(days.map(([date]) => date)),
|
||||
activity: days.map(([date, steps]) => ({ date, steps })),
|
||||
models: [...models.values()]
|
||||
.sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens))
|
||||
.map((model) => ({ ...model, cost: Money.USD.make(model.cost) })),
|
||||
}
|
||||
})
|
||||
|
||||
function windows(from: number, to: number) {
|
||||
return Array.from({ length: Math.ceil((to - from) / Window) }, (_, index) => ({
|
||||
from: from + index * Window,
|
||||
to: Math.min(to, from + (index + 1) * Window),
|
||||
}))
|
||||
}
|
||||
|
||||
function batches(ids: string[]) {
|
||||
return Array.from({ length: Math.ceil(ids.length / 500) }, (_, index) => ids.slice(index * 500, (index + 1) * 500))
|
||||
}
|
||||
|
||||
function rowTokens(row: MessageRow): Tokens {
|
||||
return {
|
||||
input: row.input ?? 0,
|
||||
output: row.output ?? 0,
|
||||
reasoning: row.reasoning ?? 0,
|
||||
cache: { read: row.cacheRead ?? 0, write: row.cacheWrite ?? 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function emptyTokens(): Tokens {
|
||||
return { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
}
|
||||
|
||||
function addTokens(target: Tokens, source: Tokens) {
|
||||
target.input += source.input
|
||||
target.output += source.output
|
||||
target.reasoning += source.reasoning
|
||||
target.cache.read += source.cache.read
|
||||
target.cache.write += source.cache.write
|
||||
}
|
||||
|
||||
function tokenTotal(tokens: Tokens) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
function addToolStatus(
|
||||
target: { calls: number; succeeded: number; failed: number; unfinished: number },
|
||||
status: string | null,
|
||||
count: number,
|
||||
) {
|
||||
target.calls += count
|
||||
if (status === "completed") {
|
||||
target.succeeded += count
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
target.failed += count
|
||||
return
|
||||
}
|
||||
target.unfinished += count
|
||||
}
|
||||
|
||||
function makeDateKey(timezone = "UTC") {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
return (time: number) => {
|
||||
const parts = Object.fromEntries(formatter.formatToParts(time).map((part) => [part.type, part.value]))
|
||||
return `${parts.year}-${parts.month}-${parts.day}`
|
||||
}
|
||||
}
|
||||
|
||||
function longestStreak(days: string[]) {
|
||||
return days.reduce(
|
||||
(result, day, index) => {
|
||||
const previous = days[index - 1]
|
||||
const current = previous && dayOrdinal(day) - dayOrdinal(previous) === 1 ? result.current + 1 : 1
|
||||
return { current, longest: Math.max(result.longest, current) }
|
||||
},
|
||||
{ current: 0, longest: 0 },
|
||||
).longest
|
||||
}
|
||||
|
||||
function dayOrdinal(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number)
|
||||
return Math.floor(Date.UTC(year, month - 1, day) / 86_400_000)
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
if (values.length === 0) return undefined
|
||||
const sorted = values.toSorted((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStats } from "@opencode-ai/core/session/stats"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(Database.node))
|
||||
const projectID = Project.ID.make("stats-project")
|
||||
const otherProjectID = Project.ID.make("stats-other-project")
|
||||
const sessionID = Session.ID.make("ses_stats_root")
|
||||
const childID = Session.ID.make("ses_stats_child")
|
||||
const forkID = Session.ID.make("ses_stats_fork")
|
||||
const usageOnlyID = Session.ID.make("ses_stats_usage_only")
|
||||
const otherSessionID = Session.ID.make("ses_stats_other")
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
const encodeUsage = Schema.encodeSync(SessionEvent.UsageRecorded.data)
|
||||
|
||||
describe("SessionStats", () => {
|
||||
it.effect("aggregates activity and tool reliability without reading message payloads outside the range", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values([
|
||||
{ id: projectID, worktree: AbsolutePath.make("/stats"), name: "stats", sandboxes: [] },
|
||||
{ id: otherProjectID, worktree: AbsolutePath.make("/other"), name: "other", sandboxes: [] },
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values([
|
||||
{ id: sessionID, project_id: projectID, slug: "root", directory: "/stats", version: "test" },
|
||||
{
|
||||
id: childID,
|
||||
project_id: projectID,
|
||||
parent_id: sessionID,
|
||||
slug: "child",
|
||||
directory: "/stats",
|
||||
version: "test",
|
||||
},
|
||||
{
|
||||
id: forkID,
|
||||
project_id: projectID,
|
||||
fork_session_id: sessionID,
|
||||
slug: "fork",
|
||||
directory: "/stats",
|
||||
version: "test",
|
||||
time_created: Date.UTC(2026, 0, 4),
|
||||
},
|
||||
{ id: usageOnlyID, project_id: projectID, slug: "usage", directory: "/stats", version: "test" },
|
||||
{ id: otherSessionID, project_id: otherProjectID, slug: "other", directory: "/other", version: "test" },
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
messageRow(
|
||||
sessionID,
|
||||
1,
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.make("msg_stats_user"),
|
||||
type: "user",
|
||||
text: "hello",
|
||||
time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 9)) },
|
||||
}),
|
||||
),
|
||||
messageRow(
|
||||
sessionID,
|
||||
2,
|
||||
assistant("msg_stats_assistant", Date.UTC(2026, 0, 2, 10), [
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)),
|
||||
ran: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 1)),
|
||||
completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 1, 250)),
|
||||
},
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_edit",
|
||||
name: "edit",
|
||||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: {},
|
||||
error: { type: "tool", message: "failed" },
|
||||
}),
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)),
|
||||
completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 2)),
|
||||
},
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_pending",
|
||||
name: "pending",
|
||||
state: SessionMessage.ToolStateRunning.make({ status: "running", input: {}, metadata: {} }),
|
||||
time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)) },
|
||||
}),
|
||||
]),
|
||||
),
|
||||
messageRow(childID, 1, assistant("msg_stats_child", Date.UTC(2026, 0, 3, 10), [], "large", 2)),
|
||||
messageRow(
|
||||
forkID,
|
||||
1,
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.make("msg_stats_fork_copied_user"),
|
||||
type: "user",
|
||||
text: "copied",
|
||||
time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 9)) },
|
||||
}),
|
||||
),
|
||||
messageRow(
|
||||
forkID,
|
||||
2,
|
||||
assistant("msg_stats_fork_copied_assistant", Date.UTC(2026, 0, 2, 10), [
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_copied",
|
||||
name: "copied",
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "copied" }],
|
||||
}),
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)),
|
||||
completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 1)),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
),
|
||||
messageRow(forkID, 3, assistant("msg_stats_fork_new", Date.UTC(2026, 0, 5, 10), [], "fork-new")),
|
||||
messageRow(sessionID, 3, assistant("msg_stats_outside", Date.UTC(2025, 11, 31, 10), [])),
|
||||
messageRow(usageOnlyID, 1, assistant("msg_stats_usage_only", Date.UTC(2025, 11, 30, 10), [])),
|
||||
messageRow(otherSessionID, 1, assistant("msg_stats_other", Date.UTC(2020, 0, 1, 10), [])),
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([
|
||||
{ aggregate_id: sessionID, seq: 0 },
|
||||
{ aggregate_id: childID, seq: 0 },
|
||||
{ aggregate_id: usageOnlyID, seq: 0 },
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: Event.ID.make("evt_stats_usage"),
|
||||
aggregate_id: sessionID,
|
||||
seq: 0,
|
||||
created: Date.UTC(2026, 0, 2, 10, 0, 3),
|
||||
type: SessionEvent.UsageRecorded.type,
|
||||
data: encodeUsage({
|
||||
sessionID,
|
||||
source: "title",
|
||||
cost: Money.USD.make(0.5),
|
||||
tokens: { input: 1, output: 1, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: Event.ID.make("evt_stats_usage_boundary"),
|
||||
aggregate_id: usageOnlyID,
|
||||
seq: 0,
|
||||
created: Date.UTC(2026, 0, 2, 10, 0, 3),
|
||||
type: SessionEvent.UsageRecorded.type,
|
||||
data: encodeUsage({
|
||||
sessionID: usageOnlyID,
|
||||
source: "compaction",
|
||||
cost: Money.USD.make(0.25),
|
||||
tokens: { input: 2, output: 2, reasoning: 2, cache: { read: 2, write: 2 } },
|
||||
}),
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const stats = yield* SessionStats.get({
|
||||
from: Date.UTC(2026, 0, 1),
|
||||
to: Date.UTC(2026, 1, 1),
|
||||
timezone: "UTC",
|
||||
tools: "detail",
|
||||
})
|
||||
|
||||
expect(stats.sessions).toBe(2)
|
||||
expect(stats.subagents).toBe(1)
|
||||
expect(stats.prompts).toBe(1)
|
||||
expect(stats.steps).toBe(3)
|
||||
expect(stats.tokens).toEqual({ input: 42, output: 22, reasoning: 10, cache: { read: 18, write: 6 } })
|
||||
expect(stats.cost).toBe(Money.USD.make(6.25))
|
||||
expect(stats.tools).toMatchObject({
|
||||
mode: "detail",
|
||||
totals: { calls: 3, succeeded: 1, failed: 1, unfinished: 1 },
|
||||
})
|
||||
expect(stats.activity).toEqual([
|
||||
{ date: "2026-01-02", steps: 1 },
|
||||
{ date: "2026-01-03", steps: 1 },
|
||||
{ date: "2026-01-05", steps: 1 },
|
||||
])
|
||||
expect(stats.streak).toBe(2)
|
||||
expect(stats.models.map((model) => String(model.model.id))).toEqual(["large", "sonnet", "fork-new"])
|
||||
expect(stats.tools.mode).toBe("detail")
|
||||
if (stats.tools.mode !== "detail") throw new Error("Expected detailed tool statistics")
|
||||
expect(stats.tools.usage).toMatchObject([
|
||||
{ name: "read", calls: 1, succeeded: 1, failed: 0, durationP50: 250 },
|
||||
{ name: "edit", calls: 1, succeeded: 0, failed: 1, durationP50: 2_000 },
|
||||
{ name: "pending", calls: 1, succeeded: 0, failed: 0, unfinished: 1 },
|
||||
])
|
||||
|
||||
const summary = yield* SessionStats.get({
|
||||
from: Date.UTC(2026, 0, 1),
|
||||
to: Date.UTC(2026, 1, 1),
|
||||
timezone: "UTC",
|
||||
})
|
||||
expect(summary.models.map((model) => String(model.model.id))).toEqual(["large", "sonnet", "fork-new"])
|
||||
expect(summary.tools).toEqual({
|
||||
mode: "summary",
|
||||
totals: { calls: 3, succeeded: 1, failed: 1, unfinished: 1 },
|
||||
})
|
||||
|
||||
const withoutTools = yield* SessionStats.get({
|
||||
from: Date.UTC(2026, 0, 1),
|
||||
to: Date.UTC(2026, 1, 1),
|
||||
timezone: "UTC",
|
||||
tools: "none",
|
||||
})
|
||||
expect(withoutTools.tools).toEqual({ mode: "none" })
|
||||
|
||||
const project = yield* SessionStats.get({ projectID, timezone: "UTC", tools: "none" })
|
||||
expect(DateTime.toEpochMillis(project.range.from)).toBe(Date.UTC(2025, 11, 30, 10))
|
||||
|
||||
const error = yield* Effect.flip(
|
||||
SessionStats.get({ from: Date.UTC(2026, 1, 1), to: Date.UTC(2026, 0, 1), tools: "none" }),
|
||||
)
|
||||
expect(error).toEqual(
|
||||
new SessionStats.InvalidRangeError({ from: Date.UTC(2026, 1, 1), to: Date.UTC(2026, 0, 1) }),
|
||||
)
|
||||
|
||||
const future = yield* Effect.flip(SessionStats.get({ from: Number.MAX_SAFE_INTEGER, tools: "none" }))
|
||||
expect(future._tag).toBe("SessionStats.InvalidRangeError")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function assistant(
|
||||
id: string,
|
||||
created: number,
|
||||
content: SessionMessage.AssistantContent[],
|
||||
model = "sonnet",
|
||||
scale = 1,
|
||||
) {
|
||||
return SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make(id),
|
||||
type: "assistant",
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: Model.ID.make(model), providerID: Provider.ID.make("anthropic") },
|
||||
content,
|
||||
cost: Money.USD.make(1.5 * scale),
|
||||
tokens: { input: 10 * scale, output: 5 * scale, reasoning: 2 * scale, cache: { read: 4 * scale, write: scale } },
|
||||
time: { created: DateTime.makeUnsafe(created), completed: DateTime.makeUnsafe(created + 2_000) },
|
||||
})
|
||||
}
|
||||
|
||||
function messageRow(
|
||||
sessionID: Session.ID,
|
||||
seq: number,
|
||||
message: SessionMessage.Info,
|
||||
): typeof SessionMessageTable.$inferInsert {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return { id: SessionMessage.ID.make(id), session_id: sessionID, type, seq, time_created: encoded.time.created, data }
|
||||
}
|
||||
@@ -96,6 +96,9 @@ function make(fs: FileSystem.FileSystem, path: Path.Path) {
|
||||
const size = image.getSize()
|
||||
return { buffer: new Uint8Array(image.toPNG()).buffer, width: size.width, height: size.height }
|
||||
},
|
||||
writeClipboardText(text: string) {
|
||||
clipboard.writeText(text)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export const fileHandlers = FileRpcs.toLayer(
|
||||
const image = files.readClipboardImage()
|
||||
return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null
|
||||
}),
|
||||
FilesWriteClipboardText: ({ text }) => Effect.sync(() => files.writeClipboardText(text)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -58,6 +58,7 @@ export type ElectronAPI = {
|
||||
openPath(path: string, app?: string): Promise<string | undefined>
|
||||
revealPath(path: string): Promise<boolean>
|
||||
readClipboardImage(): Promise<ClipboardImage | null>
|
||||
writeClipboardText(text: string): Promise<void>
|
||||
getWindowFocused(): Promise<boolean>
|
||||
getWindowFullscreen(): Promise<boolean>
|
||||
onWindowFullscreenChanged(cb: (fullscreen: boolean) => void): () => void
|
||||
|
||||
@@ -106,6 +106,7 @@ export const api: ElectronAPI = {
|
||||
invoke("FilesReadClipboardImage").then((image) =>
|
||||
image ? { ...image, buffer: toArrayBuffer(image.buffer) } : null,
|
||||
),
|
||||
writeClipboardText: (text) => invoke("FilesWriteClipboardText", { text }),
|
||||
getWindowFocused: () => invoke("WindowGetFocused"),
|
||||
getWindowFullscreen: () => invoke("WindowGetFullscreen"),
|
||||
onWindowFullscreenChanged: (cb) => listen("WindowFullscreenChanged", (event) => cb(event.fullscreen)),
|
||||
|
||||
@@ -26,6 +26,9 @@ function fileApi(events: string[]) {
|
||||
openPath: async () => undefined,
|
||||
revealPath: async () => false,
|
||||
readClipboardImage: async () => null,
|
||||
writeClipboardText: async (text: string) => {
|
||||
events.push(`clipboard:${text}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +61,13 @@ describe("desktop attachment files", () => {
|
||||
).rejects.toThrow("attachment rejected")
|
||||
expect(events.at(-1)).toBe("release:selection")
|
||||
})
|
||||
|
||||
test("writes clipboard text through the native desktop API", async () => {
|
||||
const events: string[] = []
|
||||
const files = createDesktopFiles(fileApi(events), "windows", ["txt"])
|
||||
|
||||
await files.writeClipboardText("ses_123")
|
||||
|
||||
expect(events).toEqual(["clipboard:ses_123"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ type DesktopFileAPI = Pick<
|
||||
| "openPath"
|
||||
| "revealPath"
|
||||
| "readClipboardImage"
|
||||
| "writeClipboardText"
|
||||
>
|
||||
|
||||
export function createDesktopFiles(api: DesktopFileAPI, os: DesktopOS, acceptedExtensions: string[]) {
|
||||
@@ -74,5 +75,8 @@ export function createDesktopFiles(api: DesktopFileAPI, os: DesktopOS, acceptedE
|
||||
type: "image/png",
|
||||
})
|
||||
},
|
||||
writeClipboardText(text: string) {
|
||||
return api.writeClipboardText(text)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ export const FilesRevealPath = Rpc.make("FilesRevealPath", {
|
||||
export const FilesReadClipboardImage = Rpc.make("FilesReadClipboardImage", {
|
||||
success: Schema.NullOr(ClipboardImage),
|
||||
})
|
||||
export const FilesWriteClipboardText = Rpc.make("FilesWriteClipboardText", {
|
||||
payload: { text: Schema.String },
|
||||
})
|
||||
|
||||
export const FileRpcs = RpcGroup.make(
|
||||
FilesOpenDirectoryPicker,
|
||||
@@ -68,4 +71,5 @@ export const FileRpcs = RpcGroup.make(
|
||||
FilesOpenPath,
|
||||
FilesRevealPath,
|
||||
FilesReadClipboardImage,
|
||||
FilesWriteClipboardText,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./markdown": "./src/markdown.ts",
|
||||
"./palette": "./src/palette.ts",
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MermaidDiagramKind } from "./diagnostics.js"
|
||||
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { isMermaidGanttDiagram } from "./gantt/parser.js"
|
||||
import { isMermaidGitGraphDiagram } from "./gitgraph/parser.js"
|
||||
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { isMermaidStateDiagram } from "./state/parser.js"
|
||||
@@ -7,6 +8,7 @@ import { isMermaidTimelineDiagram } from "./timeline/parser.js"
|
||||
|
||||
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
|
||||
if (isMermaidFlowchartDiagram(content)) return "flowchart"
|
||||
if (isMermaidGanttDiagram(content)) return "gantt"
|
||||
if (isMermaidGitGraphDiagram(content)) return "gitGraph"
|
||||
if (isMermaidSequenceDiagram(content)) return "sequence"
|
||||
if (isMermaidStateDiagram(content)) return "state"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" | "gitGraph"
|
||||
export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" | "gitGraph" | "gantt"
|
||||
|
||||
/** An otherwise valid diagram contains syntax that this renderer does not support. */
|
||||
export class MermaidSyntaxError extends Error {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
mergeDiagramLineGlyph,
|
||||
} from "../core/drawing.js"
|
||||
import { layoutFlowchartDiagram, visualLength } from "./layout.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import { flowchartRouteLabelLayout } from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { flowchartDirectionBetween, flowchartSourceConnector } from "./routing.js"
|
||||
import {
|
||||
@@ -43,7 +43,7 @@ function mergeFlowchartCell(
|
||||
if (incoming.style !== "edge") return incoming
|
||||
if (existing.style === "label") return existing
|
||||
if (incoming.char === " ") return existing
|
||||
if (existing.style !== "edge" || existing.char === " ") return incoming
|
||||
if ((existing.style !== "edge" && existing.style !== "group") || existing.char === " ") return incoming
|
||||
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
|
||||
|
||||
return {
|
||||
@@ -164,7 +164,7 @@ function drawSubgraphLabel(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds)
|
||||
}
|
||||
|
||||
function drawEdgeLabel(grid: FlowchartGrid, route: FlowchartEdgeRoute, style: FlowchartCellStyle): void {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis)
|
||||
const label = flowchartRouteLabelLayout(route, visualLength)
|
||||
for (const [index, line] of parseDiagramTextLines(route.edge.label).entries()) {
|
||||
grid.setText(label.point.x, label.point.y + index, " ", style)
|
||||
const width = setRichText(grid, label.point.x + 1, label.point.y + index, line.runs, style)
|
||||
@@ -185,6 +185,10 @@ function drawRoutedEdge(grid: FlowchartGrid, route: FlowchartEdgeRoute): void {
|
||||
const end = points[points.length - 1]!
|
||||
const arrowFrom = points[points.length - 2]!
|
||||
grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style)
|
||||
} else {
|
||||
const end = points[points.length - 1]!
|
||||
const endDirection = flowchartDirectionBetween(points[points.length - 2]!, end)
|
||||
if (endDirection) grid.setCell(end.x, end.y, diagramLineGlyph(new Set([endDirection])), style)
|
||||
}
|
||||
if (edge.label) {
|
||||
drawEdgeLabel(grid, route, "label")
|
||||
@@ -266,7 +270,7 @@ function drawSourceConnectors(
|
||||
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
|
||||
if (routeDirection && connectorDirection) {
|
||||
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
|
||||
if (cell) {
|
||||
if (cell && cell.style !== "label") {
|
||||
grid.replaceCell(
|
||||
sourcePoint.x,
|
||||
sourcePoint.y,
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
DEFAULT_MIN_RANK_GAP,
|
||||
DEFAULT_MIN_VERTICAL_RANK_GAP,
|
||||
layoutFlowchartDiagram as layoutParsedFlowchartDiagram,
|
||||
visualLength,
|
||||
} from "./layout.js"
|
||||
import { flowchartEdgeLabelLayout } from "./labels.js"
|
||||
import { flowchartEdgeLabelLayout, flowchartRouteLabelLayout } from "./labels.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./parser.js"
|
||||
import { renderFlowchartDiagram } from "./render.js"
|
||||
import { flowchartSourceConnector } from "./routing.js"
|
||||
import { renderGridStyledText, resolveFlowchartStyleColors } from "./style.js"
|
||||
|
||||
function drawFlowchartDiagramGrid(content: string, options?: Parameters<typeof drawParsedFlowchartDiagramGrid>[1]) {
|
||||
@@ -112,6 +114,66 @@ function boundsIntersect(
|
||||
)
|
||||
}
|
||||
|
||||
function expectFlowchartRoutesAvoidUnrelatedNodes(layout: ReturnType<typeof layoutFlowchartDiagram>): void {
|
||||
for (const route of layout.routes) {
|
||||
for (const [id, bounds] of layout.bounds) {
|
||||
if (id === route.edge.from || id === route.edge.to) continue
|
||||
expect(routeIntersectsBounds(route, bounds)).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function expectFinalFlowchartLabelLayoutUnobstructed(layout: ReturnType<typeof layoutFlowchartDiagram>) {
|
||||
const labels = layout.routes.map((route) => {
|
||||
const label = flowchartRouteLabelLayout(route, visualLength)
|
||||
return {
|
||||
route,
|
||||
label,
|
||||
bounds: { left: label.point.x, top: label.point.y, width: label.width, height: label.height },
|
||||
}
|
||||
})
|
||||
|
||||
for (const [index, label] of labels.entries()) {
|
||||
for (const bounds of layout.bounds.values()) expect(boundsIntersect(label.bounds, bounds)).toBe(false)
|
||||
for (const [otherIndex, other] of labels.entries()) {
|
||||
if (otherIndex !== index) expect(boundsIntersect(label.bounds, other.bounds)).toBe(false)
|
||||
}
|
||||
const textBounds = { ...label.bounds, left: label.bounds.left + 1, width: label.bounds.width - 2 }
|
||||
for (const other of layout.routes) {
|
||||
if (other === label.route) continue
|
||||
expect(routeIntersectsBounds(other, textBounds)).toBe(false)
|
||||
const source = layout.bounds.get(other.edge.from)
|
||||
const sourcePoint = other.points[0]
|
||||
if (!source || !sourcePoint) continue
|
||||
const connector = flowchartSourceConnector(source, sourcePoint)
|
||||
expect(boundsIntersect(label.bounds, { left: connector.x, top: connector.y, width: 1, height: 1 })).toBe(false)
|
||||
expect(boundsIntersect(label.bounds, { left: sourcePoint.x, top: sourcePoint.y, width: 1, height: 1 })).toBe(
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
function expectFlowchartLabelsUnobstructed(content: string): ReturnType<typeof layoutFlowchartDiagram> {
|
||||
const output = renderFlowchartDiagram(content)
|
||||
const layout = layoutFlowchartDiagram(content)
|
||||
const labels = expectFinalFlowchartLabelLayoutUnobstructed(layout)
|
||||
const grid = drawFlowchartDiagramGrid(content)
|
||||
|
||||
for (const label of labels) {
|
||||
const escaped = label.route.edge.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
expect(output.match(new RegExp(`(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])`, "gu")) ?? []).toHaveLength(1)
|
||||
for (const [lineIndex, line] of label.label.lines.entries()) {
|
||||
for (let offset = 0; offset < visualLength(line); offset++) {
|
||||
expect(grid.getCell(label.label.point.x + offset, label.label.point.y + lineIndex)?.style).toBe("label")
|
||||
}
|
||||
}
|
||||
}
|
||||
expectFlowchartRoutesAvoidUnrelatedNodes(layout)
|
||||
return layout
|
||||
}
|
||||
|
||||
describe("FlowchartDiagram", () => {
|
||||
test("renders compact horizontal flowcharts with shorter routes", () => {
|
||||
const output = renderFlowchartDiagram(
|
||||
@@ -331,11 +393,248 @@ describe("FlowchartDiagram", () => {
|
||||
A[Source] -->|first| B[Target]
|
||||
A -->|second| B`
|
||||
const output = renderFlowchartDiagram(content)
|
||||
const parallel = layoutFlowchartDiagram(content).routes
|
||||
|
||||
expect(output).toContain("first")
|
||||
expect(output).toContain("second")
|
||||
expect(output.match(/▶/g)).toHaveLength(1)
|
||||
expect(output.match(/▲/g)).toHaveLength(1)
|
||||
expect(output).toMatch(/[▲▼◀▶]/)
|
||||
expect(new Set(parallel.map((route) => JSON.stringify(route.points))).size).toBe(2)
|
||||
})
|
||||
|
||||
test("keeps reciprocal edge labels disjoint from nodes, labels, and other routes", () => {
|
||||
const content = `flowchart TD
|
||||
A[A] -->|forward_label| B[B]
|
||||
B -->|backward_label| A`
|
||||
|
||||
expectFlowchartLabelsUnobstructed(content)
|
||||
})
|
||||
|
||||
test("keeps labeled parallel BT edges on distinct unobstructed routes", () => {
|
||||
const content = `flowchart BT
|
||||
F3_1[Node F3_1]
|
||||
F3_4[Node F3_4]
|
||||
F3_3[Node F3_3]
|
||||
F3_2[Node F3_2]
|
||||
F3_3 -->|flow_edge_3_0| F3_4
|
||||
F3_2 -->|flow_edge_3_1| F3_3
|
||||
F3_1 -->|flow_edge_3_5| F3_4
|
||||
F3_1 -->|flow_edge_3_6| F3_4
|
||||
F3_2 -->|flow_edge_3_7| F3_1
|
||||
F3_1 -->|flow_edge_3_8| F3_4`
|
||||
const layout = expectFlowchartLabelsUnobstructed(content)
|
||||
const parallel = layout.routes.filter((route) => route.edge.from === "F3_1" && route.edge.to === "F3_4")
|
||||
|
||||
expect(parallel).toHaveLength(3)
|
||||
expect(new Set(parallel.map((route) => JSON.stringify(route.points))).size).toBe(3)
|
||||
})
|
||||
|
||||
test("keeps every label in the cyclic parallel BT fuzz fixture", () => {
|
||||
const content = `flowchart BT
|
||||
F3_0[Node F3_0]
|
||||
F3_1[Node F3_1]
|
||||
F3_2[Node F3_2]
|
||||
F3_3[Node F3_3]
|
||||
F3_4[Node F3_4]
|
||||
F3_0 -->|flow_edge_3_0| F3_1
|
||||
F3_1 -->|flow_edge_3_1| F3_2
|
||||
F3_2 -->|flow_edge_3_2| F3_3
|
||||
F3_3 -->|flow_edge_3_3| F3_4
|
||||
F3_0 -->|flow_edge_3_4| F3_4
|
||||
F3_0 -->|flow_edge_3_5| F3_0
|
||||
F3_1 -->|flow_edge_3_6| F3_4
|
||||
F3_3 -->|flow_edge_3_7| F3_0
|
||||
F3_1 -->|flow_edge_3_8| F3_4
|
||||
F3_3 -->|flow_edge_3_9| F3_0`
|
||||
|
||||
expectFlowchartLabelsUnobstructed(content)
|
||||
})
|
||||
|
||||
test("keeps every label in the cyclic parallel TB fuzz fixture", () => {
|
||||
const content = `flowchart TB
|
||||
F9_0[Node F9_0]
|
||||
F9_1[Node F9_1]
|
||||
F9_2[Node F9_2]
|
||||
F9_3[Node F9_3]
|
||||
F9_4[Node F9_4]
|
||||
F9_5[Node F9_5]
|
||||
F9_6[Node F9_6]
|
||||
F9_0 -->|flow_edge_9_0| F9_1
|
||||
F9_1 -->|flow_edge_9_1| F9_2
|
||||
F9_2 -->|flow_edge_9_2| F9_3
|
||||
F9_3 -->|flow_edge_9_3| F9_4
|
||||
F9_4 -->|flow_edge_9_4| F9_5
|
||||
F9_5 -->|flow_edge_9_5| F9_6
|
||||
F9_2 -->|flow_edge_9_6| F9_3
|
||||
F9_1 -->|flow_edge_9_7| F9_0
|
||||
F9_5 -->|flow_edge_9_8| F9_6
|
||||
F9_6 -->|flow_edge_9_9| F9_3
|
||||
F9_0 -->|flow_edge_9_10| F9_1
|
||||
F9_1 -->|flow_edge_9_11| F9_5`
|
||||
|
||||
expectFlowchartLabelsUnobstructed(content)
|
||||
})
|
||||
|
||||
test("keeps routes outside unrelated nodes in the cyclic TB fuzz fixture", () => {
|
||||
const content = `flowchart TB
|
||||
F7_0[Node F7_0]
|
||||
F7_1[Node F7_1]
|
||||
F7_2[Node F7_2]
|
||||
F7_3[Node F7_3]
|
||||
F7_4[Node F7_4]
|
||||
F7_5[Node F7_5]
|
||||
F7_6[Node F7_6]
|
||||
F7_7[Node F7_7]
|
||||
F7_0 -->|flow_edge_7_0| F7_1
|
||||
F7_1 -->|flow_edge_7_1| F7_2
|
||||
F7_2 -->|flow_edge_7_2| F7_3
|
||||
F7_3 -->|flow_edge_7_3| F7_4
|
||||
F7_4 -->|flow_edge_7_4| F7_5
|
||||
F7_5 -->|flow_edge_7_5| F7_6
|
||||
F7_6 -->|flow_edge_7_6| F7_7
|
||||
F7_7 -->|flow_edge_7_7| F7_2
|
||||
F7_3 -->|flow_edge_7_8| F7_3
|
||||
F7_0 -->|flow_edge_7_9| F7_4
|
||||
F7_5 -->|flow_edge_7_10| F7_6
|
||||
F7_4 -->|flow_edge_7_11| F7_2`
|
||||
|
||||
expectFlowchartLabelsUnobstructed(content)
|
||||
})
|
||||
|
||||
test("keeps every label clear in the cyclic BT fuzz fixture", () => {
|
||||
const content = `flowchart BT
|
||||
F130_0[Node F130_0]
|
||||
F130_1[Node F130_1]
|
||||
F130_2[Node F130_2]
|
||||
F130_3[Node F130_3]
|
||||
F130_4[Node F130_4]
|
||||
F130_5[Node F130_5]
|
||||
F130_0 -->|flow_edge_130_0| F130_1
|
||||
F130_1 -->|flow_edge_130_1| F130_2
|
||||
F130_2 -->|flow_edge_130_2| F130_3
|
||||
F130_3 -->|flow_edge_130_3| F130_4
|
||||
F130_4 -->|flow_edge_130_4| F130_5
|
||||
F130_2 -->|flow_edge_130_5| F130_1
|
||||
F130_4 -->|flow_edge_130_6| F130_3
|
||||
F130_3 -->|flow_edge_130_7| F130_1
|
||||
F130_5 -->|flow_edge_130_8| F130_5
|
||||
F130_2 -->|flow_edge_130_9| F130_0
|
||||
F130_4 -->|flow_edge_130_10| F130_0
|
||||
F130_4 -->|flow_edge_130_11| F130_0`
|
||||
|
||||
expectFlowchartLabelsUnobstructed(content)
|
||||
})
|
||||
|
||||
test("keeps translated top feedback labels distinct in the cyclic BT fuzz fixture", () => {
|
||||
const content = `flowchart BT
|
||||
F141_0[Node F141_0]
|
||||
F141_1[Node F141_1]
|
||||
F141_2[Node F141_2]
|
||||
F141_3[Node F141_3]
|
||||
F141_4[Node F141_4]
|
||||
F141_5[Node F141_5]
|
||||
F141_6[Node F141_6]
|
||||
F141_7[Node F141_7]
|
||||
F141_0 -->|flow_edge_141_0| F141_1
|
||||
F141_1 -->|flow_edge_141_1| F141_2
|
||||
F141_2 -->|flow_edge_141_2| F141_3
|
||||
F141_3 -->|flow_edge_141_3| F141_4
|
||||
F141_4 -->|flow_edge_141_4| F141_5
|
||||
F141_5 -->|flow_edge_141_5| F141_6
|
||||
F141_6 -->|flow_edge_141_6| F141_7
|
||||
F141_7 -->|flow_edge_141_7| F141_1
|
||||
F141_4 -->|flow_edge_141_8| F141_4
|
||||
F141_3 -->|flow_edge_141_9| F141_0
|
||||
F141_3 -->|flow_edge_141_10| F141_2
|
||||
F141_7 -->|flow_edge_141_11| F141_6
|
||||
F141_7 -->|flow_edge_141_12| F141_2
|
||||
F141_7 -->|flow_edge_141_13| F141_5`
|
||||
|
||||
expectFlowchartLabelsUnobstructed(content)
|
||||
})
|
||||
|
||||
test("keeps feedback labels outside their endpoint nodes in the cyclic BT fuzz fixture", () => {
|
||||
const content = `flowchart BT
|
||||
F223_0[Node F223_0]
|
||||
F223_1[Node F223_1]
|
||||
F223_2[Node F223_2]
|
||||
F223_3[Node F223_3]
|
||||
F223_4[Node F223_4]
|
||||
F223_5[Node F223_5]
|
||||
F223_6[Node F223_6]
|
||||
F223_0 -->|flow_edge_223_0| F223_1
|
||||
F223_1 -->|flow_edge_223_1| F223_2
|
||||
F223_2 -->|flow_edge_223_2| F223_3
|
||||
F223_3 -->|flow_edge_223_3| F223_4
|
||||
F223_4 -->|flow_edge_223_4| F223_5
|
||||
F223_5 -->|flow_edge_223_5| F223_6
|
||||
F223_5 -->|flow_edge_223_6| F223_2
|
||||
F223_2 -->|flow_edge_223_7| F223_6
|
||||
F223_3 -->|flow_edge_223_8| F223_6`
|
||||
|
||||
expectFlowchartLabelsUnobstructed(content)
|
||||
})
|
||||
|
||||
test("keeps self-loop labels outside their node in the cyclic TB fuzz fixture", () => {
|
||||
const content = `flowchart TB
|
||||
F238_0[Node F238_0]
|
||||
F238_1[Node F238_1]
|
||||
F238_2[Node F238_2]
|
||||
F238_3[Node F238_3]
|
||||
F238_4[Node F238_4]
|
||||
F238_5[Node F238_5]
|
||||
F238_0 -->|flow_edge_238_0| F238_1
|
||||
F238_1 -->|flow_edge_238_1| F238_2
|
||||
F238_2 -->|flow_edge_238_2| F238_3
|
||||
F238_3 -->|flow_edge_238_3| F238_4
|
||||
F238_4 -->|flow_edge_238_4| F238_5
|
||||
F238_1 -->|flow_edge_238_5| F238_2
|
||||
F238_3 -->|flow_edge_238_6| F238_3
|
||||
F238_2 -->|flow_edge_238_7| F238_0
|
||||
F238_1 -->|flow_edge_238_8| F238_5
|
||||
F238_1 -->|flow_edge_238_9| F238_1
|
||||
F238_4 -->|flow_edge_238_10| F238_4
|
||||
F238_4 -->|flow_edge_238_11| F238_0
|
||||
F238_2 -->|flow_edge_238_12| F238_2`
|
||||
|
||||
expectFlowchartLabelsUnobstructed(content)
|
||||
})
|
||||
|
||||
test.each(["LR", "RL", "TD", "TB", "BT"] as const)(
|
||||
"keeps multiple %s self-loop labels clear amid surrounding cycles",
|
||||
(direction) => {
|
||||
const content = `flowchart ${direction}
|
||||
A[Alpha] -->|entry_${direction}| B[Beta]
|
||||
B -->|loop_${direction}_0| B
|
||||
B -->|loop_${direction}_1| B
|
||||
B -->|loop_${direction}_2| B
|
||||
B -->|exit_${direction}| C[Gamma]
|
||||
C -->|cycle_${direction}| A`
|
||||
const layout = expectFlowchartLabelsUnobstructed(content)
|
||||
const loops = layout.routes.filter((route) => route.edge.from === "B" && route.edge.to === "B")
|
||||
|
||||
expect(loops).toHaveLength(3)
|
||||
},
|
||||
)
|
||||
|
||||
test.each(
|
||||
(["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) =>
|
||||
([2, 3, 4] as const).map((count) => ({ direction, count })),
|
||||
),
|
||||
)("keeps $count parallel $direction labels clear amid surrounding cycles", ({ direction, count }) => {
|
||||
const parallel = Array.from({ length: count }, (_, index) => ` A -->|parallel_${direction}_${count}_${index}| B`)
|
||||
const content = [
|
||||
`flowchart ${direction}`,
|
||||
" S[Source] -->|entry| A[Alpha]",
|
||||
...parallel,
|
||||
" B[Beta] -->|exit| T[Target]",
|
||||
" B -->|reciprocal| A",
|
||||
" T -->|cycle| S",
|
||||
].join("\n")
|
||||
const layout = expectFlowchartLabelsUnobstructed(content)
|
||||
const parallelRoutes = layout.routes.filter((route) => route.edge.from === "A" && route.edge.to === "B")
|
||||
|
||||
expect(parallelRoutes).toHaveLength(count)
|
||||
expect(new Set(parallelRoutes.map((route) => JSON.stringify(route.points))).size).toBe(count)
|
||||
})
|
||||
|
||||
test("keeps three parallel multiline edges legible in both orientations", () => {
|
||||
@@ -648,6 +947,49 @@ flowchart TD
|
||||
expectDiagram(output).toContainInOrder("Sandbox #1", "apt-get installs,", "~/.cache, /tmp")
|
||||
})
|
||||
|
||||
test.each(["LR", "RL", "TD", "BT"] as const)(
|
||||
"keeps undirected %s routes continuous up to the target",
|
||||
(direction) => {
|
||||
const content = `flowchart ${direction}\n A[Alpha] --- B[(Store)]`
|
||||
const diagram = parseMermaidFlowchartDiagram(content)
|
||||
const layout = layoutParsedFlowchartDiagram(diagram)
|
||||
const grid = drawParsedFlowchartDiagramGrid(diagram)
|
||||
const route = layout.routes[0]!
|
||||
|
||||
expect(terminalPointsTowardBounds(route, layout.bounds.get("B")!)).toBe(true)
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
const from = route.points[index - 1]!
|
||||
const to = route.points[index]!
|
||||
const length = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
|
||||
for (let offset = 0; offset <= length; offset++) {
|
||||
const x = from.x + Math.sign(to.x - from.x) * offset
|
||||
const y = from.y + Math.sign(to.y - from.y) * offset
|
||||
expect(grid.getCell(x, y)?.char).not.toBe(" ")
|
||||
}
|
||||
}
|
||||
expect(grid.getCell(route.points.at(-1)!.x, route.points.at(-1)!.y)?.char).toMatch(/[─│]/)
|
||||
},
|
||||
)
|
||||
|
||||
test("keeps independent horizontal fan-out groups on distinct routes", () => {
|
||||
const content = `flowchart LR
|
||||
A[A] -->|A to X| X[X]
|
||||
A -->|A to Y| Y[Y]
|
||||
B[B] -->|B to Y| Y
|
||||
B -->|B to Z| Z[Z]`
|
||||
const output = renderFlowchartDiagram(content)
|
||||
const routes = layoutFlowchartDiagram(content).routes
|
||||
const routeByLabel = new Map(routes.map((route) => [route.edge.label, route]))
|
||||
|
||||
for (const label of ["A to X", "A to Y", "B to Y", "B to Z"]) {
|
||||
expect(output.match(new RegExp(label, "g"))).toHaveLength(1)
|
||||
}
|
||||
expect(routeByLabel.get("A to X")!.points[1]!.x).toBe(routeByLabel.get("A to Y")!.points[1]!.x)
|
||||
expect(routeByLabel.get("B to Y")!.points[1]!.x).toBe(routeByLabel.get("B to Z")!.points[1]!.x)
|
||||
expect(routeByLabel.get("A to Y")!.points[1]!.x).not.toBe(routeByLabel.get("B to Y")!.points[1]!.x)
|
||||
expect(routeByLabel.get("A to Y")!.points.at(-1)).not.toEqual(routeByLabel.get("B to Y")!.points.at(-1))
|
||||
})
|
||||
|
||||
test("parses and renders inline dashed edge labels", () => {
|
||||
const content = `flowchart TD
|
||||
CS[conformance suite<br/>same test cases pin every driver] -.verifies.-> LS
|
||||
@@ -1051,7 +1393,45 @@ graph LR
|
||||
expect(output).toContain("API")
|
||||
expect(output).toContain("DB")
|
||||
expect(output).toContain("╭─ Web App ")
|
||||
expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).not.toContain("┼")
|
||||
expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).toContain("┼")
|
||||
})
|
||||
|
||||
test("merges horizontal routes through vertical subgraph borders", () => {
|
||||
const content = `flowchart LR
|
||||
Outside[Outside] --> Inside
|
||||
subgraph Group
|
||||
Inside[Inside]
|
||||
end`
|
||||
const diagram = parseMermaidFlowchartDiagram(content)
|
||||
const layout = layoutParsedFlowchartDiagram(diagram)
|
||||
const grid = drawParsedFlowchartDiagramGrid(diagram)
|
||||
const group = layout.subgraphBounds.get("Group")!
|
||||
const crossing = { x: group.left, y: layout.routes[0]!.points.at(-1)!.y }
|
||||
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("┼")
|
||||
expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│")
|
||||
expect(grid.getCell(crossing.x, crossing.y + 1)?.char).toBe("│")
|
||||
})
|
||||
|
||||
test("merges vertical routes through horizontal subgraph borders", () => {
|
||||
const content = `flowchart TD
|
||||
Outside[Outside] --> Inside
|
||||
subgraph Outer [O]
|
||||
subgraph Inner
|
||||
Inside[Inside]
|
||||
end
|
||||
end`
|
||||
const diagram = parseMermaidFlowchartDiagram(content)
|
||||
const layout = layoutParsedFlowchartDiagram(diagram)
|
||||
const grid = drawParsedFlowchartDiagramGrid(diagram)
|
||||
const outer = layout.subgraphBounds.get("Outer")!
|
||||
const crossing = { x: layout.routes[0]!.points[0]!.x, y: outer.top }
|
||||
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("┼")
|
||||
expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x + 1, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│")
|
||||
})
|
||||
|
||||
test("reserves frame rows for br-delimited subgraph labels", () => {
|
||||
@@ -1086,7 +1466,7 @@ graph LR
|
||||
expect(output).not.toContain("<br")
|
||||
})
|
||||
|
||||
test("draws transition lines over subgraph frames without joining them", () => {
|
||||
test("merges transition lines through subgraph frame borders", () => {
|
||||
const output = renderFlowchartDiagram(`
|
||||
flowchart TD
|
||||
subgraph Verse [verse]
|
||||
@@ -1100,7 +1480,7 @@ flowchart TD
|
||||
const crossingLines = output.split("\n").filter((line) => line.includes("Join") || line.includes("├"))
|
||||
|
||||
expect(output).toContain(" verse ")
|
||||
expect(crossingLines.join("\n")).not.toContain("┼")
|
||||
expect(crossingLines.join("\n").match(/┼/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("lays out subgraph-local directions independently from the outer flow", () => {
|
||||
@@ -1191,6 +1571,23 @@ flowchart TD
|
||||
},
|
||||
)
|
||||
|
||||
test("keeps labels on nested routes that pass translated outer siblings", () => {
|
||||
const output = renderFlowchartDiagram(`flowchart TD
|
||||
Input --> Parse
|
||||
subgraph Outer
|
||||
direction LR
|
||||
subgraph Inner
|
||||
direction BT
|
||||
Parse[Parse] --> Validate{Valid?}
|
||||
Validate -->|yes| Cache[(Cache)]
|
||||
Cache --> Validate
|
||||
end
|
||||
Validate --> Dispatch[Dispatch]
|
||||
end`)
|
||||
|
||||
expect(output).toContain("yes")
|
||||
})
|
||||
|
||||
test("routes nested RL local edges around outer siblings", () => {
|
||||
const layout = layoutFlowchartDiagram(
|
||||
`flowchart RL
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type DiagramSegment,
|
||||
} from "../core/geometry.js"
|
||||
import { splitDiagramLines } from "../core/text.js"
|
||||
import type { FlowchartPoint } from "./types.js"
|
||||
import type { FlowchartEdgeRoute, FlowchartPoint } from "./types.js"
|
||||
|
||||
const LABEL_BUS_CLEARANCE = 3
|
||||
const LABEL_NODE_CLEARANCE = 2
|
||||
@@ -71,11 +71,13 @@ function bestLabelSegment(
|
||||
points: readonly FlowchartPoint[],
|
||||
labelWidth: number,
|
||||
preferredAxis?: DiagramSegment["axis"],
|
||||
preferredSegment?: number,
|
||||
): DiagramSegment | undefined {
|
||||
const segments = points.slice(1).flatMap((to, index) => {
|
||||
const segment = segmentBetween(points[index]!, to)
|
||||
return segment ? [segment] : []
|
||||
})
|
||||
if (preferredSegment !== undefined && segments[preferredSegment]) return segments[preferredSegment]
|
||||
const preferred = preferredAxis ? segments.find((segment) => segment.axis === preferredAxis) : undefined
|
||||
if (preferred) return preferred
|
||||
|
||||
@@ -97,8 +99,9 @@ function flowchartLabelPoint(
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
preferredAxis?: DiagramSegment["axis"],
|
||||
preferredSegment?: number,
|
||||
): FlowchartPoint {
|
||||
const segment = bestLabelSegment(points, labelWidth, preferredAxis)
|
||||
const segment = bestLabelSegment(points, labelWidth, preferredAxis, preferredSegment)
|
||||
return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0))
|
||||
}
|
||||
|
||||
@@ -107,9 +110,30 @@ export function flowchartEdgeLabelLayout(
|
||||
label: string,
|
||||
measure: (text: string) => number,
|
||||
preferredAxis?: DiagramSegment["axis"],
|
||||
preferredSegment?: number,
|
||||
): FlowchartEdgeLabelLayout {
|
||||
const lines = splitDiagramLines(label).map(flowchartLabelText)
|
||||
const width = flowchartLabelWidth(label, measure)
|
||||
const height = lines.length
|
||||
return { lines, point: flowchartLabelPoint(points, width, height, preferredAxis), width, height }
|
||||
return {
|
||||
lines,
|
||||
point: flowchartLabelPoint(points, width, height, preferredAxis, preferredSegment),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
export function flowchartRouteLabelLayout(
|
||||
route: Pick<FlowchartEdgeRoute, "edge" | "points" | "labelAxis" | "labelPoint">,
|
||||
measure: (text: string) => number,
|
||||
): FlowchartEdgeLabelLayout {
|
||||
const lines = splitDiagramLines(route.edge.label).map(flowchartLabelText)
|
||||
const width = flowchartLabelWidth(route.edge.label, measure)
|
||||
const height = lines.length
|
||||
return {
|
||||
lines,
|
||||
point: route.labelPoint ?? flowchartLabelPoint(route.points, width, height, route.labelAxis),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
flowchartEdgeLabelLayout,
|
||||
flowchartHorizontalLabelRankGap,
|
||||
flowchartLabelWidth,
|
||||
flowchartRouteLabelLayout,
|
||||
flowchartVerticalBranchLabelGap,
|
||||
} from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
@@ -195,6 +196,17 @@ function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy:
|
||||
point.x += dx
|
||||
point.y += dy
|
||||
}
|
||||
if (route.labelPoint) {
|
||||
route.labelPoint.x += dx
|
||||
route.labelPoint.y += dy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function freezeRouteLabelPoints(routes: readonly FlowchartEdgeRoute[]): void {
|
||||
for (const route of routes) {
|
||||
if (!route.edge.label || route.labelPoint) continue
|
||||
route.labelPoint = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis).point
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +329,7 @@ function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBound
|
||||
|
||||
function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
|
||||
if (!route.edge.label) return undefined
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis)
|
||||
const label = flowchartRouteLabelLayout(route, visualLength)
|
||||
const { point, width, height } = label
|
||||
return {
|
||||
left: point.x,
|
||||
@@ -798,6 +810,7 @@ function layoutFlowchartWithDirection(
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
}
|
||||
freezeRouteLabelPoints(routes)
|
||||
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
|
||||
const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
|
||||
const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))
|
||||
|
||||
@@ -127,18 +127,12 @@ describe("flowchart routing", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
expect(routes.map((route) => route.points)).toEqual([
|
||||
[
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 19, y: 1 },
|
||||
],
|
||||
[
|
||||
{ x: 2, y: 3 },
|
||||
{ x: 2, y: 6 },
|
||||
{ x: 22, y: 6 },
|
||||
{ x: 22, y: 3 },
|
||||
],
|
||||
])
|
||||
const laneYs = routes.map(
|
||||
(route) => route.points.slice(1).find((point, index) => point.y === route.points[index]!.y)?.y,
|
||||
)
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(new Set(routes.map((route) => JSON.stringify(route.points))).size).toBe(2)
|
||||
expect(new Set(laneYs).size).toBe(2)
|
||||
})
|
||||
|
||||
test("spaces parallel horizontal lanes for multiline labels", () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
orthogonalPath,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
segmentBetween,
|
||||
sideForDirection,
|
||||
snapCoordinate,
|
||||
shiftPoint,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
type DiagramSide,
|
||||
} from "../core/geometry.js"
|
||||
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
||||
import { flowchartEdgeLabelLayout, type FlowchartEdgeLabelLayout } from "./labels.js"
|
||||
import { flowchartEdgeLabelLayout, flowchartRouteLabelLayout, type FlowchartEdgeLabelLayout } from "./labels.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
@@ -39,6 +40,7 @@ export { directionBetween as flowchartDirectionBetween } from "../core/geometry.
|
||||
|
||||
const BUS_CLEARANCE = 3
|
||||
const NODE_CLEARANCE = 2
|
||||
const ROUTING_CANDIDATE_BUDGET = 1024
|
||||
type HorizontalTravel = Extract<DiagramDirection, "left" | "right">
|
||||
type VerticalTravel = Extract<DiagramDirection, "up" | "down">
|
||||
type PortRole = "source" | "target"
|
||||
@@ -147,10 +149,10 @@ function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
function parallelEdgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
direction: FlowchartDirection,
|
||||
axis: DiagramAxis,
|
||||
laneCoordinate: number,
|
||||
): FlowchartPoint[] {
|
||||
if (!isVerticalDirection(direction)) {
|
||||
if (axis === "y") {
|
||||
const start = boundsSidePoint(from, "bottom")
|
||||
const end = boundsSidePoint(to, "bottom")
|
||||
return pathViaLane(start, lane("y", laneCoordinate), end)
|
||||
@@ -168,12 +170,16 @@ function labelHeight(edge: FlowchartEdge): number {
|
||||
function rightRenderExtent(route: FlowchartEdgeRoute): number {
|
||||
let right = Math.max(...route.points.map((point) => point.x))
|
||||
if (route.edge.label) {
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth, route.labelAxis)
|
||||
const label = flowchartRouteLabelLayout(route, diagramTextWidth)
|
||||
right = Math.max(right, label.point.x + label.width - 1)
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
function parallelLaneAxis(from: FlowchartNodeBounds, to: FlowchartNodeBounds): DiagramAxis {
|
||||
return Math.abs(to.centerX - from.centerX) >= Math.abs(to.centerY - from.centerY) ? "y" : "x"
|
||||
}
|
||||
|
||||
function edgePath(
|
||||
from: FlowchartNodeBounds,
|
||||
to: FlowchartNodeBounds,
|
||||
@@ -210,6 +216,46 @@ function sourceFanOutLane(
|
||||
return keepAfter(unclamped, sourceCoordinate, travel)
|
||||
}
|
||||
|
||||
function reserveFanOutLane(
|
||||
sourcePort: FlowchartPoint,
|
||||
targetPorts: readonly FlowchartPoint[],
|
||||
axis: DiagramAxis,
|
||||
travel: DiagramDirection,
|
||||
reserved: Set<number>,
|
||||
): number {
|
||||
const preferred = sourceFanOutLane(sourcePort, targetPorts, axis, travel)
|
||||
const boundary = beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE)
|
||||
const source = coordinate(sourcePort, axis)
|
||||
const available = (() => {
|
||||
let checked = 0
|
||||
for (let offset = 0; offset <= Math.abs(boundary - preferred) && checked < ROUTING_CANDIDATE_BUDGET; offset++) {
|
||||
checked++
|
||||
const candidate = advanceCoordinate(preferred, travel, offset)
|
||||
if (
|
||||
keepBefore(candidate, boundary, travel) === candidate &&
|
||||
keepAfter(candidate, source, travel) === candidate &&
|
||||
!reserved.has(candidate)
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
for (let offset = 1; offset <= Math.abs(preferred - source) && checked < ROUTING_CANDIDATE_BUDGET; offset++) {
|
||||
checked++
|
||||
const candidate = advanceCoordinate(preferred, travel, -offset)
|
||||
if (
|
||||
keepBefore(candidate, boundary, travel) === candidate &&
|
||||
keepAfter(candidate, source, travel) === candidate &&
|
||||
!reserved.has(candidate)
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
})()
|
||||
const routeLane = available ?? preferred
|
||||
reserved.add(routeLane)
|
||||
return routeLane
|
||||
}
|
||||
|
||||
function targetFanInLane(
|
||||
sourcePorts: readonly FlowchartPoint[],
|
||||
targetPort: FlowchartPoint,
|
||||
@@ -368,19 +414,43 @@ function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeReco
|
||||
|
||||
function routeHorizontalFanOut(
|
||||
records: readonly EdgeRecord[],
|
||||
bounds: ReadonlyMap<string, FlowchartNodeBounds>,
|
||||
direction: FlowchartDirection,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
|
||||
const reservedBusLanes = new Set<number>()
|
||||
const targetOwners = new Map<string, string>()
|
||||
for (const [sourceId, sourceRecords] of groupRecords(records, (record) => record.edge.from)) {
|
||||
if (sourceRecords.length < 2) continue
|
||||
const travel = direction === "RL" ? "left" : "right"
|
||||
const sourcePort = sourceRecords[0]!.sourcePort
|
||||
const targetPorts = sourceRecords.map((record) => record.targetPort)
|
||||
|
||||
const busX = sourceFanOutLane(sourcePort, targetPorts, "x", travel)
|
||||
const busX = reserveFanOutLane(sourcePort, targetPorts, "x", travel, reservedBusLanes)
|
||||
for (const record of sourceRecords) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX)))
|
||||
const targetOwner = targetOwners.get(record.edge.to)
|
||||
targetOwners.set(record.edge.to, targetOwner ?? sourceId)
|
||||
const target = bounds.get(record.edge.to)
|
||||
if (!targetOwner || targetOwner === sourceId || !target) {
|
||||
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX)))
|
||||
handled.add(record.edge)
|
||||
continue
|
||||
}
|
||||
|
||||
const targetSide = sourcePort.y < record.targetPort.y ? "top" : "bottom"
|
||||
const targetPoint = boundsSidePoint(target, targetSide)
|
||||
const approach = shiftPoint(targetPoint, targetSide === "top" ? "up" : "down")
|
||||
routes.push({
|
||||
edge: record.edge,
|
||||
points: pathThrough([
|
||||
sourcePort,
|
||||
{ x: busX, y: sourcePort.y },
|
||||
{ x: busX, y: approach.y },
|
||||
approach,
|
||||
targetPoint,
|
||||
]),
|
||||
})
|
||||
handled.add(record.edge)
|
||||
}
|
||||
}
|
||||
@@ -452,37 +522,31 @@ function routeVerticalFanIn(
|
||||
function routeParallelEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
|
||||
leftBoundary: number | undefined,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
const groups = groupRecords(diagram.edges, (edge) => `${directionForEdge(edge)}:${edge.from}:${edge.to}`)
|
||||
const groups = groupRecords(diagram.edges, (edge) => `${edge.from}:${edge.to}`)
|
||||
for (const edges of groups.values()) {
|
||||
if (edges.length < 2) continue
|
||||
const from = bounds.get(edges[0]!.from)
|
||||
const to = bounds.get(edges[0]!.to)
|
||||
if (!from || !to || from.id === to.id) continue
|
||||
const direction = directionForEdge(edges[0]!)
|
||||
const canonicalRoute = { edge: edges[0]!, points: edgePath(from, to, direction, leftBoundary) }
|
||||
routes.push(canonicalRoute)
|
||||
handled.add(edges[0]!)
|
||||
let previousRoute = canonicalRoute
|
||||
for (let index = 1; index < edges.length; index++) {
|
||||
const edge = edges[index]!
|
||||
const laneCoordinate = isVerticalDirection(direction)
|
||||
? Math.max(
|
||||
Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + BUS_CLEARANCE,
|
||||
rightRenderExtent(previousRoute) + NODE_CLEARANCE,
|
||||
)
|
||||
: Math.max(
|
||||
Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + BUS_CLEARANCE,
|
||||
Math.max(...previousRoute.points.map((point) => point.y)) + Math.max(2, labelHeight(edge) + 1),
|
||||
)
|
||||
const parallelAxis = parallelLaneAxis(from, to)
|
||||
let previousRoute: FlowchartEdgeRoute | undefined
|
||||
for (const edge of edges) {
|
||||
const height = labelHeight(edge)
|
||||
const laneCoordinate =
|
||||
parallelAxis === "x"
|
||||
? previousRoute
|
||||
? rightRenderExtent(previousRoute) + NODE_CLEARANCE
|
||||
: Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x)
|
||||
: previousRoute
|
||||
? Math.max(...previousRoute.points.map((point) => point.y)) + (height > 1 ? height + 1 : 1)
|
||||
: Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + (height > 1 ? height : 0)
|
||||
const route: FlowchartEdgeRoute = {
|
||||
edge,
|
||||
points: parallelEdgePath(from, to, direction, laneCoordinate),
|
||||
labelAxis: isVerticalDirection(direction) ? "y" : "x",
|
||||
points: parallelEdgePath(from, to, parallelAxis, laneCoordinate),
|
||||
labelAxis: parallelAxis === "x" ? "y" : "x",
|
||||
}
|
||||
routes.push(route)
|
||||
handled.add(edge)
|
||||
@@ -637,7 +701,10 @@ function pathIntersectsBounds(
|
||||
return false
|
||||
}
|
||||
|
||||
function labelIntersectsBounds(label: FlowchartEdgeLabelLayout | undefined, bounds: FlowchartNodeBounds): boolean {
|
||||
function labelIntersectsBounds(
|
||||
label: FlowchartEdgeLabelLayout | undefined,
|
||||
bounds: { left: number; top: number; width: number; height: number },
|
||||
): boolean {
|
||||
if (!label) return false
|
||||
return (
|
||||
label.point.x <= bounds.left + bounds.width - 1 &&
|
||||
@@ -682,30 +749,26 @@ function labelIntersectsLabels(
|
||||
otherLabels: readonly FlowchartEdgeLabelLayout[],
|
||||
): boolean {
|
||||
if (!label) return false
|
||||
return otherLabels.some((otherLabel) => {
|
||||
return label.lines.some((line, lineIndex) => {
|
||||
const textLeft = label.point.x + 1
|
||||
const textRight = label.point.x + diagramTextWidth(line) - 2
|
||||
const y = label.point.y + lineIndex
|
||||
return otherLabel.lines.some((otherLine, otherLineIndex) => {
|
||||
const otherLeft = otherLabel.point.x
|
||||
const otherRight = otherLeft + diagramTextWidth(otherLine) - 1
|
||||
return y === otherLabel.point.y + otherLineIndex && textLeft <= otherRight && textRight >= otherLeft
|
||||
})
|
||||
})
|
||||
})
|
||||
return otherLabels.some((otherLabel) =>
|
||||
labelIntersectsBounds(label, {
|
||||
left: otherLabel.point.x,
|
||||
top: otherLabel.point.y,
|
||||
width: otherLabel.width,
|
||||
height: otherLabel.height,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function labelIntersectsLaterRoutePaths(
|
||||
function labelIntersectsRoutePaths(
|
||||
label: FlowchartEdgeLabelLayout | undefined,
|
||||
laterRoutes: readonly FlowchartEdgeRoute[],
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
): boolean {
|
||||
if (!label) return false
|
||||
return label.lines.some((line, lineIndex) => {
|
||||
const width = diagramTextWidth(line) - 2
|
||||
if (width <= 0) return false
|
||||
return laterRoutes.some((other) =>
|
||||
pathIntersectsBounds(other.points, {
|
||||
return routes.some((route) =>
|
||||
pathIntersectsBounds(route.points, {
|
||||
left: label.point.x + 1,
|
||||
top: label.point.y + lineIndex,
|
||||
width,
|
||||
@@ -715,6 +778,23 @@ function labelIntersectsLaterRoutePaths(
|
||||
})
|
||||
}
|
||||
|
||||
function routeIntersectsLabels(route: FlowchartEdgeRoute, labels: readonly FlowchartEdgeLabelLayout[]): boolean {
|
||||
return labels.some((label) =>
|
||||
label.lines.some((line, lineIndex) => {
|
||||
const width = diagramTextWidth(line) - 2
|
||||
return (
|
||||
width > 0 &&
|
||||
pathIntersectsBounds(route.points, {
|
||||
left: label.point.x + 1,
|
||||
top: label.point.y + lineIndex,
|
||||
width,
|
||||
height: 1,
|
||||
})
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function avoidNodeObstacles(
|
||||
route: FlowchartEdgeRoute,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
@@ -724,27 +804,26 @@ function avoidNodeObstacles(
|
||||
): FlowchartEdgeRoute {
|
||||
const allNodeBounds = [...bounds.values()]
|
||||
const allSubgraphBounds = [...(subgraphBounds?.values() ?? [])]
|
||||
const laterRoutes = routes.slice(routeIndex + 1)
|
||||
const laterLabels = laterRoutes.flatMap((laterRoute) =>
|
||||
laterRoute.edge.label
|
||||
? [flowchartEdgeLabelLayout(laterRoute.points, laterRoute.edge.label, diagramTextWidth, laterRoute.labelAxis)]
|
||||
: [],
|
||||
const otherRoutes = routes.filter((_, index) => index !== routeIndex)
|
||||
const otherLabels = otherRoutes.flatMap((otherRoute) =>
|
||||
otherRoute.edge.label ? [flowchartRouteLabelLayout(otherRoute, diagramTextWidth)] : [],
|
||||
)
|
||||
const intersectsNode = (candidate: FlowchartEdgeRoute): boolean =>
|
||||
allNodeBounds.some((bound) => {
|
||||
const isSource = bound.id === route.edge.from
|
||||
const isTarget = bound.id === route.edge.to
|
||||
const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined
|
||||
return pathIntersectsBounds(candidate.points, bound, allowedContact)
|
||||
})
|
||||
const intersectsObstacle = (candidate: FlowchartEdgeRoute): boolean => {
|
||||
const label = candidate.edge.label
|
||||
? flowchartEdgeLabelLayout(candidate.points, candidate.edge.label, diagramTextWidth, candidate.labelAxis)
|
||||
: undefined
|
||||
const label = candidate.edge.label ? flowchartRouteLabelLayout(candidate, diagramTextWidth) : undefined
|
||||
return (
|
||||
allNodeBounds.some((bound) => {
|
||||
const isSource = bound.id === route.edge.from
|
||||
const isTarget = bound.id === route.edge.to
|
||||
const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined
|
||||
return pathIntersectsBounds(candidate.points, bound, allowedContact)
|
||||
}) ||
|
||||
intersectsNode(candidate) ||
|
||||
allNodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
|
||||
allSubgraphBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
|
||||
(subgraphBounds !== undefined &&
|
||||
(labelIntersectsLabels(label, laterLabels) || labelIntersectsLaterRoutePaths(label, laterRoutes)))
|
||||
labelIntersectsLabels(label, otherLabels) ||
|
||||
labelIntersectsRoutePaths(label, otherRoutes) ||
|
||||
routeIntersectsLabels(candidate, otherLabels)
|
||||
)
|
||||
}
|
||||
if (!intersectsObstacle(route)) return route
|
||||
@@ -757,69 +836,267 @@ function avoidNodeObstacles(
|
||||
const leftBusX = Math.min(...routingBounds.map((bound) => bound.left)) - BUS_CLEARANCE
|
||||
const topBusY = Math.min(...routingBounds.map((bound) => bound.top)) - BUS_CLEARANCE
|
||||
const bottomBusY = Math.max(...routingBounds.map((bound) => bound.top + bound.height - 1)) + BUS_CLEARANCE
|
||||
const start = route.points[0]!
|
||||
const end = route.points.at(-1)!
|
||||
const targetSide = sideForOutsidePoint(to, end)
|
||||
const approach = shiftPoint(
|
||||
end,
|
||||
targetSide === "left" ? "left" : targetSide === "right" ? "right" : targetSide === "top" ? "up" : "down",
|
||||
const rightBusXs = [
|
||||
...new Set([
|
||||
rightBusX,
|
||||
...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + BUS_CLEARANCE)),
|
||||
]),
|
||||
].sort((left, right) => left - right)
|
||||
const leftBusXs = [
|
||||
...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - BUS_CLEARANCE))]),
|
||||
].sort((left, right) => right - left)
|
||||
const topBusYs = [
|
||||
...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - BUS_CLEARANCE))]),
|
||||
].sort((left, right) => right - left)
|
||||
const bottomBusYs = [
|
||||
...new Set([
|
||||
bottomBusY,
|
||||
...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + BUS_CLEARANCE)),
|
||||
]),
|
||||
].sort((left, right) => left - right)
|
||||
const busLimit = Math.max(1, Math.floor(Math.sqrt(ROUTING_CANDIDATE_BUDGET / 4)))
|
||||
const candidateLeftBusXs = leftBusXs.length > busLimit ? leftBusXs.slice(0, busLimit) : leftBusXs
|
||||
const candidateRightBusXs = rightBusXs.length > busLimit ? rightBusXs.slice(0, busLimit) : rightBusXs
|
||||
const candidateTopBusYs = topBusYs.length > busLimit ? topBusYs.slice(0, busLimit) : topBusYs
|
||||
const candidateBottomBusYs = bottomBusYs.length > busLimit ? bottomBusYs.slice(0, busLimit) : bottomBusYs
|
||||
const buses = [
|
||||
...candidateLeftBusXs.map((coordinate) => lane("x", coordinate)),
|
||||
...candidateRightBusXs.map((coordinate) => lane("x", coordinate)),
|
||||
...candidateTopBusYs.map((coordinate) => lane("y", coordinate)),
|
||||
...candidateBottomBusYs.map((coordinate) => lane("y", coordinate)),
|
||||
]
|
||||
const routeViaBus = (start: FlowchartPoint, targetSide: DiagramSide, bus: DiagramLane): FlowchartEdgeRoute => {
|
||||
const end = boundsSidePoint(to, targetSide)
|
||||
const approach = shiftPoint(
|
||||
end,
|
||||
targetSide === "left" ? "left" : targetSide === "right" ? "right" : targetSide === "top" ? "up" : "down",
|
||||
)
|
||||
return {
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : bus.axis === "x" ? "y" : "x",
|
||||
points:
|
||||
bus.axis === "x"
|
||||
? pathThrough([start, { x: bus.coordinate, y: start.y }, { x: bus.coordinate, y: approach.y }, approach, end])
|
||||
: pathThrough([
|
||||
start,
|
||||
{ x: start.x, y: bus.coordinate },
|
||||
{ x: approach.x, y: bus.coordinate },
|
||||
approach,
|
||||
end,
|
||||
]),
|
||||
}
|
||||
}
|
||||
const selfLoops =
|
||||
from.id !== to.id
|
||||
? []
|
||||
: [
|
||||
...candidateRightBusXs.flatMap((busX) =>
|
||||
candidateBottomBusYs.map(
|
||||
(busY): FlowchartEdgeRoute => ({
|
||||
...route,
|
||||
points: pathThrough([
|
||||
boundsSidePoint(from, "right"),
|
||||
{ x: busX, y: from.centerY },
|
||||
{ x: busX, y: busY },
|
||||
{ x: from.centerX, y: busY },
|
||||
boundsSidePoint(from, "bottom"),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
...candidateBottomBusYs.flatMap((busY) =>
|
||||
candidateLeftBusXs.map(
|
||||
(busX): FlowchartEdgeRoute => ({
|
||||
...route,
|
||||
points: pathThrough([
|
||||
boundsSidePoint(from, "bottom"),
|
||||
{ x: from.centerX, y: busY },
|
||||
{ x: busX, y: busY },
|
||||
{ x: busX, y: from.centerY },
|
||||
boundsSidePoint(from, "left"),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
...candidateLeftBusXs.flatMap((busX) =>
|
||||
candidateTopBusYs.map(
|
||||
(busY): FlowchartEdgeRoute => ({
|
||||
...route,
|
||||
points: pathThrough([
|
||||
boundsSidePoint(from, "left"),
|
||||
{ x: busX, y: from.centerY },
|
||||
{ x: busX, y: busY },
|
||||
{ x: from.centerX, y: busY },
|
||||
boundsSidePoint(from, "top"),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
...candidateTopBusYs.flatMap((busY) =>
|
||||
candidateRightBusXs.map(
|
||||
(busX): FlowchartEdgeRoute => ({
|
||||
...route,
|
||||
points: pathThrough([
|
||||
boundsSidePoint(from, "top"),
|
||||
{ x: from.centerX, y: busY },
|
||||
{ x: busX, y: busY },
|
||||
{ x: busX, y: from.centerY },
|
||||
boundsSidePoint(from, "right"),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
]
|
||||
const targetSides = ["left", "right", "top", "bottom"] satisfies DiagramSide[]
|
||||
const shortest = (candidates: FlowchartEdgeRoute[], accept: (candidate: FlowchartEdgeRoute) => boolean) =>
|
||||
candidates.filter(accept).sort((left, right) => routeLength(left) - routeLength(right))[0]
|
||||
if (from.id === to.id)
|
||||
return (
|
||||
shortest(selfLoops, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(selfLoops, (candidate) => !intersectsNode(candidate)) ??
|
||||
route
|
||||
)
|
||||
const currentTargetSide = sideForOutsidePoint(to, route.points.at(-1)!)
|
||||
const preservedTargets = buses.map((bus) => routeViaBus(route.points[0]!, currentTargetSide, bus))
|
||||
const sameSides: FlowchartEdgeRoute[] = [
|
||||
...candidateRightBusXs.map(
|
||||
(busX): FlowchartEdgeRoute => ({
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "y",
|
||||
points: pathViaLane(boundsSidePoint(from, "right"), lane("x", busX), boundsSidePoint(to, "right")),
|
||||
}),
|
||||
),
|
||||
...candidateLeftBusXs.map(
|
||||
(busX): FlowchartEdgeRoute => ({
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "y",
|
||||
points: pathViaLane(boundsSidePoint(from, "left"), lane("x", busX), boundsSidePoint(to, "left")),
|
||||
}),
|
||||
),
|
||||
...candidateTopBusYs.map(
|
||||
(busY): FlowchartEdgeRoute => ({
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "x",
|
||||
points: pathViaLane(boundsSidePoint(from, "top"), lane("y", busY), boundsSidePoint(to, "top")),
|
||||
}),
|
||||
),
|
||||
...candidateBottomBusYs.map(
|
||||
(busY): FlowchartEdgeRoute => ({
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "x",
|
||||
points: pathViaLane(boundsSidePoint(from, "bottom"), lane("y", busY), boundsSidePoint(to, "bottom")),
|
||||
}),
|
||||
),
|
||||
]
|
||||
const preservedSources = targetSides.flatMap((targetSide) =>
|
||||
buses.map((bus) => routeViaBus(route.points[0]!, targetSide, bus)),
|
||||
)
|
||||
const attachments = targetSides.flatMap((sourceSide) =>
|
||||
targetSides.flatMap((targetSide) =>
|
||||
buses.map((bus) => routeViaBus(boundsSidePoint(from, sourceSide), targetSide, bus)),
|
||||
),
|
||||
)
|
||||
const preservedTargetCandidates: FlowchartEdgeRoute[] = [
|
||||
{
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "y",
|
||||
points: pathThrough([start, { x: leftBusX, y: start.y }, { x: leftBusX, y: approach.y }, approach, end]),
|
||||
},
|
||||
{
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "y",
|
||||
points: pathThrough([start, { x: rightBusX, y: start.y }, { x: rightBusX, y: approach.y }, approach, end]),
|
||||
},
|
||||
{
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "x",
|
||||
points: pathThrough([start, { x: start.x, y: topBusY }, { x: approach.x, y: topBusY }, approach, end]),
|
||||
},
|
||||
{
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "x",
|
||||
points: pathThrough([start, { x: start.x, y: bottomBusY }, { x: approach.x, y: bottomBusY }, approach, end]),
|
||||
},
|
||||
]
|
||||
const candidates: FlowchartEdgeRoute[] = [
|
||||
{
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "y",
|
||||
points: pathViaLane(boundsSidePoint(from, "right"), lane("x", rightBusX), boundsSidePoint(to, "right")),
|
||||
},
|
||||
{
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "y",
|
||||
points: pathViaLane(boundsSidePoint(from, "left"), lane("x", leftBusX), boundsSidePoint(to, "left")),
|
||||
},
|
||||
{
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "x",
|
||||
points: pathViaLane(boundsSidePoint(from, "top"), lane("y", topBusY), boundsSidePoint(to, "top")),
|
||||
},
|
||||
{
|
||||
...route,
|
||||
labelAxis: route.labelAxis === undefined ? undefined : "x",
|
||||
points: pathViaLane(boundsSidePoint(from, "bottom"), lane("y", bottomBusY), boundsSidePoint(to, "bottom")),
|
||||
},
|
||||
]
|
||||
const shortestValid = (candidateRoutes: FlowchartEdgeRoute[]): FlowchartEdgeRoute | undefined =>
|
||||
candidateRoutes
|
||||
.filter((candidate) => !intersectsObstacle(candidate))
|
||||
.sort((left, right) => routeLength(left) - routeLength(right))[0]
|
||||
if (subgraphBounds) {
|
||||
return shortestValid(preservedTargetCandidates) ?? shortestValid(candidates) ?? route
|
||||
return (
|
||||
shortest(preservedTargets, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(sameSides, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsNode(candidate)) ??
|
||||
route
|
||||
)
|
||||
}
|
||||
return (
|
||||
candidates.find((candidate) => !intersectsObstacle(candidate)) ?? shortestValid(preservedTargetCandidates) ?? route
|
||||
sameSides.find((candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedTargets, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
attachments.find((candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ??
|
||||
route
|
||||
)
|
||||
}
|
||||
|
||||
function avoidLabelOverlap(
|
||||
route: FlowchartEdgeRoute,
|
||||
otherRoutes: readonly FlowchartEdgeRoute[],
|
||||
bounds: ReadonlyMap<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
): FlowchartEdgeRoute {
|
||||
if (!route.edge.label) return route
|
||||
const nodeBounds = [...bounds.values()]
|
||||
const frameBounds = [...(subgraphBounds?.values() ?? [])]
|
||||
const otherLabels = otherRoutes.flatMap((other) =>
|
||||
other.edge.label ? [flowchartRouteLabelLayout(other, diagramTextWidth)] : [],
|
||||
)
|
||||
const otherConnectorBounds = otherRoutes.flatMap((other) => {
|
||||
const source = bounds.get(other.edge.from)
|
||||
const sourcePoint = other.points[0]
|
||||
if (!source || !sourcePoint) return []
|
||||
const connector = flowchartSourceConnector(source, sourcePoint)
|
||||
return [
|
||||
{ left: connector.x, top: connector.y, width: 1, height: 1 },
|
||||
{ left: sourcePoint.x, top: sourcePoint.y, width: 1, height: 1 },
|
||||
]
|
||||
})
|
||||
const intersectsObstacle = (label: FlowchartEdgeLabelLayout): boolean =>
|
||||
nodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
|
||||
frameBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
|
||||
labelIntersectsLabels(label, otherLabels) ||
|
||||
labelIntersectsRoutePaths(label, otherRoutes) ||
|
||||
otherConnectorBounds.some((bound) => labelIntersectsBounds(label, bound))
|
||||
const current = flowchartRouteLabelLayout(route, diagramTextWidth)
|
||||
if (!intersectsObstacle(current)) return route
|
||||
|
||||
const seen = new Set<string>()
|
||||
let remaining = ROUTING_CANDIDATE_BUDGET
|
||||
const available = (candidate: FlowchartPoint) => {
|
||||
remaining--
|
||||
const key = `${candidate.x}:${candidate.y}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return !intersectsObstacle({ ...current, point: candidate })
|
||||
}
|
||||
for (let index = 1; index < route.points.length && remaining > 0; index++) {
|
||||
const segment = segmentBetween(route.points[index - 1]!, route.points[index]!)
|
||||
if (!segment) continue
|
||||
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth, route.labelAxis, index - 1)
|
||||
const fixed =
|
||||
segment.axis === "y"
|
||||
? [label.point, { x: segment.from.x - label.width, y: label.point.y }]
|
||||
: [
|
||||
label.point,
|
||||
{ x: label.point.x, y: segment.from.y - label.height },
|
||||
{ x: label.point.x, y: segment.from.y + 1 },
|
||||
]
|
||||
for (const candidate of fixed) {
|
||||
if (remaining <= 0) return route
|
||||
if (available(candidate)) return { ...route, labelPoint: candidate }
|
||||
}
|
||||
if (segment.axis === "y") {
|
||||
const bottom = Math.max(segment.from.y, segment.to.y) - label.height + 1
|
||||
for (let y = Math.min(segment.from.y, segment.to.y); y <= bottom && remaining > 0; y++) {
|
||||
for (const x of [segment.from.x + 1, segment.from.x - label.width]) {
|
||||
const candidate = { x, y }
|
||||
if (available(candidate)) return { ...route, labelPoint: candidate }
|
||||
if (remaining <= 0) return route
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
const right = Math.max(segment.from.x, segment.to.x) - label.width + 1
|
||||
for (let x = Math.min(segment.from.x, segment.to.x); x <= right && remaining > 0; x++) {
|
||||
for (const y of [segment.from.y, segment.from.y - label.height, segment.from.y + 1]) {
|
||||
const candidate = { x, y }
|
||||
if (available(candidate)) return { ...route, labelPoint: candidate }
|
||||
if (remaining <= 0) return route
|
||||
}
|
||||
}
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
export function routeFlowchartEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
@@ -833,7 +1110,7 @@ export function routeFlowchartEdges(
|
||||
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
|
||||
: undefined
|
||||
|
||||
routeParallelEdges(routedDiagram, bounds, directionForEdge, leftBoundary, handled, routes)
|
||||
routeParallelEdges(routedDiagram, bounds, handled, routes)
|
||||
|
||||
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
|
||||
const horizontalEdges = routedDiagram.edges.filter(
|
||||
@@ -841,7 +1118,7 @@ export function routeFlowchartEdges(
|
||||
)
|
||||
if (horizontalEdges.length === 0) continue
|
||||
const records = horizontalForwardRecords(horizontalEdges, bounds, direction)
|
||||
routeHorizontalFanOut(records, direction, handled, routes)
|
||||
routeHorizontalFanOut(records, bounds, direction, handled, routes)
|
||||
routeHorizontalFanIn(records, direction, handled, routes)
|
||||
}
|
||||
|
||||
@@ -868,7 +1145,9 @@ export function routeFlowchartEdges(
|
||||
for (let index = routes.length - 1; index >= 0; index--) {
|
||||
routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index)
|
||||
}
|
||||
return routes
|
||||
return routes.reduce<FlowchartEdgeRoute[]>((resolved, route, index) => {
|
||||
return [...resolved, avoidLabelOverlap(route, [...resolved, ...routes.slice(index + 1)], bounds, subgraphBounds)]
|
||||
}, [])
|
||||
}
|
||||
|
||||
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface FlowchartEdgeRoute {
|
||||
edge: FlowchartEdge
|
||||
points: FlowchartPoint[]
|
||||
labelAxis?: DiagramAxis
|
||||
labelPoint?: FlowchartPoint
|
||||
}
|
||||
|
||||
export type FlowchartEdgeDirection = DiagramDirection
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderGanttDiagram } from "./diagram.js"
|
||||
import { drawGanttDiagramGrid } from "./drawing.js"
|
||||
import { isMermaidGanttDiagram, parseMermaidGanttDiagram } from "./parser.js"
|
||||
|
||||
const secondsDiagram = `gantt
|
||||
dateFormat s
|
||||
axisFormat %Ss
|
||||
section OLD (blocking)
|
||||
provider.create (Modal sandbox) :crit, 0, 15
|
||||
model streams first token :15, 17
|
||||
section NEW (eager kick)
|
||||
reserve (DB insert) :0, 1
|
||||
model streams first token :0, 2
|
||||
provisioning in background :active, 0, 15
|
||||
model calls bash → spawn runs :15, 16
|
||||
section NEW (pure chat thread)
|
||||
reserve (DB insert) :0, 1
|
||||
model answers, never calls bash :0, 4`
|
||||
|
||||
describe("GanttDiagram", () => {
|
||||
test("detects and parses second-based task ranges and states", () => {
|
||||
const diagram = parseMermaidGanttDiagram(secondsDiagram)
|
||||
|
||||
expect(diagram.dateFormat).toBe("s")
|
||||
expect(diagram.axisFormat).toBe("%Ss")
|
||||
expect(diagram.tasks).toHaveLength(8)
|
||||
expect(diagram.tasks[0]).toMatchObject({
|
||||
label: "provider.create (Modal sandbox)",
|
||||
start: 0,
|
||||
end: 15_000,
|
||||
state: "critical",
|
||||
})
|
||||
expect(diagram.tasks[4]).toMatchObject({
|
||||
label: "provisioning in background",
|
||||
start: 0,
|
||||
end: 15_000,
|
||||
state: "active",
|
||||
})
|
||||
})
|
||||
|
||||
test("renders sections, a formatted axis, and aligned task bars", () => {
|
||||
expectDiagram(renderGanttDiagram(secondsDiagram, { layoutMaxWidth: 100 })).toContainInOrder(
|
||||
"00s",
|
||||
"15s",
|
||||
"OLD (blocking)",
|
||||
"provider.create (Modal sandbox)",
|
||||
"model streams first token",
|
||||
"NEW (eager kick)",
|
||||
"provisioning in background",
|
||||
"NEW (pure chat thread)",
|
||||
"model answers, never calls bash",
|
||||
)
|
||||
expect(renderGanttDiagram(secondsDiagram)).toContain("\n\nNEW (eager kick)")
|
||||
expect(renderGanttDiagram(secondsDiagram)).not.toContain("·")
|
||||
})
|
||||
|
||||
test("renders alternate terminal bar styles", () => {
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "block" })).toContain("█")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "capsule" })).toContain("╶")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "points" })).toContain("●")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", track: "dots" })).toContain("·")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", track: "line" })).not.toContain("·")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", endpoints: "points" })).toContain("●")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", line: "thin" })).toContain("─")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", line: "double" })).toContain("═")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", line: "dashed" })).toContain("╌")
|
||||
expect(renderGanttDiagram(secondsDiagram, { labels: "tree" })).toContain("├─ provider.create")
|
||||
expect(renderGanttDiagram(secondsDiagram, { labels: "tree" })).toContain("└─ model streams first token")
|
||||
expect(renderGanttDiagram(secondsDiagram, { sections: "spaced" })).toContain("\n\nNEW (eager kick)")
|
||||
|
||||
const points = drawGanttDiagramGrid(parseMermaidGanttDiagram(secondsDiagram), {
|
||||
style: "track",
|
||||
endpoints: "points",
|
||||
trackTone: "faint",
|
||||
}).rows.flatMap((row) => row.filter((cell) => cell.char === "●"))
|
||||
expect(points.every((cell) => cell.style === "trackFaint")).toBe(true)
|
||||
})
|
||||
|
||||
test("resolves task ids, after dependencies, durations, and milestones", () => {
|
||||
const diagram = parseMermaidGanttDiagram(`gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
task one :done, first, 2026-08-01, 2d
|
||||
deploy :milestone, after first, 0d`)
|
||||
|
||||
expect(diagram.tasks[1]).toMatchObject({
|
||||
start: Date.UTC(2026, 7, 3),
|
||||
end: Date.UTC(2026, 7, 3),
|
||||
state: "milestone",
|
||||
})
|
||||
expect(
|
||||
renderGanttDiagram(`gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
task one :first, 2026-08-01, 2d
|
||||
deploy :milestone, after first, 0d`),
|
||||
).toContain("◆")
|
||||
})
|
||||
|
||||
test("rejects unsupported or ambiguous syntax with source diagnostics", () => {
|
||||
expect(() => parseMermaidGanttDiagram("gantt\n task :not-a-date, 2d")).toThrow(
|
||||
new MermaidSyntaxError(
|
||||
"gantt",
|
||||
2,
|
||||
"task :not-a-date, 2d",
|
||||
'Unsupported date "not-a-date" for dateFormat YYYY-MM-DD',
|
||||
),
|
||||
)
|
||||
expect(() => parseMermaidGanttDiagram("gantt\n task :after missing, 2d")).toThrow('Unknown Gantt task id "missing"')
|
||||
expect(() => parseMermaidGanttDiagram("gantt\n excludes weekends")).toThrow(
|
||||
"excludes is not supported in gantt diagram",
|
||||
)
|
||||
})
|
||||
|
||||
test("recognizes only Gantt headers", () => {
|
||||
expect(isMermaidGanttDiagram("%% comment\ngantt\n task :0, 1")).toBe(true)
|
||||
expect(isMermaidGanttDiagram("timeline\n 2026 : ship")).toBe(false)
|
||||
})
|
||||
|
||||
test("renders partial diagrams containing only sections", () => {
|
||||
expect(renderGanttDiagram("gantt\n section Planning")).toBe("Planning")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { drawGanttDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidGanttDiagram } from "./parser.js"
|
||||
import { renderGanttGridText } from "./render-grid.js"
|
||||
import type { GanttDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export function renderGanttDiagram(content: string, options: GanttDiagramRenderOptions = {}): string {
|
||||
return renderGanttGridText(drawGanttDiagramGrid(parseMermaidGanttDiagram(content), options))
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { DiagramCanvas } from "../core/canvas.js"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import type { GanttGrid } from "./render-grid.js"
|
||||
import type {
|
||||
GanttCellStyle,
|
||||
GanttDiagram,
|
||||
GanttDiagramRenderOptions,
|
||||
GanttLabelLayout,
|
||||
GanttLineStyle,
|
||||
GanttRenderStyle,
|
||||
GanttTask,
|
||||
} from "./types.js"
|
||||
|
||||
const LABEL_GAP = 2
|
||||
const MIN_CHART_WIDTH = 24
|
||||
const MAX_CHART_WIDTH = 64
|
||||
|
||||
export function drawGanttDiagramGrid(diagram: GanttDiagram, options: GanttDiagramRenderOptions = {}): GanttGrid {
|
||||
if (diagram.entries.length === 0) return new DiagramCanvas(0, 0)
|
||||
const labels = diagram.entries.map((_, index) => entryLabel(diagram, index, options.labels ?? "left"))
|
||||
const labelWidth = Math.max(...labels.map(diagramTextWidth))
|
||||
const entryRows: number[] = []
|
||||
let bodyHeight = 0
|
||||
diagram.entries.forEach((entry, index) => {
|
||||
if ((options.sections ?? "spaced") === "spaced" && entry.type === "section" && index > 0) bodyHeight += 1
|
||||
entryRows.push(bodyHeight)
|
||||
bodyHeight += 1
|
||||
})
|
||||
if (diagram.tasks.length === 0) {
|
||||
const grid: GanttGrid = new DiagramCanvas(labelWidth, bodyHeight)
|
||||
diagram.entries.forEach((entry, index) => {
|
||||
if (entry.type === "section") grid.setText(0, entryRows[index]!, entry.section.label, "section")
|
||||
})
|
||||
return grid
|
||||
}
|
||||
const available = (options.layoutMaxWidth ?? 120) - labelWidth - LABEL_GAP
|
||||
const chartWidth = Math.max(MIN_CHART_WIDTH, Math.min(MAX_CHART_WIDTH, available))
|
||||
const starts = diagram.tasks.map((task) => task.start)
|
||||
const ends = diagram.tasks.map((task) => task.end)
|
||||
const minimum = Math.min(...starts)
|
||||
const maximum = Math.max(...ends)
|
||||
const span = Math.max(1, maximum - minimum)
|
||||
const titleHeight = diagram.title ? 2 : 0
|
||||
const axisHeight = 2
|
||||
const grid: GanttGrid = new DiagramCanvas(labelWidth + LABEL_GAP + chartWidth, titleHeight + axisHeight + bodyHeight)
|
||||
const chartX = labelWidth + LABEL_GAP
|
||||
|
||||
if (diagram.title)
|
||||
grid.setText(
|
||||
Math.max(0, chartX + Math.floor((chartWidth - diagramTextWidth(diagram.title)) / 2)),
|
||||
0,
|
||||
diagram.title,
|
||||
"title",
|
||||
)
|
||||
drawAxis(grid, chartX, titleHeight, chartWidth, minimum, span, diagram.axisFormat)
|
||||
|
||||
diagram.entries.forEach((entry, index) => {
|
||||
const y = titleHeight + axisHeight + entryRows[index]!
|
||||
if (entry.type === "section") {
|
||||
grid.setText(0, y, labels[index]!, "section")
|
||||
return
|
||||
}
|
||||
const label = labels[index]!
|
||||
const labelX = options.labels === "right" ? labelWidth - diagramTextWidth(label) : 0
|
||||
grid.setText(labelX, y, label, entry.task.state)
|
||||
drawTask(grid, entry.task, chartX, y, chartWidth, minimum, span, options)
|
||||
})
|
||||
return grid
|
||||
}
|
||||
|
||||
function drawAxis(
|
||||
grid: GanttGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
minimum: number,
|
||||
span: number,
|
||||
format: string,
|
||||
): void {
|
||||
for (let offset = 0; offset < width; offset++) grid.setCell(x + offset, y + 1, "─", "axis")
|
||||
const step = tickStep(span)
|
||||
const ticks: number[] = []
|
||||
for (let value = Math.ceil(minimum / step) * step; value <= minimum + span; value += step) ticks.push(value)
|
||||
if (ticks.length === 0) ticks.push(minimum, minimum + span)
|
||||
for (const value of ticks) {
|
||||
const offset = Math.round(((value - minimum) / span) * (width - 1))
|
||||
const label = formatTime(value, format)
|
||||
const labelX = Math.max(
|
||||
x,
|
||||
Math.min(x + width - diagramTextWidth(label), x + offset - Math.floor(diagramTextWidth(label) / 2)),
|
||||
)
|
||||
grid.setText(labelX, y, label, "axis")
|
||||
grid.setCell(x + offset, y + 1, "┬", "axis")
|
||||
}
|
||||
}
|
||||
|
||||
function tickStep(span: number): number {
|
||||
const steps = [
|
||||
1_000, 5_000, 10_000, 30_000, 60_000, 300_000, 900_000, 3_600_000, 21_600_000, 43_200_000, 86_400_000, 172_800_000,
|
||||
604_800_000, 2_592_000_000, 31_536_000_000,
|
||||
]
|
||||
return steps.find((step) => step >= span / 4) ?? steps.at(-1)!
|
||||
}
|
||||
|
||||
function drawTask(
|
||||
grid: GanttGrid,
|
||||
task: GanttTask,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
minimum: number,
|
||||
span: number,
|
||||
options: GanttDiagramRenderOptions,
|
||||
): void {
|
||||
const style: GanttRenderStyle = options.style ?? "track"
|
||||
const line = lineGlyph(options.line ?? "thin")
|
||||
const start = Math.round(((task.start - minimum) / span) * (width - 1))
|
||||
const end = Math.round(((task.end - minimum) / span) * (width - 1))
|
||||
if (task.state === "milestone" || start === end) {
|
||||
grid.setCell(x + start, y, "◆", task.state)
|
||||
return
|
||||
}
|
||||
if (style === "track") {
|
||||
for (let offset = 0; offset < width; offset++) {
|
||||
grid.setCell(x + offset, y, (options.track ?? "line") === "line" ? line : "·", trackCellStyle(options))
|
||||
}
|
||||
}
|
||||
const glyph = style === "block" ? "█" : line
|
||||
for (let offset = start; offset <= end; offset++) grid.setCell(x + offset, y, glyph, task.state)
|
||||
if (style === "block") return
|
||||
if (style === "capsule" || style === "track") {
|
||||
if (style === "track") {
|
||||
if (options.endpoints === "points") {
|
||||
grid.setCell(x + start, y, "●", trackCellStyle(options))
|
||||
grid.setCell(x + end, y, "●", trackCellStyle(options))
|
||||
}
|
||||
return
|
||||
}
|
||||
const caps = capGlyphs(options.line ?? "thin")
|
||||
grid.setCell(x + start, y, caps.start, task.state)
|
||||
grid.setCell(x + end, y, caps.end, task.state)
|
||||
return
|
||||
}
|
||||
if (style === "points") {
|
||||
grid.setCell(x + start, y, "●", task.state)
|
||||
grid.setCell(x + end, y, "●", task.state)
|
||||
return
|
||||
}
|
||||
grid.setCell(x + start, y, "┣", task.state)
|
||||
grid.setCell(x + end, y, "┫", task.state)
|
||||
}
|
||||
|
||||
function entryLabel(diagram: GanttDiagram, index: number, layout: GanttLabelLayout): string {
|
||||
const entry = diagram.entries[index]!
|
||||
if (entry.type === "section" || layout !== "tree") {
|
||||
return entry.type === "section" ? entry.section.label : entry.task.label
|
||||
}
|
||||
const last = diagram.entries[index + 1]?.type !== "task"
|
||||
return ` ${last ? "└" : "├"}─ ${entry.task.label}`
|
||||
}
|
||||
|
||||
function lineGlyph(line: GanttLineStyle): string {
|
||||
if (line === "thin") return "─"
|
||||
if (line === "double") return "═"
|
||||
if (line === "dashed") return "╌"
|
||||
return "━"
|
||||
}
|
||||
|
||||
function capGlyphs(line: GanttLineStyle): { start: string; end: string } {
|
||||
if (line === "thin" || line === "dashed") return { start: "╶", end: "╴" }
|
||||
if (line === "double") return { start: "╞", end: "╡" }
|
||||
return { start: "╺", end: "╸" }
|
||||
}
|
||||
|
||||
function trackCellStyle(options: GanttDiagramRenderOptions): "trackMedium" | "trackDim" | "trackFaint" {
|
||||
if (options.trackTone === "medium") return "trackMedium"
|
||||
if (options.trackTone === "dim") return "trackDim"
|
||||
return "trackFaint"
|
||||
}
|
||||
|
||||
function formatTime(value: number, format: string): string {
|
||||
const date = new Date(value)
|
||||
const parts: Record<string, string> = {
|
||||
"%Y": String(date.getUTCFullYear()),
|
||||
"%m": String(date.getUTCMonth() + 1).padStart(2, "0"),
|
||||
"%d": String(date.getUTCDate()).padStart(2, "0"),
|
||||
"%H": String(date.getUTCHours()).padStart(2, "0"),
|
||||
"%M": String(date.getUTCMinutes()).padStart(2, "0"),
|
||||
"%S": String(date.getUTCSeconds()).padStart(2, "0"),
|
||||
}
|
||||
return Object.entries(parts).reduce((result, [token, replacement]) => result.replaceAll(token, replacement), format)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import type { GanttDiagram, GanttEntry, GanttSection, GanttTask, GanttTaskState } from "./types.js"
|
||||
|
||||
const HEADER_RE = /^gantt$/i
|
||||
const DIRECTIVE_RE = /^(title|dateFormat|axisFormat|tickInterval|excludes|todayMarker)\s+(.+)$/i
|
||||
const SECTION_RE = /^section(?:\s+(.+))?$/i
|
||||
const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i
|
||||
const TASK_STATES = new Set(["active", "done", "crit", "milestone", "vert"])
|
||||
|
||||
export function isMermaidGanttDiagram(content: string): boolean {
|
||||
return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
|
||||
}
|
||||
|
||||
export function parseMermaidGanttDiagram(content: string): GanttDiagram {
|
||||
const tasks: GanttTask[] = []
|
||||
const entries: GanttEntry[] = []
|
||||
const tasksById = new Map<string, GanttTask>()
|
||||
let title: string | undefined
|
||||
let dateFormat = "YYYY-MM-DD"
|
||||
let axisFormat = "%Y-%m-%d"
|
||||
let section: GanttSection | undefined
|
||||
let headerSeen = false
|
||||
let inAccessibilityDescription = false
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = stripComment(source.text)
|
||||
if (inAccessibilityDescription) {
|
||||
if (line === "}") inAccessibilityDescription = false
|
||||
continue
|
||||
}
|
||||
if (/^accDescr\s*\{$/i.test(line)) {
|
||||
inAccessibilityDescription = true
|
||||
continue
|
||||
}
|
||||
if (ACCESSIBILITY_RE.test(line)) continue
|
||||
if (!line) continue
|
||||
if (HEADER_RE.test(line)) {
|
||||
if (headerSeen) throw syntaxError(source.lineNumber, line, "Gantt header can only appear once")
|
||||
headerSeen = true
|
||||
continue
|
||||
}
|
||||
if (!headerSeen) throw syntaxError(source.lineNumber, line, "Gantt header is required")
|
||||
|
||||
const sectionMatch = line.match(SECTION_RE)
|
||||
if (sectionMatch) {
|
||||
if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Gantt section cannot be empty")
|
||||
section = { label: stripMermaidQuotes(sectionMatch[1]) }
|
||||
entries.push({ type: "section", section })
|
||||
continue
|
||||
}
|
||||
|
||||
const directive = line.match(DIRECTIVE_RE)
|
||||
if (directive) {
|
||||
const name = directive[1]!.toLowerCase()
|
||||
const value = directive[2]!.trim()
|
||||
if (name === "title") title = stripMermaidQuotes(value)
|
||||
if (name === "dateformat") dateFormat = value
|
||||
if (name === "axisformat") axisFormat = value
|
||||
if (!["title", "dateformat", "axisformat"].includes(name)) {
|
||||
throw syntaxError(source.lineNumber, line, `${directive[1]} is not supported`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const separator = line.indexOf(":")
|
||||
if (separator < 1) throw syntaxError(source.lineNumber, line)
|
||||
const label = stripMermaidQuotes(line.slice(0, separator))
|
||||
const fields = line
|
||||
.slice(separator + 1)
|
||||
.split(",")
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean)
|
||||
const flags = new Set<string>()
|
||||
while (fields[0] && TASK_STATES.has(fields[0].toLowerCase())) flags.add(fields.shift()!.toLowerCase())
|
||||
if (fields.length < 2 || fields.length > 3) {
|
||||
throw syntaxError(source.lineNumber, line, "Gantt tasks require a start and end or duration")
|
||||
}
|
||||
const id = fields.length === 3 ? fields.shift() : undefined
|
||||
if (id && tasksById.has(id)) throw syntaxError(source.lineNumber, line, `Duplicate task id "${id}"`)
|
||||
const start = parseStart(fields[0]!, dateFormat, tasksById, source.lineNumber, line)
|
||||
const end = parseEnd(fields[1]!, start, dateFormat, source.lineNumber, line)
|
||||
if (end < start) throw syntaxError(source.lineNumber, line, "Gantt task cannot end before it starts")
|
||||
const task: GanttTask = {
|
||||
label,
|
||||
...(id ? { id } : {}),
|
||||
...(section ? { section } : {}),
|
||||
start,
|
||||
end,
|
||||
state: taskState(flags),
|
||||
}
|
||||
tasks.push(task)
|
||||
entries.push({ type: "task", task })
|
||||
if (id) tasksById.set(id, task)
|
||||
}
|
||||
|
||||
if (!headerSeen) throw new MermaidSyntaxError("gantt", 1, "", "Gantt header is required")
|
||||
return { ...(title === undefined ? {} : { title }), dateFormat, axisFormat, tasks, entries }
|
||||
}
|
||||
|
||||
function parseStart(
|
||||
value: string,
|
||||
format: string,
|
||||
tasksById: Map<string, GanttTask>,
|
||||
lineNumber: number,
|
||||
sourceLine: string,
|
||||
): number {
|
||||
if (!/^after\s+/i.test(value)) return parseDate(value, format, lineNumber, sourceLine)
|
||||
const dependencies = value
|
||||
.replace(/^after\s+/i, "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
const tasks = dependencies.map((id) => tasksById.get(id))
|
||||
const missing = dependencies.find((_, index) => !tasks[index])
|
||||
if (missing) throw syntaxError(lineNumber, sourceLine, `Unknown Gantt task id "${missing}"`)
|
||||
return Math.max(...tasks.map((task) => task!.end))
|
||||
}
|
||||
|
||||
function parseEnd(value: string, start: number, format: string, lineNumber: number, sourceLine: string): number {
|
||||
const duration = value.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/i)
|
||||
if (!duration) return parseDate(value, format, lineNumber, sourceLine)
|
||||
const units = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }
|
||||
return start + Number(duration[1]) * units[duration[2]!.toLowerCase() as keyof typeof units]
|
||||
}
|
||||
|
||||
function parseDate(value: string, format: string, lineNumber: number, sourceLine: string): number {
|
||||
const numeric = Number(value)
|
||||
if (format === "s" && Number.isFinite(numeric)) return numeric * 1_000
|
||||
if (format === "X" && Number.isFinite(numeric)) return numeric * 1_000
|
||||
if (format === "x" && Number.isFinite(numeric)) return numeric
|
||||
|
||||
const calendar = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/)
|
||||
if (calendar && ["YYYY-MM-DD", "YYYY-MM-DD HH:mm", "YYYY-MM-DD HH:mm:ss"].includes(format)) {
|
||||
return Date.UTC(
|
||||
Number(calendar[1]),
|
||||
Number(calendar[2]) - 1,
|
||||
Number(calendar[3]),
|
||||
Number(calendar[4] ?? 0),
|
||||
Number(calendar[5] ?? 0),
|
||||
Number(calendar[6] ?? 0),
|
||||
)
|
||||
}
|
||||
throw syntaxError(lineNumber, sourceLine, `Unsupported date "${value}" for dateFormat ${format}`)
|
||||
}
|
||||
|
||||
function taskState(flags: Set<string>): GanttTaskState {
|
||||
if (flags.has("milestone")) return "milestone"
|
||||
if (flags.has("crit")) return "critical"
|
||||
if (flags.has("done")) return "done"
|
||||
if (flags.has("active")) return "active"
|
||||
return "task"
|
||||
}
|
||||
|
||||
function stripComment(value: string): string {
|
||||
const comment = value.indexOf("%%")
|
||||
return (comment < 0 ? value : value.slice(0, comment)).trim()
|
||||
}
|
||||
|
||||
function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError {
|
||||
return new MermaidSyntaxError("gantt", lineNumber, sourceLine, reason)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas } from "../core/canvas.js"
|
||||
import { renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import type { GanttStyleColors } from "./style.js"
|
||||
import type { GanttCellStyle } from "./types.js"
|
||||
|
||||
export type GanttGrid = DiagramCanvas<GanttCellStyle>
|
||||
|
||||
export function renderGanttGridText(grid: GanttGrid): string {
|
||||
return grid.toString({ trimBottom: true })
|
||||
}
|
||||
|
||||
export function renderGanttGridStyledText(grid: GanttGrid, colors: GanttStyleColors): StyledText {
|
||||
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { blendColor, rgba, type DiagramRgb } from "../core/color/style.js"
|
||||
import type { GanttBaseCellStyle, GanttCellStyle } from "./types.js"
|
||||
|
||||
const DEFAULT_THEME_RGB = {
|
||||
title: [228, 239, 232],
|
||||
axis: [111, 138, 126],
|
||||
section: [154, 184, 169],
|
||||
task: [134, 225, 200],
|
||||
active: [134, 225, 200],
|
||||
critical: [230, 177, 126],
|
||||
done: [111, 138, 126],
|
||||
milestone: [198, 160, 246],
|
||||
} as const satisfies Record<GanttBaseCellStyle, DiagramRgb>
|
||||
|
||||
export type GanttStyleColors = Required<Record<GanttCellStyle, RGBA>>
|
||||
|
||||
export function resolveGanttStyleColors(
|
||||
colors: Partial<Record<GanttBaseCellStyle | "background", RGBA | undefined>> = {},
|
||||
): GanttStyleColors {
|
||||
const axis = colors.axis ?? rgba(DEFAULT_THEME_RGB.axis)
|
||||
const background = colors.background ?? rgba([13, 17, 23])
|
||||
return {
|
||||
title: colors.title ?? rgba(DEFAULT_THEME_RGB.title),
|
||||
axis,
|
||||
section: colors.section ?? rgba(DEFAULT_THEME_RGB.section),
|
||||
task: colors.task ?? rgba(DEFAULT_THEME_RGB.task),
|
||||
active: colors.active ?? rgba(DEFAULT_THEME_RGB.active),
|
||||
critical: colors.critical ?? rgba(DEFAULT_THEME_RGB.critical),
|
||||
done: colors.done ?? rgba(DEFAULT_THEME_RGB.done),
|
||||
milestone: colors.milestone ?? rgba(DEFAULT_THEME_RGB.milestone),
|
||||
trackMedium: blendColor(axis, background, 0.35),
|
||||
trackDim: blendColor(axis, background, 0.55),
|
||||
trackFaint: blendColor(axis, background, 0.72),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type GanttTaskState = "task" | "active" | "critical" | "done" | "milestone"
|
||||
|
||||
export interface GanttSection {
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface GanttTask {
|
||||
label: string
|
||||
id?: string
|
||||
section?: GanttSection
|
||||
start: number
|
||||
end: number
|
||||
state: GanttTaskState
|
||||
}
|
||||
|
||||
export type GanttEntry = { type: "section"; section: GanttSection } | { type: "task"; task: GanttTask }
|
||||
|
||||
export interface GanttDiagram {
|
||||
title?: string
|
||||
dateFormat: string
|
||||
axisFormat: string
|
||||
tasks: GanttTask[]
|
||||
entries: GanttEntry[]
|
||||
}
|
||||
|
||||
export interface GanttDiagramRenderOptions {
|
||||
layoutMaxWidth?: number
|
||||
style?: GanttRenderStyle
|
||||
track?: "dots" | "line"
|
||||
endpoints?: "plain" | "points"
|
||||
line?: GanttLineStyle
|
||||
labels?: GanttLabelLayout
|
||||
sections?: "compact" | "spaced"
|
||||
trackTone?: GanttTrackTone
|
||||
}
|
||||
|
||||
export type GanttRenderStyle = "rail" | "block" | "capsule" | "points" | "track"
|
||||
export type GanttLineStyle = "heavy" | "thin" | "double" | "dashed"
|
||||
export type GanttLabelLayout = "right" | "left" | "tree"
|
||||
export type GanttTrackTone = "medium" | "dim" | "faint"
|
||||
|
||||
export type GanttBaseCellStyle = "title" | "axis" | "section" | GanttTaskState
|
||||
export type GanttTrackCellStyle = "trackMedium" | "trackDim" | "trackFaint"
|
||||
export type GanttCellStyle = GanttBaseCellStyle | GanttTrackCellStyle
|
||||
@@ -17,6 +17,11 @@ import { detectMermaidDiagram } from "./detect.js"
|
||||
import { drawFlowchartDiagramGrid } from "./flowchart/drawing.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { renderGridStyledText, resolveFlowchartStyleColors } from "./flowchart/style.js"
|
||||
import { drawGanttDiagramGrid } from "./gantt/drawing.js"
|
||||
import { parseMermaidGanttDiagram } from "./gantt/parser.js"
|
||||
import { renderGanttGridStyledText } from "./gantt/render-grid.js"
|
||||
import { resolveGanttStyleColors } from "./gantt/style.js"
|
||||
import type { GanttDiagramRenderOptions } from "./gantt/types.js"
|
||||
import { drawGitGraphDiagramGrid } from "./gitgraph/drawing.js"
|
||||
import { parseMermaidGitGraphDiagram } from "./gitgraph/parser.js"
|
||||
import { renderGitGraphGridStyledText } from "./gitgraph/render-grid.js"
|
||||
@@ -48,6 +53,8 @@ export interface MermaidMarkdownRendererOptions {
|
||||
compact?: boolean
|
||||
/** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
|
||||
layoutMaxWidth?: number
|
||||
/** Gantt-specific terminal rendering options. */
|
||||
gantt?: Omit<GanttDiagramRenderOptions, "layoutMaxWidth">
|
||||
colors?: {
|
||||
text?: ColorInput
|
||||
primary?: ColorInput
|
||||
@@ -162,6 +169,28 @@ function prepareDiagram(
|
||||
height: size.height,
|
||||
}
|
||||
}
|
||||
case "gantt": {
|
||||
const grid = drawGanttDiagramGrid(parseMermaidGanttDiagram(source), { ...options.gantt, layoutMaxWidth })
|
||||
const size = grid.getTextSize({ trimBottom: true })
|
||||
return {
|
||||
kind,
|
||||
source,
|
||||
text: renderGanttGridStyledText(
|
||||
grid,
|
||||
resolveGanttStyleColors({
|
||||
title: color(colors.text),
|
||||
axis: color(colors.muted),
|
||||
background: color(colors.background),
|
||||
section: color(colors.secondary),
|
||||
task: color(colors.primary),
|
||||
active: color(colors.primary),
|
||||
critical: color(colors.warning),
|
||||
done: color(colors.muted),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
}
|
||||
}
|
||||
case "sequence": {
|
||||
const grid = drawSequenceDiagramGrid(parseMermaidSequenceDiagram(source), { compact })
|
||||
const size = grid.getTextSize()
|
||||
|
||||
@@ -1,9 +1,64 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import stringWidth from "string-width"
|
||||
import { spatialPathClaim } from "../core/spatial.js"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderStateDiagram } from "./diagram.js"
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { createStateDiagramLayout } from "./layout.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { prepareVisibleStateDiagram } from "./visible-model.js"
|
||||
|
||||
function expectCompleteStateDiagram(source: string, output = renderStateDiagram(source)): void {
|
||||
const diagram = prepareVisibleStateDiagram(parseMermaidStateDiagram(source))
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const states = diagram.states.filter((state) => state.kind === "state")
|
||||
const stateBounds = states.map((state) => layout.bounds.get(state.id)!)
|
||||
|
||||
for (const text of [
|
||||
...states.map((state) => state.label),
|
||||
...diagram.transitions.map((transition) => transition.label),
|
||||
...diagram.notes.flatMap((note) => note.lines),
|
||||
].filter(Boolean)) {
|
||||
expect(output).toContain(text)
|
||||
}
|
||||
for (const [index, bound] of stateBounds.entries()) {
|
||||
for (const other of stateBounds.slice(index + 1)) {
|
||||
expect(
|
||||
bound.left < other.left + other.width &&
|
||||
bound.left + bound.width > other.left &&
|
||||
bound.top < other.top + other.height &&
|
||||
bound.top + bound.height > other.top,
|
||||
`${bound.id} overlaps ${other.id}`,
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
const occupiedByNote = layout.noteBounds.map((note) => {
|
||||
const connector = spatialPathClaim(`connector:${note.id}`, note.id, "boundary", note.connector!.points)
|
||||
return new Set([
|
||||
...Array.from({ length: note.height }, (_, dy) =>
|
||||
Array.from({ length: note.width }, (_, dx) => `${note.left + dx}:${note.top + dy}`),
|
||||
).flat(),
|
||||
...connector.spans.flatMap((span) =>
|
||||
Array.from({ length: span.toX - span.fromX + 1 }, (_, dx) => `${span.fromX + dx}:${span.y}`),
|
||||
),
|
||||
])
|
||||
})
|
||||
for (const [index, occupied] of occupiedByNote.entries()) {
|
||||
for (const bound of stateBounds) {
|
||||
expect(
|
||||
Array.from({ length: bound.height }, (_, dy) =>
|
||||
Array.from({ length: bound.width }, (_, dx) => occupied.has(`${bound.left + dx}:${bound.top + dy}`)),
|
||||
)
|
||||
.flat()
|
||||
.some(Boolean),
|
||||
`${layout.noteBounds[index]!.id} overlaps ${bound.id}`,
|
||||
).toBe(false)
|
||||
}
|
||||
for (const other of occupiedByNote.slice(index + 1)) {
|
||||
expect([...occupied].some((cell) => other.has(cell))).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("StateDiagram", () => {
|
||||
test("detects and parses Mermaid state diagrams", () => {
|
||||
@@ -439,6 +494,17 @@ stateDiagram-v2
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders self transitions from choice pseudo-states", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
Decision --> Decision: reconsider`)
|
||||
|
||||
expect(output).toContain("◆")
|
||||
expect(output).toContain("reconsider")
|
||||
expect(output.split("\n").filter((line) => line.trim())).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("renders parallel transitions without losing labels", () => {
|
||||
const horizontal = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
@@ -455,6 +521,154 @@ stateDiagram-v2
|
||||
expect(vertical).toContain("second")
|
||||
})
|
||||
|
||||
test("renders cyclic same-rank vertical parallels with a note", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TD
|
||||
state "Node S13_0" as S13_0
|
||||
state "Node S13_1" as S13_1
|
||||
state "Node S13_2" as S13_2
|
||||
state "Node S13_3" as S13_3
|
||||
state "Node S13_4" as S13_4
|
||||
S13_0 --> S13_1: state_edge_13_0
|
||||
S13_1 --> S13_2: state_edge_13_1
|
||||
S13_2 --> S13_3: state_edge_13_2
|
||||
S13_3 --> S13_4: state_edge_13_3
|
||||
S13_0 --> S13_2: state_edge_13_4
|
||||
S13_4 --> S13_1: state_edge_13_5
|
||||
S13_2 --> S13_0: state_edge_13_6
|
||||
S13_1 --> S13_2: state_edge_13_7
|
||||
S13_3 --> S13_2: state_edge_13_8
|
||||
S13_3 --> S13_0: state_edge_13_9
|
||||
S13_0 --> S13_0: state_edge_13_10
|
||||
note left of S13_0: state_note_13_0`)
|
||||
|
||||
for (const index of [0, 1, 2, 3, 4]) expect(output).toContain(`Node S13_${index}`)
|
||||
for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) expect(output).toContain(`state_edge_13_${index}`)
|
||||
expect(output).toContain("state_note_13_0")
|
||||
})
|
||||
|
||||
test("preserves every state in a routed multi-note vertical graph", () => {
|
||||
const source = `stateDiagram-v2
|
||||
direction TD
|
||||
state "Node S21_0" as S21_0
|
||||
state "Node S21_1" as S21_1
|
||||
state "Node S21_2" as S21_2
|
||||
state "Node S21_3" as S21_3
|
||||
state "Node S21_4" as S21_4
|
||||
state "Node S21_5" as S21_5
|
||||
S21_0 --> S21_1: state_edge_21_0
|
||||
S21_1 --> S21_2: state_edge_21_1
|
||||
S21_2 --> S21_3: state_edge_21_2
|
||||
S21_3 --> S21_4: state_edge_21_3
|
||||
S21_4 --> S21_5: state_edge_21_4
|
||||
S21_4 --> S21_5: state_edge_21_5
|
||||
S21_2 --> S21_4: state_edge_21_6
|
||||
S21_3 --> S21_1: state_edge_21_7
|
||||
note right of S21_1: state_note_21_0
|
||||
note left of S21_0: state_note_21_1
|
||||
note left of S21_4: state_note_21_2`
|
||||
const output = renderStateDiagram(source)
|
||||
|
||||
for (const index of [0, 1, 2, 3, 4, 5]) expect(output).toContain(`Node S21_${index}`)
|
||||
for (const index of [0, 1, 2, 3, 4, 5, 6, 7]) expect(output).toContain(`state_edge_21_${index}`)
|
||||
for (const index of [0, 1, 2]) expect(output).toContain(`state_note_21_${index}`)
|
||||
expectCompleteStateDiagram(source, output)
|
||||
})
|
||||
|
||||
test("renders every note in a cyclic vertical graph", () => {
|
||||
const source = `stateDiagram-v2
|
||||
direction TB
|
||||
state "Node S27_0" as S27_0
|
||||
state "Node S27_1" as S27_1
|
||||
state "Node S27_2" as S27_2
|
||||
state "Node S27_3" as S27_3
|
||||
state "Node S27_4" as S27_4
|
||||
state "Node S27_5" as S27_5
|
||||
S27_0 --> S27_1: state_edge_27_0
|
||||
S27_1 --> S27_2: state_edge_27_1
|
||||
S27_2 --> S27_3: state_edge_27_2
|
||||
S27_3 --> S27_4: state_edge_27_3
|
||||
S27_4 --> S27_5: state_edge_27_4
|
||||
S27_1 --> S27_0: state_edge_27_5
|
||||
S27_5 --> S27_1: state_edge_27_6
|
||||
S27_2 --> S27_1: state_edge_27_7
|
||||
S27_1 --> S27_5: state_edge_27_8
|
||||
note left of S27_5: state_note_27_0
|
||||
note right of S27_0: state_note_27_1
|
||||
note left of S27_3: state_note_27_2`
|
||||
const output = renderStateDiagram(source)
|
||||
|
||||
for (const index of [0, 1, 2, 3, 4, 5]) expect(output).toContain(`Node S27_${index}`)
|
||||
for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8]) expect(output).toContain(`state_edge_27_${index}`)
|
||||
for (const index of [0, 1, 2]) expect(output).toContain(`state_note_27_${index}`)
|
||||
expectCompleteStateDiagram(source, output)
|
||||
})
|
||||
|
||||
test("places exhausted notes on deterministic exterior lanes", () => {
|
||||
const source = `stateDiagram-v2
|
||||
direction TD
|
||||
state "Node S33_0" as S33_0
|
||||
state "Node S33_1" as S33_1
|
||||
state "Node S33_2" as S33_2
|
||||
state "Node S33_3" as S33_3
|
||||
state "Node S33_4" as S33_4
|
||||
state "Node S33_5" as S33_5
|
||||
state "Node S33_6" as S33_6
|
||||
S33_0 --> S33_1: state_edge_33_0
|
||||
S33_1 --> S33_2: state_edge_33_1
|
||||
S33_2 --> S33_3: state_edge_33_2
|
||||
S33_3 --> S33_4: state_edge_33_3
|
||||
S33_4 --> S33_5: state_edge_33_4
|
||||
S33_5 --> S33_6: state_edge_33_5
|
||||
S33_6 --> S33_4: state_edge_33_6
|
||||
S33_2 --> S33_4: state_edge_33_7
|
||||
S33_3 --> S33_3: state_edge_33_8
|
||||
note left of S33_2: state_note_33_0
|
||||
note left of S33_6: state_note_33_1`
|
||||
const output = renderStateDiagram(source)
|
||||
const exhaustedBudget = { remaining: 0 }
|
||||
const layout = createStateDiagramLayout(prepareVisibleStateDiagram(parseMermaidStateDiagram(source)), {
|
||||
minStateGap: 5,
|
||||
searchBudget: exhaustedBudget,
|
||||
})
|
||||
|
||||
for (const index of [0, 1, 2, 3, 4, 5, 6]) expect(output).toContain(`Node S33_${index}`)
|
||||
for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8]) expect(output).toContain(`state_edge_33_${index}`)
|
||||
for (const index of [0, 1]) expect(output).toContain(`state_note_33_${index}`)
|
||||
expect(layout.noteBounds).toHaveLength(2)
|
||||
expect(exhaustedBudget.remaining).toBe(0)
|
||||
expectCompleteStateDiagram(source, output)
|
||||
})
|
||||
|
||||
test("separates reciprocal RL branches sharing a parallel lane", () => {
|
||||
const source = `stateDiagram-v2
|
||||
direction RL
|
||||
state "Node S237_0" as S237_0
|
||||
state "Node S237_1" as S237_1
|
||||
state "Node S237_2" as S237_2
|
||||
state "Node S237_3" as S237_3
|
||||
state "Node S237_4" as S237_4
|
||||
state "Node S237_5" as S237_5
|
||||
S237_0 --> S237_1: state_edge_237_0
|
||||
S237_1 --> S237_2: state_edge_237_1
|
||||
S237_2 --> S237_3: state_edge_237_2
|
||||
S237_3 --> S237_4: state_edge_237_3
|
||||
S237_4 --> S237_5: state_edge_237_4
|
||||
S237_1 --> S237_5: state_edge_237_5
|
||||
S237_4 --> S237_5: state_edge_237_6
|
||||
S237_4 --> S237_0: state_edge_237_7
|
||||
S237_0 --> S237_4: state_edge_237_8
|
||||
note right of S237_1: state_note_237_0
|
||||
note left of S237_5: state_note_237_1
|
||||
note right of S237_2: state_note_237_2`
|
||||
const output = renderStateDiagram(source)
|
||||
|
||||
for (const index of [0, 1, 2, 3, 4, 5]) expect(output).toContain(`Node S237_${index}`)
|
||||
for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8]) expect(output).toContain(`state_edge_237_${index}`)
|
||||
for (const index of [0, 1, 2]) expect(output).toContain(`state_note_237_${index}`)
|
||||
expectCompleteStateDiagram(source, output)
|
||||
})
|
||||
|
||||
test("separates labels on four parallel vertical transitions", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
@@ -501,6 +715,24 @@ stateDiagram-v2
|
||||
for (const state of ["A", "B", "C", "D"]) expect(output.match(new RegExp(state, "g"))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("keeps dense vertical fan routes out of sibling states", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
state "Alpha" as A
|
||||
state "Beta" as B
|
||||
state "Gamma store" as C
|
||||
state "Delta notifier" as D
|
||||
A --> B
|
||||
A --> C
|
||||
A --> D
|
||||
B --> A: back
|
||||
B --> D: across`)
|
||||
|
||||
for (const text of ["Alpha", "Beta", "Gamma store", "Delta notifier", "back", "across"]) {
|
||||
expect(output).toContain(text)
|
||||
}
|
||||
})
|
||||
|
||||
test("routes parallel transitions around vertically offset states", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
A --> B: first<br/>line two
|
||||
@@ -566,6 +798,68 @@ stateDiagram-v2
|
||||
expect(output).not.toContain("║──")
|
||||
})
|
||||
|
||||
test("places notes away from transition routes and labels", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: proceed only after validation
|
||||
note left of B: blocking note`)
|
||||
|
||||
expect(output).toContain("proceed only after validation")
|
||||
expect(output).toContain("blocking note")
|
||||
expect(output.match(/ A | B /g)).toHaveLength(2)
|
||||
expect(output).toMatch(/[╠╣]═/)
|
||||
})
|
||||
|
||||
test("keeps adjacent note connectors out of other notes", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
note right of B: first note
|
||||
note right of B: second note
|
||||
note left of C: left note`)
|
||||
|
||||
for (const text of ["advance", "continue", "first note", "second note", "left note"]) {
|
||||
expect(output).toContain(text)
|
||||
}
|
||||
expect(output).not.toContain("╝═════╗")
|
||||
})
|
||||
|
||||
test("keeps note connectors out of vertical loop and transition labels", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
state "Processing Ω<br/>phase two" as Processing
|
||||
state "Result ≥ threshold" as Result
|
||||
Ready --> Processing: first
|
||||
Ready --> Processing: duplicate
|
||||
Processing --> Ready: restore
|
||||
Processing --> Processing: heartbeat
|
||||
Processing --> Result: result ≥ 1
|
||||
Result --> Ready: reopen
|
||||
note right of Processing: Unicode note 界`)
|
||||
|
||||
for (const text of ["heartbeat", "result ≥ 1", "Unicode note 界"]) {
|
||||
expect(output).toContain(text)
|
||||
}
|
||||
expect(output).not.toContain("hea║tbeat")
|
||||
expect(output).not.toContain("result║≥ 1")
|
||||
})
|
||||
|
||||
test("keeps nested state labels clear of note connectors", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state "Open document" as Open {
|
||||
[*] --> Clean: load
|
||||
Clean --> Dirty: edit
|
||||
Dirty --> Clean: save
|
||||
}
|
||||
note right of Dirty: unsaved changes`)
|
||||
|
||||
for (const text of ["Open document", "Clean", "Dirty", "load", "edit", "save", "unsaved changes"]) {
|
||||
expect(output).toContain(text)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps duplicate feedback labels away from an independent return path", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
@@ -733,6 +1027,23 @@ stateDiagram-v2
|
||||
}
|
||||
})
|
||||
|
||||
test("routes vertical branch feedback around sibling state bodies", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
[*] --> Root
|
||||
Root --> A
|
||||
Root --> B
|
||||
A --> Merge
|
||||
B --> Merge
|
||||
Merge --> A: retry`)
|
||||
|
||||
for (const state of ["Root", "A", "B", "Merge"]) {
|
||||
expect(output.match(new RegExp(`\\b${state}\\b`, "g"))).toHaveLength(1)
|
||||
}
|
||||
expect(output).toContain("│ B │")
|
||||
expect(output).toContain("retry")
|
||||
})
|
||||
|
||||
test("keeps lifecycle states intact around branches and feedback", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
[*] --> Idle
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import { directionBetween, orthogonalPathPoints, type DiagramDirection } from "../core/geometry.js"
|
||||
import {
|
||||
diagramArrowHead,
|
||||
diagramLineGlyph,
|
||||
@@ -11,9 +12,11 @@ import {
|
||||
createStateDiagramLayout,
|
||||
expandCompositeBoundsForFeedback,
|
||||
expandCompositeBoundsForInternalTransitions,
|
||||
translateStateDiagramLayout,
|
||||
type StateDiagramBoxBounds as BoxBounds,
|
||||
type StateDiagramNoteBounds as StateNoteBounds,
|
||||
} from "./layout.js"
|
||||
import { stateDiagramNoteConnector } from "./note.js"
|
||||
import { DEFAULT_STATE_ARROW_HEAD_STYLE, DEFAULT_STATE_BORDER_STYLE, normalizeStateMinStateGap } from "./options.js"
|
||||
import type { StateGrid } from "./render-grid.js"
|
||||
import {
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
measureStateTransitionLabel,
|
||||
type StateTransitionRenderPlan,
|
||||
} from "./routing.js"
|
||||
import { createStateSearchBudget } from "./search.js"
|
||||
import type {
|
||||
NoteConnectorRampStyle,
|
||||
StateCellStyle,
|
||||
@@ -36,13 +40,14 @@ type StateCell = DiagramCanvasCell<StateCellStyle>
|
||||
|
||||
function translateTransitionPlans(
|
||||
plans: readonly StateTransitionRenderPlan[],
|
||||
dx: number,
|
||||
dy: number,
|
||||
): StateTransitionRenderPlan[] {
|
||||
return plans.map((plan) => ({
|
||||
...plan,
|
||||
cells: plan.cells.map((cell) => ({ ...cell, y: cell.y + dy })),
|
||||
path: plan.path.map(([x, y]) => [x, y + dy]),
|
||||
label: plan.label ? { ...plan.label, y: plan.label.y + dy } : undefined,
|
||||
cells: plan.cells.map((cell) => ({ ...cell, x: cell.x + dx, y: cell.y + dy })),
|
||||
path: plan.path.map(([x, y]) => [x + dx, y + dy]),
|
||||
label: plan.label ? { ...plan.label, x: plan.label.x + dx, y: plan.label.y + dy } : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -62,22 +67,24 @@ function makeGrid(width: number, height: number): StateGrid {
|
||||
})
|
||||
}
|
||||
|
||||
function setCell(grid: StateGrid, x: number, y: number, char: string, style?: StateCellStyle): void {
|
||||
grid.setCell(x, y, char, style)
|
||||
}
|
||||
|
||||
function setText(grid: StateGrid, x: number, y: number, text: string, style?: StateCellStyle): void {
|
||||
grid.setText(x, y, text, style)
|
||||
}
|
||||
|
||||
function setTransitionLabel(
|
||||
function setCell(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
lines: readonly string[],
|
||||
style: StateCellStyle,
|
||||
char: string,
|
||||
style?: StateCellStyle,
|
||||
): void {
|
||||
lines.forEach((line, index) => setText(grid, x, y + index, line, style))
|
||||
grid.setCell(x, y, char, style)
|
||||
}
|
||||
|
||||
function setText(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
style?: StateCellStyle,
|
||||
): void {
|
||||
grid.setText(x, y, text, style)
|
||||
}
|
||||
|
||||
function drawBox(
|
||||
@@ -101,7 +108,12 @@ function drawBox(
|
||||
})
|
||||
}
|
||||
|
||||
function drawStateFrame(grid: StateGrid, bounds: BoxBounds, chars: BorderCharacters, style: StateCellStyle): void {
|
||||
function drawStateFrame(
|
||||
grid: StateGrid,
|
||||
bounds: BoxBounds,
|
||||
chars: BorderCharacters,
|
||||
style: StateCellStyle,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
|
||||
}
|
||||
|
||||
@@ -116,56 +128,46 @@ function drawContainerFrame(
|
||||
if (label) setText(grid, bounds.left + 2, bounds.top, ` ${label} `, style)
|
||||
}
|
||||
|
||||
function drawHorizontalNoteConnector(grid: StateGrid, fromX: number, toX: number, y: number, char: string): void {
|
||||
const step = fromX <= toX ? 1 : -1
|
||||
for (let x = fromX; step === 1 ? x <= toX : x >= toX; x += step) {
|
||||
const distanceFromNote = Math.abs(toX - x)
|
||||
const style: StateCellStyle =
|
||||
distanceFromNote < 3 ? (`noteConnectorRamp${3 - distanceFromNote}` as NoteConnectorRampStyle) : "noteConnector"
|
||||
setCell(grid, x, y, char, style)
|
||||
}
|
||||
function noteConnectorGlyph(directions: ReadonlySet<DiagramDirection>): string {
|
||||
const chars = BorderChars.double
|
||||
const up = directions.has("up")
|
||||
const down = directions.has("down")
|
||||
const left = directions.has("left")
|
||||
const right = directions.has("right")
|
||||
if (up && down && left && right) return chars.cross
|
||||
if (up && down && right) return chars.leftT
|
||||
if (up && down && left) return chars.rightT
|
||||
if (left && right && down) return chars.topT
|
||||
if (left && right && up) return chars.bottomT
|
||||
if (up && right) return chars.bottomLeft
|
||||
if (up && left) return chars.bottomRight
|
||||
if (down && right) return chars.topLeft
|
||||
if (down && left) return chars.topRight
|
||||
if (up || down) return chars.vertical
|
||||
return chars.horizontal
|
||||
}
|
||||
|
||||
function drawNote(grid: StateGrid, bounds: StateNoteBounds, target: BoxBounds): void {
|
||||
const chars = BorderChars.double
|
||||
const connectorChars = BorderChars.double
|
||||
const noteX = bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width
|
||||
const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1
|
||||
const targetBottom = target.top + target.height - 1
|
||||
const noteBottom = bounds.top + bounds.height - 1
|
||||
const noteAbove = noteBottom < target.top
|
||||
const noteBelow = bounds.top > targetBottom
|
||||
let connectorY: number
|
||||
|
||||
if (noteAbove || noteBelow) {
|
||||
const targetY = noteAbove ? target.top - 1 : targetBottom + 1
|
||||
connectorY = bounds.centerY
|
||||
const verticalStep = targetY <= connectorY ? 1 : -1
|
||||
|
||||
for (let y = targetY; verticalStep === 1 ? y <= connectorY : y >= connectorY; y += verticalStep) {
|
||||
setCell(grid, targetX, y, connectorChars.vertical, "noteConnector")
|
||||
}
|
||||
|
||||
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
|
||||
const connectorTurnsRight = targetX <= noteX
|
||||
const corner = noteAbove
|
||||
? connectorTurnsRight
|
||||
? connectorChars.topLeft
|
||||
: connectorChars.topRight
|
||||
: connectorTurnsRight
|
||||
? connectorChars.bottomLeft
|
||||
: connectorChars.bottomRight
|
||||
setCell(grid, targetX, connectorY, corner, "noteConnector")
|
||||
} else {
|
||||
connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2))
|
||||
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
|
||||
const connector = stateDiagramNoteConnector(bounds, target)
|
||||
const points = orthogonalPathPoints(connector.points)
|
||||
for (const [index, point] of points.entries()) {
|
||||
const directions = new Set<DiagramDirection>()
|
||||
const previous = points[index - 1]
|
||||
const next = points[index + 1]
|
||||
if (previous) directions.add(directionBetween(point, previous)!)
|
||||
if (next) directions.add(directionBetween(point, next)!)
|
||||
const distanceFromNote = points.length - index - 1
|
||||
const style: StateCellStyle =
|
||||
distanceFromNote < 3 ? (`noteConnectorRamp${3 - distanceFromNote}` as NoteConnectorRampStyle) : "noteConnector"
|
||||
setCell(grid, point.x, point.y, noteConnectorGlyph(directions), style)
|
||||
}
|
||||
|
||||
drawContainerFrame(grid, bounds, "", chars, "noteBorder")
|
||||
setCell(
|
||||
grid,
|
||||
bounds.note.position === "right" ? bounds.left : bounds.left + bounds.width - 1,
|
||||
connectorY,
|
||||
connector.connectorY,
|
||||
bounds.note.position === "right" ? chars.rightT : chars.leftT,
|
||||
"noteBorder",
|
||||
)
|
||||
@@ -188,7 +190,9 @@ function drawTransitionRenderPlan(
|
||||
setCell(grid, cell.x, cell.y, char, departure.get(`${cell.x}:${cell.y}`) ?? "transition")
|
||||
}
|
||||
if (plan.label) {
|
||||
setTransitionLabel(grid, plan.label.x, plan.label.y, plan.label.lines, "label")
|
||||
plan.label.lines.forEach((line, index) =>
|
||||
setText(grid, plan.label!.x, plan.label!.y + index, line, "label"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,46 +215,55 @@ export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: State
|
||||
const borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE
|
||||
const arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE
|
||||
const minStateGap = normalizeStateMinStateGap(options.minStateGap)
|
||||
const { bounds, sizes, compositeBounds, noteBounds } = createStateDiagramLayout(diagram, {
|
||||
const searchBudget = createStateSearchBudget()
|
||||
const layout = createStateDiagramLayout(diagram, {
|
||||
minStateGap,
|
||||
searchBudget,
|
||||
})
|
||||
const { bounds, sizes, compositeBounds, noteBounds } = layout
|
||||
let allBounds = [...bounds.values(), ...noteBounds]
|
||||
let maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
let feedbackLaneY = maxY + 3
|
||||
let feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3
|
||||
const feedbackLaneY = maxY + 3
|
||||
const feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3
|
||||
expandCompositeBoundsForFeedback(diagram, bounds, compositeBounds, feedbackLaneY)
|
||||
let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
|
||||
const transitionTop = Math.min(
|
||||
0,
|
||||
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
|
||||
)
|
||||
if (transitionTop < 0) {
|
||||
const dy = -transitionTop
|
||||
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
|
||||
bound.top += dy
|
||||
bound.centerY += dy
|
||||
}
|
||||
feedbackLaneY += dy
|
||||
feedbackTopY += dy
|
||||
transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
|
||||
}
|
||||
let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, {
|
||||
feedbackTopY,
|
||||
noteBounds,
|
||||
searchBudget,
|
||||
})
|
||||
expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
|
||||
const connectorPoints = noteBounds.flatMap((bound) => bound.connector?.points ?? [])
|
||||
const contentLeft = Math.min(
|
||||
0,
|
||||
...[...bounds.values(), ...noteBounds].map((bound) => bound.left),
|
||||
...connectorPoints.map((point) => point.x),
|
||||
...transitionPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.x),
|
||||
...(plan.label ? [plan.label.x] : []),
|
||||
]),
|
||||
)
|
||||
const contentTop = Math.min(
|
||||
0,
|
||||
...[...bounds.values(), ...noteBounds].map((bound) => bound.top),
|
||||
...connectorPoints.map((point) => point.y),
|
||||
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
|
||||
)
|
||||
if (contentTop < 0) {
|
||||
const dy = -contentTop
|
||||
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
|
||||
bound.top += dy
|
||||
bound.centerY += dy
|
||||
}
|
||||
transitionPlans = translateTransitionPlans(transitionPlans, dy)
|
||||
if (contentLeft < 0 || contentTop < 0) {
|
||||
translateStateDiagramLayout(layout, -contentLeft, -contentTop)
|
||||
transitionPlans = translateTransitionPlans(transitionPlans, -contentLeft, -contentTop)
|
||||
}
|
||||
allBounds = [...bounds.values(), ...noteBounds]
|
||||
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
|
||||
maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
||||
const translatedConnectorPoints = noteBounds.flatMap((bound) => bound.connector?.points ?? [])
|
||||
const maxX = Math.max(
|
||||
0,
|
||||
...allBounds.map((bound) => bound.left + bound.width),
|
||||
...translatedConnectorPoints.map((point) => point.x + 1),
|
||||
)
|
||||
maxY = Math.max(
|
||||
0,
|
||||
...allBounds.map((bound) => bound.top + bound.height),
|
||||
...translatedConnectorPoints.map((point) => point.y + 1),
|
||||
)
|
||||
const transitionLabelSizes = diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label))
|
||||
const maxTransitionLabelWidth = Math.max(0, ...transitionLabelSizes.map((size) => size.width))
|
||||
const maxTransitionLabelLines = Math.max(0, ...transitionLabelSizes.map((size) => size.height))
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { spatialPathClaim } from "../core/spatial.js"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import type { StateDiagram } from "./types.js"
|
||||
import { createStateDiagramLayout } from "./layout.js"
|
||||
import { stateDiagramNoteConnector } from "./note.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { createStateTransitionRenderPlans } from "./routing.js"
|
||||
import { prepareVisibleStateDiagram } from "./visible-model.js"
|
||||
|
||||
describe("StateDiagramLayout", () => {
|
||||
test("lays out horizontal main-path states before branch states", () => {
|
||||
@@ -95,4 +101,95 @@ describe("StateDiagramLayout", () => {
|
||||
|
||||
expect(longGap).toBeGreaterThan(shortGap)
|
||||
})
|
||||
|
||||
test("reserves notes and connectors from final transition geometry", () => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> B: advance
|
||||
B --> C: continue
|
||||
note right of B: first note
|
||||
note right of B: second note
|
||||
note left of C: left note`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30, { noteBounds: layout.noteBounds })
|
||||
const occupiedByNote = layout.noteBounds.map((noteBound) => {
|
||||
const target = layout.bounds.get(noteBound.note.target)!
|
||||
const connector = spatialPathClaim(
|
||||
`connector:${noteBound.id}`,
|
||||
`connector:${noteBound.id}`,
|
||||
"boundary",
|
||||
stateDiagramNoteConnector(noteBound, target).points,
|
||||
)
|
||||
return new Set([
|
||||
...Array.from({ length: noteBound.height }, (_, dy) =>
|
||||
Array.from({ length: noteBound.width }, (_, dx) => `${noteBound.left + dx}:${noteBound.top + dy}`),
|
||||
).flat(),
|
||||
...connector.spans.flatMap((span) =>
|
||||
Array.from({ length: span.toX - span.fromX + 1 }, (_, dx) => `${span.fromX + dx}:${span.y}`),
|
||||
),
|
||||
])
|
||||
})
|
||||
|
||||
for (const [index, occupied] of occupiedByNote.entries()) {
|
||||
for (const other of occupiedByNote.slice(index + 1)) {
|
||||
expect([...occupied].some((cell) => other.has(cell))).toBe(false)
|
||||
}
|
||||
}
|
||||
const noteCells = new Set(occupiedByNote.flatMap((occupied) => [...occupied]))
|
||||
for (const plan of plans) {
|
||||
expect(plan.path.some(([x, y]) => noteCells.has(`${x}:${y}`))).toBe(false)
|
||||
if (!plan.label) continue
|
||||
const width = Math.max(...plan.label.lines.map(diagramTextWidth))
|
||||
expect(
|
||||
plan.label.lines.some((_, dy) =>
|
||||
Array.from({ length: width }, (_, dx) => `${plan.label!.x + dx}:${plan.label!.y + dy}`).some((cell) =>
|
||||
noteCells.has(cell),
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("finalizes nested composite bounds after transition-aware note placement", () => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
state Outer {
|
||||
state Inner {
|
||||
A --> B: internal route
|
||||
note right of B: nested note
|
||||
}
|
||||
}
|
||||
Outer --> Done: leave composite`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const note = layout.noteBounds[0]!
|
||||
const inner = layout.compositeBounds.get("Inner")!
|
||||
const outer = layout.compositeBounds.get("Outer")!
|
||||
const done = layout.bounds.get("Done")!
|
||||
|
||||
for (const composite of [inner, outer]) {
|
||||
expect(note.left).toBeGreaterThan(composite.left)
|
||||
expect(note.top).toBeGreaterThan(composite.top)
|
||||
expect(note.left + note.width).toBeLessThan(composite.left + composite.width)
|
||||
expect(note.top + note.height).toBeLessThan(composite.top + composite.height)
|
||||
}
|
||||
expect(
|
||||
done.left < outer.left + outer.width &&
|
||||
done.left + done.width > outer.left &&
|
||||
done.top < outer.top + outer.height &&
|
||||
done.top + done.height > outer.top,
|
||||
).toBe(false)
|
||||
|
||||
const maxY = Math.max(...[...layout.bounds.values(), note].map((bound) => bound.top + bound.height))
|
||||
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, maxY + 3, { noteBounds: layout.noteBounds })
|
||||
const noteCells = new Set(
|
||||
Array.from({ length: note.height }, (_, dy) =>
|
||||
Array.from({ length: note.width }, (_, dx) => `${note.left + dx}:${note.top + dy}`),
|
||||
).flat(),
|
||||
)
|
||||
expect(plans.every((plan) => plan.path.every(([x, y]) => !noteCells.has(`${x}:${y}`)))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { translateDiagramBounds } from "../core/geometry.js"
|
||||
import { orthogonalPathPoints, translateDiagramBounds, type DiagramPoint } from "../core/geometry.js"
|
||||
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js"
|
||||
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
|
||||
import { stateDiagramNoteConnector, type StateDiagramNoteConnector } from "./note.js"
|
||||
import {
|
||||
createStateTransitionRenderPlans,
|
||||
hasReverseTransition,
|
||||
isStateHorizontalFeedback,
|
||||
measureStateTransitionLabel,
|
||||
type StateTransitionRenderPlan,
|
||||
} from "./routing.js"
|
||||
import {
|
||||
createStateSearchBudget,
|
||||
createStateSearchSpace,
|
||||
findStateManhattanPath,
|
||||
type StateSearchBudget,
|
||||
type StateSearchSpace,
|
||||
} from "./search.js"
|
||||
import type {
|
||||
StateDiagram,
|
||||
StateDiagramCompositeState,
|
||||
@@ -14,6 +24,8 @@ import type {
|
||||
StateDiagramTransition,
|
||||
} from "./types.js"
|
||||
|
||||
const MAX_STRICT_NOTE_PLACEMENTS = 24
|
||||
|
||||
export interface StateDiagramBoxBounds {
|
||||
id: string
|
||||
left: number
|
||||
@@ -34,10 +46,12 @@ export interface StateDiagramLayout {
|
||||
export interface StateDiagramNoteBounds extends StateDiagramBoxBounds {
|
||||
note: StateDiagramNote
|
||||
lines: string[]
|
||||
connector?: StateDiagramNoteConnector
|
||||
}
|
||||
|
||||
export interface StateDiagramLayoutOptions {
|
||||
minStateGap: number
|
||||
searchBudget?: StateSearchBudget
|
||||
}
|
||||
|
||||
function computeRanks(diagram: StateDiagram): Map<string, number> {
|
||||
@@ -150,12 +164,25 @@ function emptyLayout(
|
||||
return { bounds, sizes, compositeBounds: new Map(), noteBounds: [] }
|
||||
}
|
||||
|
||||
function isNoteBound(bound: StateDiagramBoxBounds): bound is StateDiagramNoteBounds {
|
||||
return "note" in bound
|
||||
}
|
||||
|
||||
function shiftBounds(bounds: Iterable<StateDiagramBoxBounds>, dx: number, dy: number): void {
|
||||
for (const bound of bounds) {
|
||||
translateDiagramBounds(bound, dx, dy)
|
||||
if (!isNoteBound(bound) || !bound.connector) continue
|
||||
bound.connector = {
|
||||
connectorY: bound.connector.connectorY + dy,
|
||||
points: bound.connector.points.map((point) => ({ x: point.x + dx, y: point.y + dy })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function translateStateDiagramLayout(layout: StateDiagramLayout, dx: number, dy: number): void {
|
||||
shiftBounds(uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds), dx, dy)
|
||||
}
|
||||
|
||||
function uniqueBounds(...bounds: Iterable<StateDiagramBoxBounds>[]): StateDiagramBoxBounds[] {
|
||||
return [...new Set(bounds.flatMap((group) => [...group]))]
|
||||
}
|
||||
@@ -163,8 +190,9 @@ function uniqueBounds(...bounds: Iterable<StateDiagramBoxBounds>[]): StateDiagra
|
||||
function normalizeLayout(layout: StateDiagramLayout): void {
|
||||
const allBounds = uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds)
|
||||
if (allBounds.length === 0) return
|
||||
const minX = Math.min(0, ...allBounds.map((bound) => bound.left))
|
||||
const minY = Math.min(0, ...allBounds.map((bound) => bound.top))
|
||||
const connectorPoints = layout.noteBounds.flatMap((bound) => bound.connector?.points ?? [])
|
||||
const minX = Math.min(0, ...allBounds.map((bound) => bound.left), ...connectorPoints.map((point) => point.x))
|
||||
const minY = Math.min(0, ...allBounds.map((bound) => bound.top), ...connectorPoints.map((point) => point.y))
|
||||
if (minX === 0 && minY === 0) return
|
||||
shiftBounds(allBounds, -minX, -minY)
|
||||
}
|
||||
@@ -221,21 +249,6 @@ function addCompositeBounds(diagram: StateDiagram, layout: StateDiagramLayout):
|
||||
for (const composite of diagram.composites) addComposite(composite)
|
||||
}
|
||||
|
||||
function addNoteBounds(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
const compositeIds = new Set(diagram.composites.map((composite) => composite.id))
|
||||
const avoidBounds = [...layout.bounds.values()].filter((bound) => !compositeIds.has(bound.id))
|
||||
const noteBounds: StateDiagramNoteBounds[] = []
|
||||
|
||||
for (const [index, note] of diagram.notes.entries()) {
|
||||
const target = layout.bounds.get(note.target)
|
||||
if (!target) continue
|
||||
const size = noteSize(note)
|
||||
noteBounds.push(placeNote(note, index, target, size, avoidBounds, noteBounds))
|
||||
}
|
||||
|
||||
layout.noteBounds = noteBounds
|
||||
}
|
||||
|
||||
function intersects(
|
||||
left: number,
|
||||
top: number,
|
||||
@@ -272,43 +285,65 @@ function createNoteBound(
|
||||
}
|
||||
}
|
||||
|
||||
function placeNote(
|
||||
note: StateDiagramNote,
|
||||
index: number,
|
||||
function findNoteConnector(
|
||||
search: StateSearchSpace,
|
||||
bounds: StateDiagramNoteBounds,
|
||||
target: StateDiagramBoxBounds,
|
||||
size: { width: number; height: number; lines: string[] },
|
||||
avoidBounds: readonly StateDiagramBoxBounds[],
|
||||
existingNotes: readonly StateDiagramNoteBounds[],
|
||||
): StateDiagramNoteBounds {
|
||||
const gap = 4
|
||||
const baseLeft = note.position === "right" ? target.left + target.width + gap : target.left - size.width - gap
|
||||
const baseTop = target.centerY - Math.floor(size.height / 2)
|
||||
const candidateTops = [
|
||||
baseTop,
|
||||
target.top - size.height - 2,
|
||||
target.top + target.height + 2,
|
||||
baseTop - size.height - 2,
|
||||
baseTop + target.height + 2,
|
||||
budget: StateSearchBudget,
|
||||
): StateDiagramNoteConnector | undefined {
|
||||
const connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2))
|
||||
const end = {
|
||||
x: bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width,
|
||||
y: connectorY,
|
||||
}
|
||||
const goal = { x: end.x + (bounds.note.position === "right" ? -1 : 1), y: end.y }
|
||||
const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1
|
||||
const preferredStarts = [
|
||||
{ x: targetX, y: target.centerY },
|
||||
{ x: targetX, y: target.top - 1 },
|
||||
{ x: targetX, y: target.top + target.height },
|
||||
]
|
||||
const collides = (left: number, top: number) => {
|
||||
for (const bound of avoidBounds) {
|
||||
if (bound.id !== target.id && intersects(left, top, size.width, size.height, bound)) return true
|
||||
}
|
||||
for (const bound of existingNotes) {
|
||||
if (intersects(left, top, size.width, size.height, bound)) return true
|
||||
}
|
||||
return false
|
||||
const perimeterStarts = [
|
||||
...Array.from({ length: target.height }, (_, offset) => ({ x: target.left - 1, y: target.top + offset })),
|
||||
...Array.from({ length: target.height }, (_, offset) => ({
|
||||
x: target.left + target.width,
|
||||
y: target.top + offset,
|
||||
})),
|
||||
...Array.from({ length: target.width }, (_, offset) => ({ x: target.left + offset, y: target.top - 1 })),
|
||||
...Array.from({ length: target.width }, (_, offset) => ({
|
||||
x: target.left + offset,
|
||||
y: target.top + target.height,
|
||||
})),
|
||||
]
|
||||
const starts = [...preferredStarts, ...perimeterStarts]
|
||||
const isFree = (point: DiagramPoint): boolean =>
|
||||
point.x >= 0 &&
|
||||
!search.blocked.has(`${point.x}:${point.y}`) &&
|
||||
!(point.x >= bounds.left && point.x < bounds.left + bounds.width && point.y >= bounds.top && point.y < bounds.top + bounds.height)
|
||||
if (!isFree(end) || !isFree(goal)) return undefined
|
||||
|
||||
for (const start of starts.filter(isFree)) {
|
||||
const directPaths = [
|
||||
[start, { x: start.x, y: goal.y }, goal, end],
|
||||
[start, { x: goal.x, y: start.y }, goal, end],
|
||||
]
|
||||
const direct = directPaths.find((points) => orthogonalPathPoints(points).every(isFree))
|
||||
if (direct) return { connectorY, points: direct }
|
||||
}
|
||||
|
||||
for (const top of candidateTops) {
|
||||
if (!collides(baseLeft, top)) return createNoteBound(note, index, baseLeft, top, size)
|
||||
}
|
||||
|
||||
const shiftedLeft =
|
||||
note.position === "right"
|
||||
? Math.max(...avoidBounds.map((bound) => bound.left + bound.width), target.left + target.width) + gap
|
||||
: Math.min(...avoidBounds.map((bound) => bound.left), target.left) - size.width - gap
|
||||
return createNoteBound(note, index, shiftedLeft, baseTop + target.height + 1, size)
|
||||
const margin = 8
|
||||
const minY = Math.min(target.top, bounds.top, search.minY) - margin
|
||||
const maxX = Math.max(target.left + target.width, bounds.left + bounds.width, search.maxX) + margin
|
||||
const maxY = Math.max(target.top + target.height, bounds.top + bounds.height, search.maxY) + margin
|
||||
const path = findStateManhattanPath(
|
||||
starts,
|
||||
goal,
|
||||
search,
|
||||
{ minX: 0, minY, maxX, maxY },
|
||||
budget,
|
||||
isFree,
|
||||
)
|
||||
return path ? { connectorY, points: [...path, end] } : undefined
|
||||
}
|
||||
|
||||
function belongsToComposite(
|
||||
@@ -392,16 +427,42 @@ function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: Sta
|
||||
if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
|
||||
}
|
||||
|
||||
for (const noteBound of layout.noteBounds) {
|
||||
const target = layout.bounds.get(noteBound.note.target)
|
||||
if (target && boundsToShift.includes(target)) boundsToShift.push(noteBound)
|
||||
}
|
||||
|
||||
shiftBounds(uniqueBounds(boundsToShift), dx, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeLayout(diagram: StateDiagram, layout: StateDiagramLayout): StateDiagramLayout {
|
||||
function finalizeLayout(
|
||||
diagram: StateDiagram,
|
||||
layout: StateDiagramLayout,
|
||||
budget: StateSearchBudget,
|
||||
): StateDiagramLayout {
|
||||
if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout
|
||||
addCompositeBounds(diagram, layout)
|
||||
normalizeLayout(layout)
|
||||
addNoteBounds(diagram, layout)
|
||||
if (diagram.notes.length > 0) {
|
||||
const allBounds = [...layout.bounds.values()]
|
||||
placeStateDiagramNotesAroundTransitions(
|
||||
diagram,
|
||||
layout,
|
||||
createStateTransitionRenderPlans(
|
||||
diagram,
|
||||
layout.bounds,
|
||||
Math.max(0, ...allBounds.map((bound) => bound.top + bound.height)) + 3,
|
||||
{
|
||||
feedbackTopY: Math.min(0, ...allBounds.map((bound) => bound.top)) - 3,
|
||||
repairRoutes: false,
|
||||
searchBudget: budget,
|
||||
},
|
||||
),
|
||||
budget,
|
||||
)
|
||||
}
|
||||
expandCompositeBoundsForNotes(diagram, layout)
|
||||
separateExternalBoundsFromComposites(diagram, layout)
|
||||
normalizeLayout(layout)
|
||||
@@ -412,8 +473,9 @@ export function createStateDiagramLayout(
|
||||
diagram: StateDiagram,
|
||||
options: StateDiagramLayoutOptions,
|
||||
): StateDiagramLayout {
|
||||
const budget = options.searchBudget ?? createStateSearchBudget()
|
||||
if (diagram.direction === "LR" || diagram.direction === "RL") {
|
||||
return finalizeLayout(diagram, createHorizontalLayout(diagram, options))
|
||||
return finalizeLayout(diagram, createHorizontalLayout(diagram, options), budget)
|
||||
}
|
||||
|
||||
const ranks = computeRanks(diagram)
|
||||
@@ -466,7 +528,7 @@ export function createStateDiagramLayout(
|
||||
y += rowHeight + Math.max(4, labelRows + 3) + pseudoStateApproachClearance
|
||||
}
|
||||
|
||||
return finalizeLayout(diagram, emptyLayout(bounds, sizes))
|
||||
return finalizeLayout(diagram, emptyLayout(bounds, sizes), budget)
|
||||
}
|
||||
|
||||
function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayoutOptions): StateDiagramLayout {
|
||||
@@ -540,7 +602,13 @@ function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayo
|
||||
if (bounds.has(branchId)) continue
|
||||
const size = sizes.get(branchId)
|
||||
if (!size) continue
|
||||
const top = baselineY + (parallelLane ? 6 : 5)
|
||||
const top = availableStateTop(
|
||||
[...bounds.values()],
|
||||
left,
|
||||
baselineY + (parallelLane ? 6 : 5),
|
||||
size.width,
|
||||
size.height,
|
||||
)
|
||||
bounds.set(branchId, {
|
||||
id: branchId,
|
||||
left,
|
||||
@@ -618,6 +686,21 @@ function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayo
|
||||
return emptyLayout(bounds, sizes)
|
||||
}
|
||||
|
||||
function availableStateTop(
|
||||
bounds: readonly StateDiagramBoxBounds[],
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): number {
|
||||
let available = top
|
||||
while (true) {
|
||||
const collision = bounds.find((bound) => intersects(left, available, width, height, bound, 0))
|
||||
if (!collision) return available
|
||||
available = collision.top + collision.height + 3
|
||||
}
|
||||
}
|
||||
|
||||
export function expandCompositeBoundsForFeedback(
|
||||
diagram: StateDiagram,
|
||||
bounds: Map<string, StateDiagramBoxBounds>,
|
||||
@@ -648,6 +731,234 @@ export function expandCompositeBoundsForFeedback(
|
||||
}
|
||||
}
|
||||
|
||||
function placeStateDiagramNotesAroundTransitions(
|
||||
diagram: StateDiagram,
|
||||
layout: StateDiagramLayout,
|
||||
transitionPlans: readonly StateTransitionRenderPlan[],
|
||||
budget: StateSearchBudget,
|
||||
): void {
|
||||
if (diagram.notes.length === 0) return
|
||||
const compositeIds = new Set(diagram.composites.map((composite) => composite.id))
|
||||
let noteSpace = SpatialIndex.empty().add(
|
||||
...[...layout.bounds.values()].flatMap((bound) =>
|
||||
compositeIds.has(bound.id) ? [] : [spatialRectClaim(`state:${bound.id}`, `state:${bound.id}`, "body", bound)],
|
||||
),
|
||||
)
|
||||
let reserved = noteSpace.add(
|
||||
...transitionPlans.flatMap((plan, planIndex) => [
|
||||
spatialPathClaim(
|
||||
`transition-terminal:${planIndex}:source`,
|
||||
`transition-terminal:${planIndex}:source`,
|
||||
"route",
|
||||
plan.path.slice(0, 2).map(([x, y]) => ({ x, y })),
|
||||
),
|
||||
spatialPathClaim(
|
||||
`transition-terminal:${planIndex}:target`,
|
||||
`transition-terminal:${planIndex}:target`,
|
||||
"route",
|
||||
plan.path.slice(-2).map(([x, y]) => ({ x, y })),
|
||||
),
|
||||
]),
|
||||
)
|
||||
let space = reserved.add(
|
||||
...transitionPlans.flatMap((plan, planIndex) => [
|
||||
spatialPathClaim(
|
||||
`transition:${planIndex}`,
|
||||
`transition:${planIndex}`,
|
||||
"route",
|
||||
plan.path.map(([x, y]) => ({ x, y })),
|
||||
),
|
||||
...(plan.label
|
||||
? [
|
||||
spatialRectClaim(`transition-label:${planIndex}`, `transition-label:${planIndex}`, "label", {
|
||||
left: plan.label.x,
|
||||
top: plan.label.y,
|
||||
width: Math.max(...plan.label.lines.map(diagramTextWidth)),
|
||||
height: plan.label.lines.length,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
)
|
||||
const noteBounds: StateDiagramNoteBounds[] = []
|
||||
|
||||
for (const [index, note] of diagram.notes.entries()) {
|
||||
const target = layout.bounds.get(note.target)
|
||||
if (!target) continue
|
||||
const size = noteSize(note)
|
||||
const gap = 4
|
||||
const baseLeft = note.position === "right" ? target.left + target.width + gap : target.left - size.width - gap
|
||||
const baseTop = target.centerY - Math.floor(size.height / 2)
|
||||
const candidates = [
|
||||
baseTop,
|
||||
target.top - size.height - 2,
|
||||
target.top + target.height + 2,
|
||||
...Array.from({ length: 12 }, (_, distance) => [baseTop - distance - 1, baseTop + distance + 1]).flat(),
|
||||
]
|
||||
const candidateBounds = Array.from({ length: 5 }, (_, outward) =>
|
||||
candidates.map((top) =>
|
||||
createNoteBound(
|
||||
note,
|
||||
index,
|
||||
baseLeft + (note.position === "right" ? 1 : -1) * outward * (size.width + 2),
|
||||
top,
|
||||
size,
|
||||
),
|
||||
),
|
||||
)
|
||||
.flat()
|
||||
const findPlacement = (candidateSpace: SpatialIndex, limit: number) => {
|
||||
const connectorSearch = createStateSearchSpace(candidateSpace, (role) => (role === "label" ? 1 : 0))
|
||||
for (const bound of candidateBounds.slice(0, limit)) {
|
||||
if (bound.left < 0) continue
|
||||
const owner = `note:${index}`
|
||||
if (!candidateSpace.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }))
|
||||
continue
|
||||
const connector = findNoteConnector(connectorSearch, bound, target, budget)
|
||||
if (connector) return { bound: { ...bound, connector }, connector }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const placement =
|
||||
findPlacement(space, MAX_STRICT_NOTE_PLACEMENTS) ??
|
||||
findPlacement(reserved, candidateBounds.length) ??
|
||||
outsideNotePlacement(noteSpace, note, index, target, size)
|
||||
const owner = `note:${index}`
|
||||
const claims = [
|
||||
spatialRectClaim(`${owner}:body`, owner, "body", placement.bound),
|
||||
spatialPathClaim(`${owner}:connector`, owner, "boundary", placement.connector.points),
|
||||
] as const
|
||||
noteBounds.push(placement.bound)
|
||||
noteSpace = noteSpace.add(...claims)
|
||||
reserved = reserved.add(...claims)
|
||||
space = space.add(...claims)
|
||||
}
|
||||
|
||||
layout.noteBounds.splice(0, layout.noteBounds.length, ...noteBounds)
|
||||
}
|
||||
|
||||
function outsideNotePlacement(
|
||||
space: SpatialIndex,
|
||||
note: StateDiagramNote,
|
||||
index: number,
|
||||
target: StateDiagramBoxBounds,
|
||||
size: { width: number; height: number; lines: string[] },
|
||||
): { bound: StateDiagramNoteBounds; connector: StateDiagramNoteConnector } {
|
||||
const search = createStateSearchSpace(space)
|
||||
const top = search.maxY + 4
|
||||
const owner = `note:${index}:outside`
|
||||
|
||||
for (const position of [note.position, note.position === "left" ? "right" : "left"] as const) {
|
||||
const aligned = createNoteBound(
|
||||
{ ...note, position },
|
||||
index,
|
||||
position === "left" ? search.minX - size.width - 4 : search.maxX + 4,
|
||||
target.centerY - Math.floor(size.height / 2),
|
||||
size,
|
||||
)
|
||||
const alignedNoteX = position === "left" ? aligned.left + aligned.width : aligned.left - 1
|
||||
const alignedConnectorY = Math.max(
|
||||
aligned.top + 1,
|
||||
Math.min(target.centerY, aligned.top + aligned.height - 2),
|
||||
)
|
||||
const alignedTargetX = position === "left" ? target.left - 1 : target.left + target.width
|
||||
const alignedConnector = {
|
||||
connectorY: alignedConnectorY,
|
||||
points: [
|
||||
{ x: alignedTargetX, y: target.centerY },
|
||||
{ x: alignedNoteX, y: alignedConnectorY },
|
||||
],
|
||||
}
|
||||
if (
|
||||
space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", aligned), { clearance: 1 }) &&
|
||||
space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", alignedConnector.points))
|
||||
)
|
||||
return { bound: { ...aligned, connector: alignedConnector }, connector: alignedConnector }
|
||||
|
||||
const railX = position === "left" ? search.minX - 2 : search.maxX + 2
|
||||
const bound = createNoteBound(
|
||||
{ ...note, position },
|
||||
index,
|
||||
position === "left" ? railX - size.width - 2 : railX + 3,
|
||||
top,
|
||||
size,
|
||||
)
|
||||
const noteX = position === "left" ? bound.left + bound.width : bound.left - 1
|
||||
const connectorY = bound.top + 1
|
||||
const sideX = position === "left" ? target.left - 1 : target.left + target.width
|
||||
const escapes = [
|
||||
...[target.top, target.centerY, target.top + target.height - 1].map((y) => [
|
||||
{ x: sideX, y },
|
||||
{ x: railX, y },
|
||||
]),
|
||||
[
|
||||
{ x: target.centerX, y: target.top - 1 },
|
||||
{ x: railX, y: target.top - 1 },
|
||||
],
|
||||
[
|
||||
{ x: target.centerX, y: target.top + target.height },
|
||||
{ x: railX, y: target.top + target.height },
|
||||
],
|
||||
]
|
||||
|
||||
for (const escape of escapes) {
|
||||
const connector = {
|
||||
connectorY,
|
||||
points: [...escape, { x: railX, y: connectorY }, { x: noteX, y: connectorY }],
|
||||
}
|
||||
if (
|
||||
space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }) &&
|
||||
space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", connector.points))
|
||||
)
|
||||
return { bound: { ...bound, connector }, connector }
|
||||
}
|
||||
}
|
||||
|
||||
for (const vertical of ["below", "above"] as const) {
|
||||
const railY = vertical === "below" ? search.maxY + 2 : search.minY - 2
|
||||
const bound = createNoteBound(
|
||||
note,
|
||||
index,
|
||||
note.position === "left" ? search.minX - size.width - 4 : search.maxX + 4,
|
||||
vertical === "below" ? railY + 2 : railY - size.height - 2,
|
||||
size,
|
||||
)
|
||||
const noteX = note.position === "left" ? bound.left + bound.width : bound.left - 1
|
||||
const connectorY = bound.top + 1
|
||||
const targetY = vertical === "below" ? target.top + target.height : target.top - 1
|
||||
const escapes = [
|
||||
...[target.left, target.centerX, target.left + target.width - 1].map((x) => [
|
||||
{ x, y: targetY },
|
||||
{ x, y: railY },
|
||||
]),
|
||||
[
|
||||
{ x: target.left, y: targetY },
|
||||
{ x: target.left - 1, y: targetY },
|
||||
{ x: target.left - 1, y: railY },
|
||||
],
|
||||
[
|
||||
{ x: target.left + target.width - 1, y: targetY },
|
||||
{ x: target.left + target.width, y: targetY },
|
||||
{ x: target.left + target.width, y: railY },
|
||||
],
|
||||
]
|
||||
|
||||
for (const escape of escapes) {
|
||||
const connector = {
|
||||
connectorY,
|
||||
points: [...escape, { x: noteX, y: railY }, { x: noteX, y: connectorY }],
|
||||
}
|
||||
if (
|
||||
space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }) &&
|
||||
space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", connector.points))
|
||||
)
|
||||
return { bound: { ...bound, connector }, connector }
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`State ${note.target} has no exterior note corridor`)
|
||||
}
|
||||
|
||||
export function expandCompositeBoundsForInternalTransitions(
|
||||
diagram: StateDiagram,
|
||||
compositeBounds: Map<string, StateDiagramBoxBounds>,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { DiagramPoint } from "../core/geometry.js"
|
||||
import type { StateDiagramNote } from "./types.js"
|
||||
|
||||
interface NoteBounds {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
centerY: number
|
||||
note: StateDiagramNote
|
||||
connector?: StateDiagramNoteConnector
|
||||
}
|
||||
|
||||
interface TargetBounds {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
centerY: number
|
||||
}
|
||||
|
||||
export interface StateDiagramNoteConnector {
|
||||
points: readonly DiagramPoint[]
|
||||
connectorY: number
|
||||
}
|
||||
|
||||
export function stateDiagramNoteConnector(
|
||||
bounds: NoteBounds,
|
||||
target: TargetBounds,
|
||||
): StateDiagramNoteConnector {
|
||||
if (bounds.connector) return bounds.connector
|
||||
const noteX = bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width
|
||||
const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1
|
||||
const targetBottom = target.top + target.height - 1
|
||||
const noteBottom = bounds.top + bounds.height - 1
|
||||
const noteAbove = noteBottom < target.top
|
||||
const noteBelow = bounds.top > targetBottom
|
||||
|
||||
if (noteAbove || noteBelow) {
|
||||
const connectorY = bounds.centerY
|
||||
return {
|
||||
connectorY,
|
||||
points: [
|
||||
{ x: targetX, y: noteAbove ? target.top - 1 : targetBottom + 1 },
|
||||
{ x: targetX, y: connectorY },
|
||||
{ x: noteX, y: connectorY },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2))
|
||||
return {
|
||||
connectorY,
|
||||
points: [
|
||||
{ x: targetX, y: connectorY },
|
||||
{ x: noteX, y: connectorY },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,28 @@ describe("createStateTransitionRoutePlans", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("uses a bottom lane for same-rank parallel transitions in vertical diagrams", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "TB",
|
||||
states: ["A", "B"].map((id) => ({ id, label: id, kind: "state" })),
|
||||
transitions: [
|
||||
{ from: "A", to: "B", label: "first" },
|
||||
{ from: "A", to: "B", label: "second" },
|
||||
],
|
||||
composites: [],
|
||||
notes: [],
|
||||
}
|
||||
const placements = new Map([
|
||||
["A", bounds("A", 4, 4)],
|
||||
["B", bounds("B", 18, 4)],
|
||||
])
|
||||
|
||||
expect(createStateTransitionRoutePlans(diagram, placements, 12).map((plan) => plan.kind)).toEqual([
|
||||
"horizontal-forward",
|
||||
"bottom-parallel",
|
||||
])
|
||||
})
|
||||
|
||||
test("routes interleaving independent feedback transitions on opposite sides", () => {
|
||||
const diagram: StateVisibleDiagram = {
|
||||
direction: "LR",
|
||||
@@ -244,6 +266,67 @@ describe("createStateTransitionRenderPlans", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps vertical feedback routes out of compact sibling state bounds", () => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
[*] --> Root
|
||||
Root --> A
|
||||
Root --> B
|
||||
A --> Merge
|
||||
B --> Merge
|
||||
Merge --> A: retry`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 12 })
|
||||
const plan = createStateTransitionRenderPlans(diagram, layout.bounds, 30).find(
|
||||
(plan) => plan.route.transition.label === "retry",
|
||||
)!
|
||||
const sibling = layout.bounds.get("B")!
|
||||
|
||||
expect(
|
||||
plan.path.some(
|
||||
([x, y]) =>
|
||||
x >= sibling.left &&
|
||||
x < sibling.left + sibling.width &&
|
||||
y >= sibling.top &&
|
||||
y < sibling.top + sibling.height,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps every dense vertical fan route out of unrelated state bounds", () => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
state "Alpha" as A
|
||||
state "Beta" as B
|
||||
state "Gamma store" as C
|
||||
state "Delta notifier" as D
|
||||
A --> B
|
||||
A --> C
|
||||
A --> D
|
||||
B --> A: back
|
||||
B --> D: across`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
|
||||
|
||||
for (const plan of plans) {
|
||||
const unrelated = diagram.states
|
||||
.filter((state) => state.id !== plan.route.transition.from && state.id !== plan.route.transition.to)
|
||||
.map((state) => layout.bounds.get(state.id)!)
|
||||
expect(
|
||||
plan.path.some(([x, y]) =>
|
||||
unrelated.some(
|
||||
(bound) =>
|
||||
x >= bound.left && x < bound.left + bound.width && y >= bound.top && y < bound.top + bound.height,
|
||||
),
|
||||
),
|
||||
`${plan.route.transition.from} -> ${plan.route.transition.to}`,
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps routes to offset end markers continuous", () => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { BorderChars } from "@opentui/core"
|
||||
import { diagramLineGlyph } from "../core/drawing.js"
|
||||
import type { DiagramDirection } from "../core/geometry.js"
|
||||
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js"
|
||||
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
||||
import type { StateDiagramBoxBounds as BoxBounds } from "./layout.js"
|
||||
import type { StateDiagramBoxBounds as BoxBounds, StateDiagramNoteBounds } from "./layout.js"
|
||||
import { stateDiagramNoteConnector } from "./note.js"
|
||||
import {
|
||||
createStateSearchBudget,
|
||||
createStateSearchSpace,
|
||||
findStateManhattanPath,
|
||||
type StateSearchBudget,
|
||||
type StateSearchSpace,
|
||||
} from "./search.js"
|
||||
import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
|
||||
import { isHiddenCompositeMarker, type StateVisibleDiagram, type StateVisibleTransition } from "./visible-model.js"
|
||||
|
||||
@@ -21,7 +30,11 @@ export type StateTransitionRoutePlan =
|
||||
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number; approachX: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "vertical-elbow"; hasReverse: boolean; offsetConnector: boolean })
|
||||
| (StateTransitionRoutePlanBase & { kind: "side-parallel"; railX: number })
|
||||
| (StateTransitionRoutePlanBase & {
|
||||
kind: "side-parallel"
|
||||
railX: number
|
||||
targetApproach?: "top" | "bottom"
|
||||
})
|
||||
| (StateTransitionRoutePlanBase & { kind: "vertical" })
|
||||
|
||||
export type StateTransitionPathPoint = readonly [number, number]
|
||||
@@ -47,6 +60,13 @@ export interface StateTransitionRenderPlan {
|
||||
label?: StateTransitionRenderLabel
|
||||
}
|
||||
|
||||
export interface StateTransitionRenderOptions {
|
||||
feedbackTopY?: number
|
||||
noteBounds?: readonly StateDiagramNoteBounds[]
|
||||
repairRoutes?: boolean
|
||||
searchBudget?: StateSearchBudget
|
||||
}
|
||||
|
||||
export interface StateTransitionJunctionPlan {
|
||||
state: StateDiagramState
|
||||
bounds: BoxBounds
|
||||
@@ -286,6 +306,50 @@ function bottomApproachX(
|
||||
return targetX
|
||||
}
|
||||
|
||||
function sideParallelTargetApproach(
|
||||
diagram: StateVisibleDiagram,
|
||||
transition: StateVisibleTransition,
|
||||
from: BoxBounds,
|
||||
to: BoxBounds,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
railX: number,
|
||||
): "top" | "bottom" | undefined {
|
||||
const space = SpatialIndex.empty().add(
|
||||
...diagram.states.flatMap((state) => {
|
||||
if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return []
|
||||
const bound = bounds.get(state.id)
|
||||
return bound ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)] : []
|
||||
}),
|
||||
)
|
||||
const targetSideX = to.left + to.width
|
||||
const claim = (points: readonly { x: number; y: number }[]) =>
|
||||
spatialPathClaim(`side-target:${transition.from}:${transition.to}`, "side-target", "route", points)
|
||||
|
||||
if (
|
||||
space.isFree(
|
||||
claim([
|
||||
{ x: railX, y: to.centerY },
|
||||
{ x: targetSideX, y: to.centerY },
|
||||
]),
|
||||
)
|
||||
)
|
||||
return undefined
|
||||
|
||||
const targetX = innerConnectorX(to, from.centerX)
|
||||
const preferred = from.centerY > to.centerY ? "top" : "bottom"
|
||||
return ([preferred, preferred === "top" ? "bottom" : "top"] as const).find((side) => {
|
||||
const railY = side === "top" ? to.top - 2 : to.top + to.height + 1
|
||||
const targetY = side === "top" ? to.top - 1 : to.top + to.height
|
||||
return space.isFree(
|
||||
claim([
|
||||
{ x: railX, y: railY },
|
||||
{ x: targetX, y: railY },
|
||||
{ x: targetX, y: targetY },
|
||||
]),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function createStateTransitionRoutePlans(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
@@ -327,6 +391,15 @@ export function createStateTransitionRoutePlans(
|
||||
const targetIsChoice = targetState?.kind === "choice"
|
||||
const targetIsHiddenMarker = isHiddenCompositeMarker(targetState)
|
||||
const base = { transition, from, to, targetIsChoice, targetIsHiddenMarker }
|
||||
const sideParallel = (): StateTransitionRoutePlan => {
|
||||
const railX = allocateSideRail(transition.label)
|
||||
return {
|
||||
...base,
|
||||
kind: "side-parallel",
|
||||
railX,
|
||||
targetApproach: sideParallelTargetApproach(diagram, transition, from, to, bounds, railX),
|
||||
}
|
||||
}
|
||||
if (transition.from === transition.to) return [{ ...base, kind: "self" }]
|
||||
const endpointKey = `${transition.from}\u0000${transition.to}`
|
||||
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
|
||||
@@ -354,7 +427,7 @@ export function createStateTransitionRoutePlans(
|
||||
]
|
||||
}
|
||||
if (parallelIndex > 0) {
|
||||
if ((diagram.direction === "LR" || diagram.direction === "RL") && from.centerY === to.centerY) {
|
||||
if (from.centerY === to.centerY) {
|
||||
const railY = allocateBottomRail()
|
||||
return [
|
||||
{
|
||||
@@ -365,19 +438,19 @@ export function createStateTransitionRoutePlans(
|
||||
},
|
||||
]
|
||||
}
|
||||
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
|
||||
return [sideParallel()]
|
||||
}
|
||||
if (diagram.direction !== "LR" && diagram.direction !== "RL") {
|
||||
const fromParent = statesById.get(transition.from)?.parentId
|
||||
const toParent = statesById.get(transition.to)?.parentId
|
||||
if (fromParent && toParent && fromParent !== toParent) {
|
||||
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
|
||||
return [sideParallel()]
|
||||
}
|
||||
if (verticalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
|
||||
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
|
||||
return [sideParallel()]
|
||||
}
|
||||
if (from.centerY > to.centerY) {
|
||||
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
|
||||
return [sideParallel()]
|
||||
}
|
||||
if (from.centerY === to.centerY) {
|
||||
if (hasReverseTransition(diagram, transition) && from.centerX > to.centerX) {
|
||||
@@ -394,7 +467,7 @@ export function createStateTransitionRoutePlans(
|
||||
return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
|
||||
}
|
||||
if (!hasVerticalCorridor(from, to)) {
|
||||
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
|
||||
return [sideParallel()]
|
||||
}
|
||||
if (from.centerX !== to.centerX) {
|
||||
return [{ ...base, kind: "vertical-elbow", hasReverse: false, offsetConnector: false }]
|
||||
@@ -404,7 +477,7 @@ export function createStateTransitionRoutePlans(
|
||||
|
||||
if (from.centerY !== to.centerY) {
|
||||
if (!hasVerticalCorridor(from, to)) {
|
||||
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
|
||||
return [sideParallel()]
|
||||
}
|
||||
if (from.centerY > to.centerY && feedback)
|
||||
return [
|
||||
@@ -540,7 +613,20 @@ function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
|
||||
|
||||
function addSelfTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from: bounds, transition } = builder.route
|
||||
if (bounds.width <= 1 || bounds.height <= 1) return
|
||||
if (bounds.width <= 1 || bounds.height <= 1) {
|
||||
const railX = bounds.left + 4
|
||||
const railY = bounds.top + 2
|
||||
addHorizontalLine(builder, bounds.left + 1, railX - 1, bounds.top, 1)
|
||||
addCell(builder, { x: railX, y: bounds.top, char: "╮" })
|
||||
addCell(builder, { x: railX, y: bounds.top + 1, char: "│" })
|
||||
addCell(builder, { x: railX, y: railY, char: "╯" })
|
||||
addHorizontalLine(builder, railX - 1, bounds.left + 1, railY, -1)
|
||||
addCell(builder, { x: bounds.left, y: railY, char: "╰" })
|
||||
addCell(builder, { x: bounds.left, y: bounds.top + 1, arrowDirection: "up" })
|
||||
addPathPoint(builder, bounds.left, bounds.top)
|
||||
if (transition.label) addLabel(builder, railX + 2, bounds.top + 1, transition.label)
|
||||
return
|
||||
}
|
||||
const sourceX = bounds.left + Math.max(2, Math.floor(bounds.width / 3))
|
||||
const bottomY = bounds.top + bounds.height - 1
|
||||
const railY = bottomY + 2
|
||||
@@ -646,20 +732,36 @@ function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
|
||||
}
|
||||
|
||||
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX } = builder.route as Extract<
|
||||
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "side-parallel" }
|
||||
>
|
||||
const startX = from.left + from.width
|
||||
const endX = to.left + to.width
|
||||
const startY = from.centerY
|
||||
const endY = to.centerY
|
||||
const endY = targetApproach === "top" ? to.top - 2 : targetApproach === "bottom" ? to.top + to.height + 1 : to.centerY
|
||||
const verticalStep: 1 | -1 = startY <= endY ? 1 : -1
|
||||
addRightDeparture(builder, from)
|
||||
addHorizontalLine(builder, startX, railX - 1, startY, 1)
|
||||
addCell(builder, { x: railX, y: startY, char: verticalStep === 1 ? "╮" : "╯" })
|
||||
for (let y = startY + verticalStep; y !== endY; y += verticalStep) addCell(builder, { x: railX, y, char: "│" })
|
||||
addCell(builder, { x: railX, y: endY, char: verticalStep === 1 ? "╯" : "╮" })
|
||||
if (targetApproach) {
|
||||
const targetX = innerConnectorX(to, from.centerX)
|
||||
for (let x = railX - 1; x > targetX; x--) addCell(builder, { x, y: endY, char: "─" })
|
||||
addCell(builder, { x: targetX, y: endY, char: targetApproach === "top" ? "╭" : "╰" })
|
||||
addCell(builder, {
|
||||
x: targetX,
|
||||
y: targetApproach === "top" ? endY + 1 : endY - 1,
|
||||
arrowDirection: targetApproach === "top" ? "down" : "up",
|
||||
})
|
||||
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
|
||||
if (transition.label) {
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
addLabel(builder, railX + 2, Math.max(0, Math.floor((startY + to.centerY - metrics.height + 1) / 2)), transition.label)
|
||||
}
|
||||
return
|
||||
}
|
||||
const endX = to.left + to.width
|
||||
for (let x = railX - 1; x > endX; x--) addCell(builder, { x, y: endY, char: "─" })
|
||||
addCell(
|
||||
builder,
|
||||
@@ -797,10 +899,187 @@ function createStateTransitionRenderPlan(route: StateTransitionRoutePlan): State
|
||||
return builder
|
||||
}
|
||||
|
||||
function pointIsInsideBounds(point: StateTransitionPathPoint, bounds: BoxBounds): boolean {
|
||||
return (
|
||||
point[0] >= bounds.left &&
|
||||
point[0] < bounds.left + bounds.width &&
|
||||
point[1] >= bounds.top &&
|
||||
point[1] < bounds.top + bounds.height
|
||||
)
|
||||
}
|
||||
|
||||
function routeIntersectsUnrelatedState(
|
||||
plan: StateTransitionRenderPlan,
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
noteBounds: readonly StateDiagramNoteBounds[],
|
||||
): boolean {
|
||||
if (
|
||||
diagram.states.some((state) => {
|
||||
if (
|
||||
state.id === plan.route.transition.from ||
|
||||
state.id === plan.route.transition.to ||
|
||||
isHiddenCompositeMarker(state)
|
||||
)
|
||||
return false
|
||||
const bound = bounds.get(state.id)
|
||||
return Boolean(bound && plan.path.some((point) => pointIsInsideBounds(point, bound)))
|
||||
})
|
||||
)
|
||||
return true
|
||||
|
||||
return noteBounds.some((noteBound) => {
|
||||
if (plan.path.some((point) => pointIsInsideBounds(point, noteBound))) return true
|
||||
const target = bounds.get(noteBound.note.target)
|
||||
if (!target) return false
|
||||
const connector = spatialPathClaim(
|
||||
`note-connector:${noteBound.id}`,
|
||||
`note-connector:${noteBound.id}`,
|
||||
"boundary",
|
||||
stateDiagramNoteConnector(noteBound, target).points,
|
||||
)
|
||||
return plan.path.some(([x, y]) =>
|
||||
connector.spans.some((span) => span.y === y && x >= span.fromX && x <= span.toX),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function findBodySafePath(
|
||||
start: StateTransitionPathPoint,
|
||||
end: StateTransitionPathPoint,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
plan: StateTransitionRenderPlan,
|
||||
search: StateSearchSpace,
|
||||
budget: StateSearchBudget,
|
||||
): StateTransitionPathPoint[] | undefined {
|
||||
const margin = Math.max(8, bounds.size * 2)
|
||||
const path = findStateManhattanPath(
|
||||
[{ x: start[0], y: start[1] }],
|
||||
{ x: end[0], y: end[1] },
|
||||
search,
|
||||
{
|
||||
minX: search.minX - margin,
|
||||
minY: Math.min(search.minY, ...plan.path.map((point) => point[1])) - margin,
|
||||
maxX: Math.max(search.maxX, ...plan.path.map((point) => point[0])) + margin,
|
||||
maxY: Math.max(search.maxY, ...plan.path.map((point) => point[1])) + margin,
|
||||
},
|
||||
budget,
|
||||
)
|
||||
return path?.map((point) => [point.x, point.y] as const)
|
||||
}
|
||||
|
||||
function bodySafeTransitionPlan(
|
||||
plan: StateTransitionRenderPlan,
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
noteBounds: readonly StateDiagramNoteBounds[],
|
||||
search: StateSearchSpace,
|
||||
budget: StateSearchBudget,
|
||||
): StateTransitionRenderPlan {
|
||||
if (!routeIntersectsUnrelatedState(plan, diagram, bounds, noteBounds)) return plan
|
||||
const sourceOutsideIndex = plan.path.findIndex((point) => !pointIsInsideBounds(point, plan.route.from))
|
||||
const targetOutsideIndex = plan.path.findLastIndex((point) => !pointIsInsideBounds(point, plan.route.to))
|
||||
if (sourceOutsideIndex < 0 || targetOutsideIndex < sourceOutsideIndex) return plan
|
||||
const safePath = findBodySafePath(plan.path[sourceOutsideIndex]!, plan.path[targetOutsideIndex]!, bounds, plan, search, budget)
|
||||
if (!safePath) return alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
|
||||
|
||||
const prefix = plan.path.slice(0, sourceOutsideIndex)
|
||||
const suffix = plan.path.slice(targetOutsideIndex + 1)
|
||||
return renderBodySafeTransitionPlan(plan, safePath, prefix, suffix)
|
||||
}
|
||||
|
||||
function alternateBodySafeTransitionPlan(
|
||||
plan: StateTransitionRenderPlan,
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
noteBounds: readonly StateDiagramNoteBounds[],
|
||||
search: StateSearchSpace,
|
||||
budget: StateSearchBudget,
|
||||
): StateTransitionRenderPlan {
|
||||
for (const source of stateRoutePorts(plan.route.from)) {
|
||||
for (const target of stateRoutePorts(plan.route.to)) {
|
||||
const safePath = findBodySafePath(source.outside, target.outside, bounds, plan, search, budget)
|
||||
if (!safePath) continue
|
||||
const prefix = plan.route.from.width > 1 && plan.route.from.height > 1 ? [source.border] : []
|
||||
const suffix =
|
||||
plan.route.targetIsChoice || plan.route.targetIsHiddenMarker
|
||||
? ([[plan.route.to.left, plan.route.to.top]] as const)
|
||||
: []
|
||||
const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix, source.char)
|
||||
if (!routeIntersectsUnrelatedState(repaired, diagram, bounds, noteBounds)) return repaired
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
function stateRoutePorts(bounds: BoxBounds): Array<{
|
||||
outside: StateTransitionPathPoint
|
||||
border: StateTransitionPathPoint
|
||||
char: string
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
outside: [bounds.centerX, bounds.top - 1] as const,
|
||||
border: [bounds.centerX, bounds.top] as const,
|
||||
char: BorderChars.rounded.bottomT,
|
||||
},
|
||||
{
|
||||
outside: [bounds.centerX, bounds.top + bounds.height] as const,
|
||||
border: [bounds.centerX, bounds.top + bounds.height - 1] as const,
|
||||
char: BorderChars.rounded.topT,
|
||||
},
|
||||
{
|
||||
outside: [bounds.left - 1, bounds.centerY] as const,
|
||||
border: [bounds.left, bounds.centerY] as const,
|
||||
char: BorderChars.rounded.rightT,
|
||||
},
|
||||
{
|
||||
outside: [bounds.left + bounds.width, bounds.centerY] as const,
|
||||
border: [bounds.left + bounds.width - 1, bounds.centerY] as const,
|
||||
char: BorderChars.rounded.leftT,
|
||||
},
|
||||
].filter((port) => port.outside[0] >= 0)
|
||||
}
|
||||
|
||||
function renderBodySafeTransitionPlan(
|
||||
plan: StateTransitionRenderPlan,
|
||||
safePath: readonly StateTransitionPathPoint[],
|
||||
prefix: readonly StateTransitionPathPoint[],
|
||||
suffix: readonly StateTransitionPathPoint[],
|
||||
sourceChar?: string,
|
||||
): StateTransitionRenderPlan {
|
||||
const prefixKeys = new Set(prefix.map(([x, y]) => `${x}:${y}`))
|
||||
const cells: StateTransitionRenderCell[] = sourceChar
|
||||
? prefix.map(([x, y]) => ({ x, y, char: sourceChar }))
|
||||
: plan.cells.filter((cell) => prefixKeys.has(`${cell.x}:${cell.y}`))
|
||||
const fullPath = [...prefix, ...safePath, ...suffix]
|
||||
const previous = prefix.at(-1)
|
||||
for (const [index, point] of safePath.entries()) {
|
||||
if (index === safePath.length - 1) {
|
||||
const targetDirection = connectionDirection(point, [plan.route.to.centerX, plan.route.to.centerY])
|
||||
cells.push(
|
||||
plan.route.targetIsHiddenMarker
|
||||
? { x: point[0], y: point[1], char: targetDirection === "left" || targetDirection === "right" ? "─" : "│" }
|
||||
: { x: point[0], y: point[1], arrowDirection: targetDirection },
|
||||
)
|
||||
continue
|
||||
}
|
||||
const before = index === 0 ? previous : safePath[index - 1]
|
||||
const after = safePath[index + 1]!
|
||||
const connections = new Set<DiagramDirection>()
|
||||
if (before) connections.add(connectionDirection(point, before))
|
||||
connections.add(connectionDirection(point, after))
|
||||
cells.push({ x: point[0], y: point[1], char: diagramLineGlyph(connections, "rounded") })
|
||||
}
|
||||
|
||||
return { ...plan, cells, path: fullPath }
|
||||
}
|
||||
|
||||
function placeStateTransitionLabels(
|
||||
plans: readonly StateTransitionRenderPlan[],
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
noteBounds: readonly StateDiagramNoteBounds[],
|
||||
): StateTransitionRenderPlan[] {
|
||||
let space = SpatialIndex.empty().add(
|
||||
...diagram.states.flatMap((state) => {
|
||||
@@ -817,6 +1096,22 @@ function placeStateTransitionLabels(
|
||||
plan.path.map(([x, y]) => ({ x, y })),
|
||||
),
|
||||
),
|
||||
...noteBounds.flatMap((noteBound) => {
|
||||
const target = bounds.get(noteBound.note.target)
|
||||
return [
|
||||
spatialRectClaim(`note:${noteBound.id}`, `note:${noteBound.id}`, "body", noteBound),
|
||||
...(target
|
||||
? [
|
||||
spatialPathClaim(
|
||||
`note-connector:${noteBound.id}`,
|
||||
`note-connector:${noteBound.id}`,
|
||||
"boundary",
|
||||
stateDiagramNoteConnector(noteBound, target).points,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
return plans.map((plan, planIndex) => {
|
||||
@@ -866,12 +1161,51 @@ export function createStateTransitionRenderPlans(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
feedbackLaneY: number,
|
||||
feedbackTopY?: number,
|
||||
options: StateTransitionRenderOptions = {},
|
||||
): StateTransitionRenderPlan[] {
|
||||
const noteBounds = options.noteBounds ?? []
|
||||
const budget = options.searchBudget ?? createStateSearchBudget()
|
||||
const plans = createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, options.feedbackTopY).map(
|
||||
createStateTransitionRenderPlan,
|
||||
)
|
||||
if (options.repairRoutes === false) return placeStateTransitionLabels(plans, diagram, bounds, noteBounds)
|
||||
const routeSpace = createStateSearchSpace(transitionObstacles(diagram, bounds, noteBounds))
|
||||
return placeStateTransitionLabels(
|
||||
createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, feedbackTopY).map(createStateTransitionRenderPlan),
|
||||
plans.map((plan) => bodySafeTransitionPlan(plan, diagram, bounds, noteBounds, routeSpace, budget)),
|
||||
diagram,
|
||||
bounds,
|
||||
noteBounds,
|
||||
)
|
||||
}
|
||||
|
||||
function transitionObstacles(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
noteBounds: readonly StateDiagramNoteBounds[],
|
||||
): SpatialIndex {
|
||||
return SpatialIndex.empty().add(
|
||||
...diagram.states.flatMap((state) => {
|
||||
const bound = bounds.get(state.id)
|
||||
return bound && !isHiddenCompositeMarker(state)
|
||||
? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)]
|
||||
: []
|
||||
}),
|
||||
...noteBounds.flatMap((noteBound) => {
|
||||
const target = bounds.get(noteBound.note.target)
|
||||
return [
|
||||
spatialRectClaim(`note:${noteBound.id}`, `note:${noteBound.id}`, "body", noteBound),
|
||||
...(target
|
||||
? [
|
||||
spatialPathClaim(
|
||||
`note-connector:${noteBound.id}`,
|
||||
`note-connector:${noteBound.id}`,
|
||||
"boundary",
|
||||
stateDiagramNoteConnector(noteBound, target).points,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { DiagramPoint } from "../core/geometry.js"
|
||||
import type { SpatialIndex, SpatialRole } from "../core/spatial.js"
|
||||
|
||||
const MAX_RENDER_SEARCH_VISITS = 250_000
|
||||
|
||||
export interface StateSearchBudget {
|
||||
remaining: number
|
||||
}
|
||||
|
||||
export interface StateSearchSpace {
|
||||
blocked: ReadonlySet<string>
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
}
|
||||
|
||||
export function createStateSearchBudget(): StateSearchBudget {
|
||||
return { remaining: MAX_RENDER_SEARCH_VISITS }
|
||||
}
|
||||
|
||||
export function createStateSearchSpace(
|
||||
space: SpatialIndex,
|
||||
clearance: (role: SpatialRole) => number = () => 0,
|
||||
): StateSearchSpace {
|
||||
const blocked = new Set<string>()
|
||||
let minX = 0
|
||||
let minY = 0
|
||||
let maxX = 0
|
||||
let maxY = 0
|
||||
|
||||
for (const claim of space.claims) {
|
||||
const padding = clearance(claim.role)
|
||||
for (const span of claim.spans) {
|
||||
for (let y = span.y - padding; y <= span.y + padding; y++) {
|
||||
for (let x = span.fromX - padding; x <= span.toX + padding; x++) {
|
||||
blocked.add(`${x}:${y}`)
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, x)
|
||||
maxY = Math.max(maxY, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { blocked, minX, minY, maxX, maxY }
|
||||
}
|
||||
|
||||
export function findStateManhattanPath(
|
||||
starts: readonly DiagramPoint[],
|
||||
goal: DiagramPoint,
|
||||
space: Pick<StateSearchSpace, "blocked">,
|
||||
bounds: Pick<StateSearchSpace, "minX" | "minY" | "maxX" | "maxY">,
|
||||
budget: StateSearchBudget,
|
||||
isFree: (point: DiagramPoint) => boolean = (point) => !space.blocked.has(pointKey(point)),
|
||||
): DiagramPoint[] | undefined {
|
||||
const queue = [...new Map(starts.filter(isFree).map((point) => [pointKey(point), point])).values()]
|
||||
const parents = new Map(queue.map((point) => [pointKey(point), undefined as string | undefined]))
|
||||
const points = new Map(queue.map((point) => [pointKey(point), point]))
|
||||
|
||||
for (let cursor = 0; cursor < queue.length && budget.remaining > 0; cursor++, budget.remaining--) {
|
||||
const current = queue[cursor]!
|
||||
if (current.x === goal.x && current.y === goal.y) break
|
||||
const dx = Math.sign(goal.x - current.x)
|
||||
const dy = Math.sign(goal.y - current.y)
|
||||
const candidates = [
|
||||
...(dx === 0 ? [] : [{ x: current.x + dx, y: current.y }]),
|
||||
...(dy === 0 ? [] : [{ x: current.x, y: current.y + dy }]),
|
||||
{ x: current.x + 1, y: current.y },
|
||||
{ x: current.x, y: current.y + 1 },
|
||||
{ x: current.x - 1, y: current.y },
|
||||
{ x: current.x, y: current.y - 1 },
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
candidate.x < bounds.minX ||
|
||||
candidate.x > bounds.maxX ||
|
||||
candidate.y < bounds.minY ||
|
||||
candidate.y > bounds.maxY
|
||||
)
|
||||
continue
|
||||
const key = pointKey(candidate)
|
||||
if (parents.has(key) || !isFree(candidate)) continue
|
||||
parents.set(key, pointKey(current))
|
||||
points.set(key, candidate)
|
||||
queue.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
if (!parents.has(pointKey(goal))) return undefined
|
||||
const path: DiagramPoint[] = []
|
||||
let cursor: string | undefined = pointKey(goal)
|
||||
while (cursor) {
|
||||
path.push(points.get(cursor)!)
|
||||
cursor = parents.get(cursor)
|
||||
}
|
||||
return path.reverse()
|
||||
}
|
||||
|
||||
function pointKey(point: DiagramPoint): string {
|
||||
return `${point.x}:${point.y}`
|
||||
}
|
||||
@@ -13,7 +13,7 @@ let treeSitterClient: TreeSitterClient
|
||||
let renderer: Awaited<ReturnType<typeof createTestRenderer>>["renderer"] | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
const dataPath = join(tmpdir(), "merman-markdown-test-data")
|
||||
const dataPath = join(tmpdir(), "mermaid-markdown-test-data")
|
||||
await mkdir(dataPath, { recursive: true })
|
||||
treeSitterClient = new TreeSitterClient({ dataPath })
|
||||
await treeSitterClient.initialize()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user