Compare commits

..

1 Commits

Author SHA1 Message Date
Luke Parker 67e4c6916b fix(ui): accumulate page key scrolling
Replace Chromium smooth scrolling for PageUp and PageDown with a short owned tween. Repeated key presses now extend the active target instead of cancelling and restarting browser easing.
2026-08-07 06:37:27 +00:00
174 changed files with 1512 additions and 17613 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

-18
View File
@@ -395,7 +395,6 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
@@ -571,20 +570,6 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/merman": {
"name": "@opencode-ai/merman",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.8",
@@ -895,7 +880,6 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
@@ -2079,8 +2063,6 @@
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -134,6 +134,22 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
})
test("accumulates rapid page key presses", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await scroller.focus()
const before = await scroller.evaluate((element) => ({ top: element.scrollTop, height: element.clientHeight }))
for (let index = 0; index < 3; index++) await scroller.press("PageUp")
await page.waitForTimeout(150)
expect(before.top - (await scroller.evaluate((element) => element.scrollTop))).toBeGreaterThan(before.height * 2.2)
})
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
@@ -153,88 +153,4 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_cancelled",
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ missing: "msg_user" })
})
test("keeps steered input available to the promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_steered",
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_queued",
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
})
})
@@ -29,9 +29,6 @@ export function createV2SessionReducer() {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
+13 -12
View File
@@ -688,8 +688,6 @@ export default function Page() {
return {
queryKey: [...vcsKey(), mode] as const,
enabled,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: mode
? () =>
sdk()
@@ -703,16 +701,6 @@ export default function Page() {
}
})
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
createEffect(
on(
() => desktopReviewOpen() || mobileChanges(),
(open, previous) => {
if (!open || previous || !desktopFileTreeOpen() || vcsQuery.isFetching) return
refreshVcs()
},
{ defer: true },
),
)
const reviewDiffs = () => {
if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense
@@ -959,6 +947,19 @@ export default function Page() {
),
)
const stopVcs = sdk().event.listen((evt) => {
const details = evt.details as { type: string; properties?: unknown }
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
const props =
typeof details.properties === "object" && details.properties
? (details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect(
on(
() => sdk().directory,
+27 -73
View File
@@ -263,52 +263,38 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionPendingCancelOperation<E = never> = (
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = {
export type Endpoint5_25Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_28Output = void
export type Endpoint5_25Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_29Output = void
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_26Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_29Input,
) => Effect.Effect<Endpoint5_29Output, E>
input: Endpoint5_26Input,
) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_30Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_27Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_31Input = {
export type Endpoint5_28Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_31Output =
export type Endpoint5_28Output =
| (
| {
readonly id: Event.ID
@@ -418,33 +404,6 @@ export type Endpoint5_31Output =
readonly input: SessionPending.Message
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.steered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.queued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -903,19 +862,19 @@ export type Endpoint5_31Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
export type Endpoint5_32Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
export type Endpoint5_29Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
export type Endpoint5_33Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
export type Endpoint5_30Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_31Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -943,12 +902,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionPendingQueueOperation<E>
}
readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly instructions: {
readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E>
+21 -48
View File
@@ -80,12 +80,6 @@ import type {
Endpoint5_30Output,
Endpoint5_31Input,
Endpoint5_31Output,
Endpoint5_32Input,
Endpoint5_32Output,
Endpoint5_33Input,
Endpoint5_33Output,
Endpoint5_34Input,
Endpoint5_34Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -529,58 +523,37 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveStream<Endpoint5_31Output>()(
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveStream<Endpoint5_28Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -592,18 +565,18 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
),
)
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -632,13 +605,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw),
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
generate: Endpoint5_30(raw),
log: Endpoint5_31(raw),
interrupt: Endpoint5_32(raw),
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
pending: { list: Endpoint5_23(raw) },
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
generate: Endpoint5_27(raw),
log: Endpoint5_28(raw),
interrupt: Endpoint5_29(raw),
background: Endpoint5_30(raw),
message: Endpoint5_31(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -58,12 +58,6 @@ import type {
SessionContextOutput,
SessionPendingListInput,
SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -772,39 +766,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionPendingCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionPendingSteerOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionPendingQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
},
instructions: {
entry: {
@@ -502,36 +502,6 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string }
}
export type SessionInputCancelled = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.cancelled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputSteered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.steered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputQueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.queued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = {
id: string
created: number
@@ -2000,9 +1970,6 @@ export type SessionEventDurable =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2057,9 +2024,6 @@ export type V2Event =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -3725,27 +3689,6 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingCancelOutput = void
export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingSteerOutput = void
export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type Endpoint5_5Output = Session.Info")
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi")
})
-23
View File
@@ -32,7 +32,6 @@ test("exposes every standard HTTP API group", () => {
"projectCopy",
"vcs",
"debug",
"migration",
"websearch",
"config",
])
@@ -357,28 +356,6 @@ test("session.pending.list uses the public HTTP contract", async () => {
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
})
test("session.pending mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return new Response(null, { status: 204 })
},
})
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
-2
View File
@@ -17,7 +17,6 @@
"opencode": "./bin/opencode"
},
"exports": {
"./environment": "./src/environment/index.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"
@@ -118,7 +117,6 @@
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+4 -6
View File
@@ -13,14 +13,12 @@ export const Plugin = define({
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => {
const configuredIntegrations = new Set(
configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
)
for (const [id, provider] of configuredProviders(loaded.entries)) {
const integrationID = id
if (!integrations.get(integrationID)) {
integrations.method.update({
integrationID,
method: { type: "key", label: "Manually enter API Key" },
})
}
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = provider.name ?? integration.name
})
-9
View File
@@ -1,9 +0,0 @@
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { FilesImpl } from "./files"
export interface Driver {
readonly spawner: ChildProcessSpawner["Service"]
readonly overrides?: Partial<FilesImpl>
}
export * as EnvironmentDriver from "./driver"
@@ -1,26 +0,0 @@
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { Files } from "./files"
import { makeFiles } from "./index"
import { makeLocalDriver } from "./local"
export interface Interface {
readonly files: Files
readonly spawner: ChildProcessSpawner["Service"]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Environment") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
return Service.of({ files: makeFiles(makeLocalDriver(spawner)), spawner })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [CrossSpawnSpawner.node] })
export * as EnvironmentService from "./environment"
@@ -1,192 +0,0 @@
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { collectStream } from "@opencode-ai/util/process"
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
/**
* Files derived from spawning processes: one process per intent, "$1" is
* always the target path. Scripts report classification through an exit-code
* protocol (44/45/46) so failures never require parsing localized error text;
* LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
* findutils in the target image — BSD and busybox userlands will not work.
* Malformed output from these scripts is our own bug and dies as a defect.
*/
const MAX_DATA_BYTES = 64 * 1024 * 1024
const MAX_ERROR_BYTES = 64 * 1024
const NOT_FOUND = 44
const WRONG_KIND = 45
const FAILED = 46
const TAB = "\t"
const loadMetadata = (flags = "") => `
metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
case "$metadata" in
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
esac
}
`
const statScript = `
${loadMetadata()}
printf '%s\n' "$metadata"
`
const readScript = `
${loadMetadata("-L")}
kind=\${metadata%%${TAB}*}
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
printf '%s\n' "$metadata"
if [ "$2" = range ]; then
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
else
cat -- "$1"
fi
`
const listScript = `
${loadMetadata("-L")}
kind=\${metadata%%${TAB}*}
if [ "$kind" != directory ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
find -H "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
`
const moveScript = `
${loadMetadata()}
mv -- "$1" "$2"
`
interface Result {
readonly exitCode: number
readonly stdout: Uint8Array
readonly stderr: Uint8Array
}
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
const run = (
path: string,
script: string,
args: ReadonlyArray<string> = [],
stdin?: Uint8Array,
): Effect.Effect<Result, Failed> =>
Effect.scoped(
Effect.gen(function* () {
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
env: { LC_ALL: "C" },
extendEnv: true,
stdin: stdin === undefined ? undefined : Stream.make(stdin),
})
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStream(handle.stdout, MAX_DATA_BYTES),
collectStream(handle.stderr, MAX_ERROR_BYTES),
handle.exitCode,
],
{ concurrency: "unbounded" },
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
if (stdout.truncated || stderr.truncated) {
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
}
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
}),
)
const classify = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
if (result.exitCode === WRONG_KIND) {
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
}
return Effect.fail(processFailure(path, result))
}
const complete = (path: string, result: Result) =>
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
return {
stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
read: (path, range) =>
run(
path,
readScript,
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
).pipe(
Effect.flatMap((result) =>
classify(path, result, (stdout) => {
const newline = stdout.indexOf(10)
if (newline < 0) throw new Error("Missing read metadata header")
return {
info: parseInfo(stdout.slice(0, newline)),
bytes: stdout.slice(newline + 1),
}
}),
),
),
write: (path, bytes) =>
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
Effect.flatMap((result) => complete(path, result)),
),
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
move: (from, to) =>
run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
}
}
/** `classify` for scripts whose protocol never reports WrongKind. */
const classifyPlain = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
return Effect.fail(processFailure(path, result))
}
const processFailure = (path: string, result: Result) =>
new Failed({
path,
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
})
const parseInfo = (bytes: Uint8Array): FileInfo => {
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
const size = Number(rawSize)
const mtimeMs = Number(rawMtime) * 1_000
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
return { type: parseType(rawType), size, mtimeMs }
}
const parseType = (value: string): FileType => {
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
if (value === "directory" || value === "d") return "directory"
if (value === "symbolic link" || value === "l") return "symlink"
return "other"
}
const parseList = (bytes: Uint8Array) => {
const fields = new TextDecoder().decode(bytes).split("\0")
fields.pop()
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
return Array.from({ length: fields.length / 2 }, (_, index) => ({
name: fields[index * 2 + 1],
type: parseType(fields[index * 2]),
}))
}
export * as EnvironmentExecDefaults from "./exec-defaults"
-70
View File
@@ -1,70 +0,0 @@
import { Effect, Schema } from "effect"
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
export type FileType = typeof FileType.Type
export interface FileInfo {
readonly type: FileType
readonly size: number
readonly mtimeMs: number
}
export interface DirEntry {
readonly name: string
readonly type: FileType
}
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
path: Schema.String,
}) {}
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
path: Schema.String,
actual: FileType,
}) {}
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
path: Schema.String,
cause: Schema.Defect(),
}) {}
export interface FilesImpl {
/**
* Content operations (`read`, `list`) follow final symlinks; metadata operations (`stat` and entry
* tags returned by `list`) do not. `info` describes the target file whose bytes are returned.
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
* `Failed`, so callers must use ranges for larger files.
*/
readonly read: (
path: string,
range?: { readonly offset: number; readonly length: number },
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
/** Follows a final symlink to the listed directory while preserving each returned entry's own type. */
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
readonly remove: (path: string) => Effect.Effect<void, Failed>
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
}
export interface Files extends FilesImpl {}
/**
* Derives a follow-stat kind from the lstat-like Files contract. A dangling
* symlink fails with `NotFound`.
*/
export const typeFollowing = (files: Files, path: string) =>
files.stat(path).pipe(
Effect.flatMap((info) =>
info.type === "symlink"
? files.read(path, { offset: 0, length: 0 }).pipe(
Effect.map((result) => result.info.type),
Effect.catchTag("Environment.WrongKind", (error) => Effect.succeed(error.actual)),
)
: Effect.succeed(info.type),
),
)
export * as EnvironmentFiles from "./files"
-27
View File
@@ -1,27 +0,0 @@
export * as Environment from "./index"
export { type Driver } from "./driver"
export {
type DirEntry,
Failed,
type FileInfo,
type Files,
type FilesImpl,
type FileType,
NotFound,
typeFollowing,
WrongKind,
} from "./files"
export { execDefaults } from "./exec-defaults"
export { makeLocalDriver } from "./local"
export { makeMemoryDriver, type MemoryDriver } from "./memory"
export { type Interface, node, Service } from "./environment"
import type { Driver } from "./driver"
import { execDefaults } from "./exec-defaults"
import type { Files } from "./files"
export const makeFiles = (driver: Driver): Files => ({
...execDefaults(driver.spawner),
...driver.overrides,
})
-103
View File
@@ -1,103 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "./driver"
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
/**
* The host filesystem binding. Deliberately raw node:fs rather than effect's
* FileSystem service or FSUtil: the contract needs lstat semantics (stat
* reports "symlink") and typed directory entries, and effect's node
* FileSystem provides neither — its stat always follows symlinks and
* readDirectory returns names only. FSUtil hits the same gap and its
* readDirectoryEntries already bypasses to raw node readdir internally.
* Nothing above the environment seam touches node:fs.
*/
export const makeLocalDriver = (spawner: ChildProcessSpawner["Service"]): Driver => {
const overrides: FilesImpl = {
read: (value, range) =>
Effect.gen(function* () {
const info = yield* stat(value, true)
if (info.type !== "file") return yield* new WrongKind({ path: value, actual: info.type })
if (range === undefined) {
const bytes = yield* attempt(value, () => fs.readFile(value), true)
return { info, bytes }
}
const bytes = yield* attempt(
value,
async () => {
const handle = await fs.open(value, "r")
try {
const buffer = new Uint8Array(range.length)
const result = await handle.read(buffer, 0, range.length, range.offset)
return buffer.subarray(0, result.bytesRead)
} finally {
await handle.close()
}
},
true,
)
return { info, bytes }
}),
stat: (value) => stat(value, false),
list: (value) =>
Effect.gen(function* () {
const info = yield* stat(value, true)
if (info.type !== "directory") return yield* new WrongKind({ path: value, actual: info.type })
const entries = yield* attempt(value, () => fs.readdir(value, { withFileTypes: true }), true)
return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }))
}),
write: (value, bytes) =>
attempt(value, async () => {
await fs.mkdir(path.dirname(value), { recursive: true })
await fs.writeFile(value, bytes)
}),
remove: (value) => attempt(value, () => fs.rm(value, { recursive: true, force: true })),
move: (from, to) =>
Effect.gen(function* () {
yield* stat(from, false)
const destination = yield* stat(to, false).pipe(
Effect.map((info) => (info.type === "directory" ? path.join(to, path.basename(from)) : to)),
Effect.catchIf(
(error) => error instanceof NotFound,
() => Effect.succeed(to),
),
)
yield* attempt(from, () => fs.rename(from, destination))
}),
mkdir: (value) => attempt(value, () => fs.mkdir(value, { recursive: true }).then(() => undefined)),
}
return { spawner, overrides }
}
const stat = (value: string, follow: boolean) =>
attempt(value, () => (follow ? fs.stat(value) : fs.lstat(value)), true).pipe(
Effect.map((stats): FileInfo => ({ type: fileType(stats), size: stats.size, mtimeMs: stats.mtimeMs })),
)
const fileType = (entry: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileType => {
if (entry.isFile()) return "file"
if (entry.isDirectory()) return "directory"
if (entry.isSymbolicLink()) return "symlink"
return "other"
}
function attempt<A>(value: string, run: () => Promise<A>): Effect.Effect<A, Failed>
function attempt<A>(value: string, run: () => Promise<A>, missing: true): Effect.Effect<A, NotFound | Failed>
function attempt<A>(value: string, run: () => Promise<A>, missing = false) {
return Effect.tryPromise({
try: run,
catch: (cause) =>
missing && isMissing(cause) ? new NotFound({ path: value }) : new Failed({ path: value, cause }),
})
}
const isMissing = (cause: unknown) =>
cause !== null &&
typeof cause === "object" &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
export * as EnvironmentLocal from "./local"
-168
View File
@@ -1,168 +0,0 @@
import path from "node:path"
import { Effect, PlatformError } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "./driver"
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
type Node =
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
| { readonly type: "directory"; readonly mtimeMs: number }
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
export interface MemoryDriver extends Driver {
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
}
export const makeMemoryDriver = (): MemoryDriver => {
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
const key = (value: string) => path.posix.resolve("/", value)
const info = (node: Node): FileInfo => ({
type: node.type,
size:
node.type === "file"
? node.bytes.length
: node.type === "symlink"
? new TextEncoder().encode(node.target).length
: 0,
mtimeMs: node.mtimeMs,
})
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
const normalized = key(value)
const parts = normalized.split("/").filter(Boolean)
const base = "/"
const walk = (current: string, index: number): string | undefined => {
if (index === parts.length) return current
const part = parts[index]
const candidate = path.posix.join(current, part)
const node = nodes.get(candidate)
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
if (seen.has(candidate)) return undefined
seen.add(candidate)
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
}
return walk(base, 0)
}
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
const requireParent = (value: string) => {
const parentPath = path.posix.dirname(key(value))
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
}
const mkdirSync = (value: string) => {
const target = resolveKey(value, false) ?? key(value)
const existing = nodes.get(target)
if (existing?.type === "directory") return
if (existing) throw new Error(`Path is not a directory: ${value}`)
const parent = path.posix.dirname(target)
if (parent !== target) mkdirSync(parent)
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
write: (value, bytes) =>
Effect.try({
try: () => {
mkdirSync(path.posix.dirname(key(value)))
const existing = lookup(value)
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
requireParent(target)
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
for (const entry of nodes.keys()) {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
const spawner = make((command) =>
Effect.suspend(() => {
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
return Effect.fail(
PlatformError.systemError({
_tag: "Unknown",
module: "EnvironmentMemory",
method: "spawn",
pathOrDescriptor: description,
cause: failed(description, new Error("The memory driver cannot spawn processes")),
}),
)
}),
)
return {
spawner,
overrides,
symlink: (target, value) =>
Effect.try({
try: () => {
requireParent(value)
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
}
}
export * as EnvironmentMemory from "./memory"
+12 -48
View File
@@ -5,8 +5,6 @@ import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "./environment"
import type { Files } from "./environment"
export interface Target {
readonly absolute: string
@@ -31,36 +29,13 @@ export interface WriteResult {
}
export interface Interface {
/** Serialize a complete read/prepare/write mutation transaction by resolved path. */
readonly withLock: (
targets: ReadonlyArray<string>,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, Environment.Failed>
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: (
input: TextWriteInput,
) => Effect.Effect<WriteResult, Environment.WrongKind | Environment.Failed>
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
export const readText = Effect.fn("FileMutation.readText")(function* (files: Files, target: string) {
return Bom.decodeBytes((yield* files.read(target)).bytes)
})
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
files: Files,
target: string,
bom: boolean,
) {
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
if (synced.bytes) yield* files.write(target, synced.bytes)
return synced.text
})
/** Share transaction locks across Location graphs that address the same file. */
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* Serialize file changes by absolute target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
@@ -69,12 +44,8 @@ const transactionLocks = KeyedMutex.makeUnsafe<string>()
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
const fs = yield* FSUtil.Service
const locks = KeyedMutex.makeUnsafe<string>()
const withLock: Interface["withLock"] = (targets) => (effect) =>
[...new Set(targets.map(FSUtil.resolve))]
.sort()
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -90,14 +61,8 @@ const layer = Layer.effect(
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const existed = yield* environment.files.stat(input.target.absolute).pipe(
Effect.as(true),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
)
yield* environment.files.write(
input.target.absolute,
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
)
const existed = yield* fs.exists(input.target.absolute)
yield* fs.writeWithDirs(input.target.absolute, input.content)
return writeResult(input.target, existed)
}),
),
@@ -107,24 +72,23 @@ const layer = Layer.effect(
withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
Effect.map((result) => result.bytes),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
)
yield* environment.files.write(
const current = yield* fs
.readFile(input.target.absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.absolute,
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
),
)
return Service.of({ withLock, write, writeTextPreservingBom })
return Service.of({ write, writeTextPreservingBom })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
/**
* Deferred until the corresponding integrations exist.
@@ -11,6 +11,15 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
}
export interface Interface {}
@@ -35,6 +44,19 @@ const layer = Layer.effect(
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = Protected.isHome(location.directory)
if (!home && location.vcs) {
const updates = yield* watcher.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
if (home) {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
@@ -42,7 +64,10 @@ const layer = Layer.effect(
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
-2
View File
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { FileMutation } from "./file-mutation"
import { Environment } from "./environment"
import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
@@ -54,7 +53,6 @@ export { LocationServiceMap } from "./location-service-map"
const locationServiceNodes = [
Location.node,
Environment.node,
Config.node,
Agent.node,
Command.node,
-3
View File
@@ -16,7 +16,6 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { Form } from "../form"
@@ -71,7 +70,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const config = yield* Config.Service
const credential = yield* Credential.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const filesystem = yield* FileSystem.Service
@@ -104,7 +102,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(FileSystem.Service, filesystem),
+1 -5
View File
@@ -14,7 +14,6 @@ import { Credential } from "../credential"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem"
@@ -283,9 +282,7 @@ const layer = Layer.effect(
})
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -323,7 +320,6 @@ export const node = makeLocationNode({
Config.node,
Credential.node,
Bus.node,
Environment.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
@@ -1,84 +0,0 @@
export * as WebSearchFirecrawl from "./firecrawl"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
limit: Schema.Number.pipe(Schema.optional),
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
})
const SearchResponse = Schema.fromJsonString(
Schema.Struct({
success: Schema.Boolean,
data: Schema.Struct({
web: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
}),
),
}),
}),
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
integrationID: "firecrawl",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "firecrawl",
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "firecrawl",
name: "Firecrawl",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("firecrawl")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"firecrawl_search",
{ input: McpInput, output: McpOutput },
{ query: input.query, limit: 8 },
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
})) ?? []
)
}),
})
})
}),
})
+1 -2
View File
@@ -1,5 +1,4 @@
import { WebSearchExa } from "./exa"
import { WebSearchFirecrawl } from "./firecrawl"
import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
+5 -7
View File
@@ -3,9 +3,8 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { collectStream, waitForAbort } from "@opencode-ai/util/process"
import { Environment } from "./environment"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary"
@@ -94,7 +93,7 @@ const isInvalidPattern = (stderr: string) =>
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
const process = yield* AppProcess.Service
const binary = yield* RipgrepBinary.Service
const run = <A>(input: {
@@ -108,8 +107,7 @@ const layer = Layer.effect(
}) => {
const program = Effect.scoped(
Effect.gen(function* () {
// Hosted environments will resolve rg through their driver image; the spawner is the execution seam.
const handle = yield* environment.spawner.spawn(
const handle = yield* process.spawn(
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
)
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
@@ -277,4 +275,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node, RipgrepBinary.node] })
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
-39
View File
@@ -133,14 +133,6 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
"Session.PendingInputConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
@@ -189,9 +181,6 @@ export interface Interface {
* unhandled compaction barriers.
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -329,31 +318,6 @@ const layer = Layer.effect(
),
)
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
yield* result.get(input.sessionID)
return yield* new PendingInputConflictError(input)
})
const mutatePending = (
input: PendingInputRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) => Effect.Effect<unknown>,
wake = false,
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
? pendingConflict(input)
: Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
}),
)
const result = Service.of({
create: Effect.fn("Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
@@ -543,9 +507,6 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID)
}),
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
log: (input) =>
Stream.unwrap(
result
+1 -16
View File
@@ -2,14 +2,9 @@ export * as SessionRestart from "./restart"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../../bus"
import { SessionEvent } from "../event"
import { SessionExecution } from "../execution"
import { SessionStore } from "../store"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
export interface Interface {
/**
* Marks every execution active in this process for resumption by the next server start.
@@ -31,7 +26,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
return Service.of({
suspendActiveSessions: Effect.gen(function* () {
yield* store.suspend(yield* execution.active)
@@ -43,11 +37,6 @@ export const layer = Layer.effect(
(sessionID) =>
Effect.gen(function* () {
if (!(yield* store.consumeSuspended(sessionID))) return
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Drain failures are already logged and durably recorded by the execution layer.
yield* Effect.ignore(execution.resume(sessionID))
}),
@@ -58,8 +47,4 @@ export const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] })
@@ -90,9 +90,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void,
"session.input.cancelled": () => Effect.void,
"session.input.steered": () => Effect.void,
"session.input.queued": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
+4 -84
View File
@@ -37,7 +37,6 @@ const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict",
@@ -295,7 +294,10 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
*/
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService,
input: PendingRef,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
@@ -310,55 +312,6 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored
})
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionPendingTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.delivery, input.from),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
(db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
(db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
@@ -436,39 +389,6 @@ export const equivalent = (
return false
}
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
-18
View File
@@ -485,24 +485,6 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionPending.projectSteered(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputQueued, (event) =>
SessionPending.projectQueued(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
+270 -269
View File
@@ -6,9 +6,9 @@ import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Bus } from "./bus"
import { Environment } from "./environment"
import { Location } from "./location"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select"
@@ -65,284 +65,285 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const layer = (options?: ShellSelect.Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const session = yield* Deferred.await(ready)
return session.info
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const resolve = () =>
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* environment.spawner.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node],
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
})
}
+34 -105
View File
@@ -2,7 +2,8 @@ export * as Skill from "./skill"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
@@ -12,7 +13,6 @@ import { Permission } from "./permission"
import { AbsolutePath } from "./schema"
import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state"
import { Watcher } from "./filesystem/watcher"
export const DirectorySource = Skill.DirectorySource
export type DirectorySource = Skill.DirectorySource
@@ -81,82 +81,6 @@ const layer = Layer.effect(
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const changes = yield* PubSub.unbounded<string>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const changed = yield* lock.withPermit(
Effect.gen(function* () {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
)
if (invalidated.length === 0) return false
cache.clear()
yield* FiberMap.clear(watches)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
return true
}),
)
if (!changed) return
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe(
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
)
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{
onlyIfMissing: true,
startImmediately: true,
},
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
directory: string,
) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) {
yield* watch(target, "file")
}
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const state = State.create<Data, Draft>({
name: "skill",
@@ -168,10 +92,7 @@ const layer = Layer.effect(
},
list: () => draft.sources as Source[],
}),
finalize: () =>
lock
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
const load = Effect.fn("Skill.load")(function* (source: Source) {
@@ -183,22 +104,14 @@ const layer = Layer.effect(
directories: [],
skills: [source.skill.id],
})
return { skills: [source.skill], paths: [] }
return { skills: [source.skill], directories: [] }
}
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const paths = [...roots]
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
const external = path.dirname(resolved)
paths.push(external)
yield* watch(external, "directory")
}
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const markdown = ConfigMarkdown.parseOption(content)
@@ -226,22 +139,38 @@ const layer = Layer.effect(
directories,
skills: skills.map((skill) => skill.id),
})
return { skills, paths }
return { skills, directories }
})
const list = Effect.fn("Skill.list")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
}),
const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.directories.some((directory) => FSUtil.contains(directory, file)),
)
if (invalidated.length === 0) return
for (const [key] of invalidated) cache.delete(key)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => invalidate(event.data.file)),
Effect.forkScoped({ startImmediately: true }),
)
const list = Effect.fn("Skill.list")(function* () {
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
})
return Service.of({
@@ -258,5 +187,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
deps: [SkillDiscovery.node, FSUtil.node, Bus.node],
})
+17 -20
View File
@@ -11,11 +11,9 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
@@ -111,10 +109,9 @@ export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -155,16 +152,17 @@ export const Plugin = {
})
}
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
),
Effect.catchTag("Environment.WrongKind", (error) =>
error.actual === "directory"
? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
: Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
),
)
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
),
)
if (info.type === "Directory") {
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
}
const original = yield* Bom.readFile(fs, target.absolute)
const source = original.text
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
@@ -206,20 +204,19 @@ export const Plugin = {
})
}
const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* fileMutation.write({
const result = yield* files.write({
target,
content: Bom.join(replaced, original.bom || replacementBom),
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
: (yield* FileMutation.readText(environment.files, target.absolute)).text
? yield* Bom.syncFile(fs, target.absolute, bom)
: (yield* Bom.readFile(fs, target.absolute)).text
return {
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
fileMutation.withLock([path.resolve(location.directory, input.path)]),
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+12 -10
View File
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Ripgrep } from "../../ripgrep"
@@ -42,7 +42,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
@@ -82,20 +82,22 @@ export const Plugin = {
agent: context.agent,
source,
})
const type = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (type !== "directory")
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (info.type !== "Directory")
return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
)
const root = target.absolute
const root = path.resolve(location.directory, searchPath ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep
.glob({
cwd: root,
cwd: target.absolute,
pattern: input.pattern,
limit: limit + 1,
})
+94 -90
View File
@@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
@@ -15,11 +15,11 @@ import { RelativePath } from "../../schema"
export const name = "grep"
export const Input = Schema.Struct({
pattern: FileSystem.GrepInput.fields.pattern
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
.annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
}),
pattern: FileSystem.GrepInput.fields.pattern.check(
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
).annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
}),
path: Schema.optionalKey(RelativePath).annotate({
description: "File or directory to search. Defaults to the current working directory.",
}),
@@ -58,7 +58,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
@@ -66,100 +66,104 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
draft.add(
({
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
const root = target.absolute
const type = yield* Environment.typeFollowing(environment.files, root).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
const cwd = type === "directory" ? root : path.dirname(root)
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const matches = yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: type === "file" ? path.basename(root) : undefined,
include: input.include,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
const root = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs
.stat(root)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
const cwd = info?.type === "Directory" ? root : path.dirname(root)
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const matches = yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: info?.type === "File" ? path.basename(root) : undefined,
include: input.include,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
}),
),
}),
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
),
}),
}),
),
}),
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
),
}),
}),
),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
),
),
}),
}),
),
)
.pipe(Effect.orDie)
}),
+42 -41
View File
@@ -4,13 +4,12 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Result, Schema } from "effect"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Environment } from "../../environment"
import { Formatter } from "../../formatter"
import { FileMutation } from "../../file-mutation"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
@@ -45,13 +44,7 @@ export const toModelOutput = (output: Output) =>
].join("\n")
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
readonly target: Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: Target
readonly before: string
readonly after: string
@@ -76,8 +69,7 @@ interface Target {
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
@@ -92,13 +84,6 @@ export const Plugin = {
output: Output,
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
path.resolve(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
])
: []
const fail = (operation: string, error: unknown) => {
const completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({
@@ -112,7 +97,7 @@ export const Plugin = {
id: context.id,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(parsed).pipe(
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
)
if (hunks.length === 0) {
@@ -140,19 +125,18 @@ export const Plugin = {
})
}
if (hunk.type === "add") {
const content =
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
prepared.push({
...hunk,
target,
content,
before: "",
after: Bom.split(content).text,
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
).text,
})
return
}
if (hunk.type === "delete") {
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -167,7 +151,20 @@ export const Plugin = {
const original =
previous ??
(yield* Effect.gen(function* () {
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
const stats = yield* fs.stat(target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
}),
),
)
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -236,8 +233,13 @@ export const Plugin = {
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
yield* environment.files
.write(change.target.absolute, new TextEncoder().encode(change.content))
yield* fs
.writeWithDirs(
change.target.absolute,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
@@ -247,7 +249,7 @@ export const Plugin = {
return
}
if (change.type === "delete") {
yield* environment.files
yield* fs
.remove(change.target.absolute)
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
applied.push({
@@ -259,10 +261,10 @@ export const Plugin = {
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
yield* environment.files
.write(moveTarget.absolute, new TextEncoder().encode(change.content))
yield* fs
.writeWithDirs(moveTarget.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* environment.files
yield* fs
.remove(change.target.absolute)
.pipe(
Effect.mapError((error) =>
@@ -276,8 +278,8 @@ export const Plugin = {
})
return
}
yield* environment.files
.write(change.target.absolute, new TextEncoder().encode(change.content))
yield* fs
.writeWithDirs(change.target.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
@@ -292,13 +294,13 @@ export const Plugin = {
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* FileMutation.readText(environment.files, target).pipe(
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
@@ -313,7 +315,6 @@ export const Plugin = {
})
return { applied, files }
}).pipe(
mutation.withLock(lockTargets),
Effect.map((output) => ({
output,
content: toModelOutput(output),
@@ -344,10 +345,10 @@ export const Plugin = {
}
function errorMessage(error: unknown) {
if (error instanceof Environment.NotFound) return "file does not exist"
if (error instanceof Environment.WrongKind)
return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
if (error instanceof Environment.Failed) return errorMessage(error.cause)
if (error instanceof PlatformError) {
if (error.reason._tag === "NotFound") return "file does not exist"
return error.reason.description ?? error.reason.message
}
return error instanceof Error ? error.message : String(error)
}
+11 -8
View File
@@ -11,7 +11,6 @@ import { Permission } from "../../permission"
import { SessionInstructions } from "../../session/instructions"
import { AbsolutePath } from "../../schema"
import { ReadToolFileSystem } from "../read-filesystem"
import { Environment } from "../../environment"
export const name = "read"
const FILENAME = "AGENTS.md"
@@ -73,12 +72,16 @@ export const Plugin = {
agent: context.agent,
source,
})
const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe(
Effect.catchIf(
(error) => error instanceof Environment.NotFound,
() => missing(input.path, target.absolute),
),
)
const type = yield* reader
.inspect(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
@@ -92,7 +95,7 @@ export const Plugin = {
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: content.type === "list-page" ? resolved : dirname(resolved),
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
+10 -8
View File
@@ -5,8 +5,8 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Config } from "../../config"
import { Environment } from "../../environment"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
@@ -83,7 +83,7 @@ export const Plugin = {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
const fsUtil = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* Permission.Service
@@ -179,12 +179,14 @@ export const Plugin = {
agent: context.agent,
source,
})
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir !== "directory")
const workdir = yield* fsUtil
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}),
)
+8 -10
View File
@@ -10,7 +10,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation"
@@ -47,9 +47,9 @@ export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -77,8 +77,8 @@ export const Plugin = {
agent: context.agent,
source,
})
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
const current = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
)
const next = Bom.split(input.content)
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
@@ -91,11 +91,9 @@ export const Plugin = {
agent: context.agent,
source,
})
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
+191 -152
View File
@@ -2,22 +2,17 @@ export * as ReadToolFileSystem from "./read-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { lookup } from "mime-types"
import { Environment } from "../environment"
import type { Files } from "../environment"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { Mime } from "../mime"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const FIRST_CHUNK = 256 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
resource: Schema.String,
@@ -57,13 +52,8 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
}
}
export type ReadError =
| Environment.NotFound
| Environment.Failed
| BinaryFileError
| MediaIngestLimitError
| OffsetOutOfRangeError
| PathKindError
export type InspectError = FSUtil.Error | PathKindError
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
export const PageInput = Schema.Struct({
offset: Schema.optionalKey(NonNegativeInt),
@@ -100,113 +90,202 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
}) {}
export interface Interface {
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError>
readonly read: (
path: AbsolutePath,
resource: string,
page?: PageInput,
) => Effect.Effect<FileContent | TextPage | ListPage, ReadError>
) => Effect.Effect<FileContent | TextPage, ReadError>
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
const mimeType = (value: string) => lookup(value) || "application/octet-stream"
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const mediaMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
}
const binary = (bytes: Uint8Array) => {
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
const info = yield* fs.stat(input)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
return type
})
export const read = Effect.fn("ReadTool.read")(function* (
files: Files,
input: AbsolutePath,
fs: FSUtil.Interface,
input: string,
resource: string,
page: PageInput = {},
) {
const first = yield* files.read(input, { offset: 0, length: FIRST_CHUNK }).pipe(
Effect.catchTag("Environment.WrongKind", (error) => {
if (error.actual !== "directory")
return Effect.fail(new PathKindError({ resource, expected: "a file or directory" }))
return files.list(input).pipe(
Effect.map((entries) => list(entries, page)),
Effect.catchTag("Environment.WrongKind", () =>
Effect.fail(new PathKindError({ resource, expected: "a file or directory" })),
),
const real = yield* fs.realPath(input)
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(real, { flag: "r" })
const info = yield* file.stat
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" }))
const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
() => new Uint8Array(),
)
const mime = mediaMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64" as const,
mime,
}
}
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder()
const text = [decodeUtf8(decoder, first)]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
text.push(yield* decodeChunk(resource, decoder, chunk.value))
}
text.push(decodeUtf8(decoder))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: text.join(""),
encoding: "utf8" as const,
mime: FSUtil.mimeType(real),
}
}
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder()
let pending = ""
let discard = false
let line = 1
let bytes = 0
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return true
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
next = line
return false
}
lines.push(text)
bytes += size
line++
return true
}
const consume = (input: string) => {
let text = input
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false
}
return true
}
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
let start = 0
while (start < chunk.length) {
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const newline = chunk.indexOf(10, start)
const end = newline === -1 ? chunk.length : newline + 1
const segment = chunk.subarray(start, end)
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(decodeUtf8(decoder, segment))) return false
start = end
}
return true
})
let done = !(yield* consumeChunk(first))
while (!done) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
done = !(yield* consumeChunk(chunk.value))
}
if (!done) {
const tail = decodeUtf8(decoder)
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
}
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
return new TextPage({
type: "text-page",
content: lines.join("\n"),
mime: FSUtil.mimeType(real),
offset,
truncated: next !== undefined,
...(next === undefined ? {} : { next }),
})
}),
)
if (first instanceof ListPage) return first
const media = Mime.detect(first.bytes)
if (MEDIA_MIMES.has(media)) {
if (first.info.size > MAX_MEDIA_INGEST_BYTES)
return yield* new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })
const whole = yield* readFile(files, input, resource)
return {
type: "file" as const,
uri: pathToFileURL(input).href,
name: path.basename(input),
content: Buffer.from(whole.bytes).toString("base64"),
encoding: "base64" as const,
mime: media,
}
}
const paged = first.info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (first.bytes.includes(0)) return yield* new BinaryFileError({ resource })
return {
type: "file" as const,
uri: pathToFileURL(input).href,
name: path.basename(input),
content: new TextDecoder().decode(first.bytes),
encoding: "utf8" as const,
mime: mimeType(input),
}
}
const chunks = [first.bytes]
while (true) {
const bytes = Buffer.concat(chunks)
const eof = bytes.length >= first.info.size
const result = textPage(bytes, eof, page)
if (result !== undefined) return yield* makeTextPage(bytes, input, resource, result)
const next = yield* readFile(files, input, resource, { offset: bytes.length, length: FIRST_CHUNK })
if (next.bytes.length === 0) {
const result = textPage(bytes, true, page)
if (result === undefined) return yield* Effect.die("Read page did not settle at EOF")
return yield* makeTextPage(bytes, input, resource, result)
}
chunks.push(next.bytes)
}
})
const readFile = (
files: Files,
input: AbsolutePath,
resource: string,
range?: { readonly offset: number; readonly length: number },
) =>
files
.read(input, range)
.pipe(
Effect.catchTag("Environment.WrongKind", () => Effect.fail(new PathKindError({ resource, expected: "a file" }))),
)
const makeTextPage = Effect.fnUntraced(function* (
bytes: Uint8Array,
input: AbsolutePath,
resource: string,
result: NonNullable<ReturnType<typeof textPage>>,
) {
if (bytes.subarray(0, result.consumed).includes(0)) return yield* new BinaryFileError({ resource })
if (result.entries.length === 0 && result.offset !== 1)
return yield* new OffsetOutOfRangeError({ offset: result.offset })
return new TextPage({
type: "text-page",
content: result.entries.join("\n"),
mime: mimeType(input),
offset: result.offset,
truncated: result.next !== undefined,
...(result.next === undefined ? {} : { next: result.next }),
})
})
const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
const real = yield* fs.realPath(input)
const items = yield* fs.readDirectoryEntries(real)
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const visible = items
@@ -237,58 +316,18 @@ const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
truncated,
...(truncated ? { next: offset + selected.length } : {}),
})
}
const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const decoded = new TextDecoder().decode(bytes)
const split = decoded.split("\n")
const complete = eof ? (split.at(-1) === "" ? split.slice(0, -1) : split) : split.slice(0, -1)
const available = complete.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
const entries: string[] = []
let size = 0
let next: number | undefined
for (const [index, value] of available.slice(offset - 1).entries()) {
const line = offset + index
if (entries.length >= limit || size >= MAX_READ_BYTES) {
next = line
break
}
const text = value.length > MAX_LINE_LENGTH ? value.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : value
const lineSize = Buffer.byteLength(text, "utf-8") + (entries.length > 0 ? 1 : 0)
if (size + lineSize > MAX_READ_BYTES) {
next = line
break
}
entries.push(text)
size += lineSize
}
if (next === undefined && entries.length >= limit && (!eof || offset - 1 + entries.length < available.length))
next = offset + entries.length
if (!eof && next === undefined) return
const consumedLines = next === undefined ? available.length : next - 1
const consumed = consumedLines === 0 ? 0 : (nthNewline(bytes, consumedLines) ?? bytes.length)
return { entries, offset, next, consumed }
}
const nthNewline = (bytes: Uint8Array, count: number) => {
let found = 0
for (const [index, byte] of bytes.entries()) {
if (byte !== 10) continue
found++
if (found === count) return index + 1
}
}
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) })
const fs = yield* FSUtil.Service
return Service.of({
inspect: (path) => inspect(fs, path),
read: (path, resource, page) => read(fs, path, resource, page),
list: (path, page) => list(fs, path, page),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
@@ -50,33 +50,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const entries = [
new Document({
type: "document",
info: decode({
providers: {
litellm: {
package: "aisdk:@ai-sdk/openai-compatible",
models: { chat: {} },
},
},
}),
}),
]
yield* addPlugin(entries)
expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({
id: "litellm",
name: "litellm",
methods: [{ type: "key", label: "Manually enter API Key" }],
})
}),
)
it.effect("defaults custom models to agent capabilities", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
-88
View File
@@ -1,88 +0,0 @@
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import {
execDefaults,
Failed,
makeFiles,
makeLocalDriver,
makeMemoryDriver,
NotFound,
typeFollowing,
} from "../src/environment/index"
import { tmpdir } from "./fixture/tmpdir"
import { environmentConformance } from "./lib/environment-conformance"
import { it } from "./lib/effect"
describe("typeFollowing", () => {
it.effect("follows symlinks without changing stat semantics", () =>
Effect.gen(function* () {
const driver = makeMemoryDriver()
const files = makeFiles(driver)
yield* files.mkdir("/directory")
yield* files.write("/file", new Uint8Array())
yield* driver.symlink("/directory", "/directory-link")
yield* driver.symlink("/file", "/file-link")
yield* driver.symlink("/missing", "/dangling-link")
expect(yield* typeFollowing(files, "/directory-link")).toBe("directory")
expect(yield* typeFollowing(files, "/file-link")).toBe("file")
expect(yield* typeFollowing(files, "/dangling-link").pipe(Effect.flip)).toBeInstanceOf(NotFound)
}),
)
})
environmentConformance("memory environment", () =>
Effect.sync(() => {
const driver = makeMemoryDriver()
return {
files: makeFiles(driver),
root: `/workspace-${crypto.randomUUID()}`,
symlink: driver.symlink,
}
}),
)
environmentConformance("local environment", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const tmp = yield* Effect.promise(() => tmpdir("opencode-local-environment-"))
return {
files: makeFiles(makeLocalDriver(spawner)),
root: tmp.path,
...(process.platform === "win32"
? {}
: {
symlink: (target: string, link: string) =>
Effect.tryPromise({
try: () => fs.symlink(target, link),
catch: (cause) => new Failed({ path: link, cause }),
}),
}),
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
}
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
environmentConformance(
"GNU exec environment",
() =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
return {
files: execDefaults(spawner),
root: tmp.path,
symlink: (target: string, link: string) =>
Effect.tryPromise({
try: () => fs.symlink(target, link),
catch: (cause) => new Failed({ path: link, cause }),
}),
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
}
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
process.platform !== "linux",
)
+12 -63
View File
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -13,7 +13,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) {
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
@@ -21,7 +21,7 @@ function provide(directory: string, environmentLayer = LayerNode.compile(Environ
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
[Location.node, activeLocation],
[Environment.node, environmentLayer],
[FSUtil.node, filesystemLayer],
]),
)
}
@@ -152,57 +152,6 @@ describe("FileMutation", () => {
),
)
it.live("shares transaction locks across Location service instances", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const target = path.join(directory, "shared.txt")
const first = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
}).pipe(provide(directory), Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
}).pipe(provide(directory), Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
),
)
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondFinished = yield* Deferred.make<void>()
const files = yield* FileMutation.Service
const first = yield* files
.withLock([path.join(directory, "first.txt")])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
.pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
}).pipe(provide(directory)),
),
)
it.live("allows distinct absolute targets to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -242,16 +191,16 @@ describe("FileMutation", () => {
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
return Layer.effect(
Environment.Service,
FSUtil.Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
return Environment.Service.of({
...environment,
files: {
...environment.files,
write: (target, content) => run(environment.files.write(target, content), target),
},
const filesystem = yield* FSUtil.Service
return FSUtil.Service.of({
...filesystem,
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
writeFileString: (target, content, options) =>
run(filesystem.writeFileString(target, content, options), target),
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
}
+126 -93
View File
@@ -17,8 +17,9 @@ import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
@@ -74,9 +75,10 @@ describe("Watcher lifecycle", () => {
const interrupted = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe({ path: "/pending", type: "directory" })
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
const consumer = yield* watcher.subscribe({ path: "/pending", type: "directory" }).pipe(
Effect.flatMap(Stream.runDrain),
Effect.forkScoped({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* Fiber.interrupt(consumer)
expect(yield* Deferred.isDone(interrupted)).toBe(true)
@@ -97,9 +99,10 @@ describe("Watcher lifecycle", () => {
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consume = () =>
watcher
.subscribe({ path: "/shared", type: "directory" })
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
watcher.subscribe({ path: "/shared", type: "directory" }).pipe(
Effect.flatMap(Stream.runDrain),
Effect.forkScoped({ startImmediately: true }),
)
const first = yield* consume()
const second = yield* consume()
yield* Effect.yieldNow
@@ -135,26 +138,22 @@ describe("Watcher lifecycle", () => {
})
})
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
return Effect.provide(built)
return Effect.provide(
AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
)
}
function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: {
vcs?: "git" | "hg"
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
},
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
) {
return Effect.acquireRelease(
Effect.promise(async () => {
@@ -174,57 +173,9 @@ function withTmp<A, E, R>(
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
}
describe("LocationWatcher subscriptions", () => {
it.live("watches only exact Git branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
}),
{ vcs: "git", watcher },
)
})
it.live("watches only exact Hg branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
}),
{ vcs: "hg", watcher },
)
})
})
function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -275,18 +226,31 @@ function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: (
)
}
function ready(file: string, eventFile = file) {
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
return Effect.acquireUseRelease(
wait(check),
({ deferred }) =>
trigger.pipe(
Effect.andThen(Deferred.await(deferred)),
Effect.timeoutOption(`${timeout} millis`),
Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
),
({ fiber }) => Fiber.interrupt(fiber),
)
}
function ready(directory: string) {
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
yield* eventuallyUpdate(
(event) => event.file === eventFile,
() => fs.writeFileString(file, content),
).pipe(Effect.asVoid)
(event) => event.file === file,
() => fs.writeFileString(file, `ready-${Math.random()}`),
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
})
}
describeNative("LocationWatcher", () => {
describeWatcher("LocationWatcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -312,25 +276,94 @@ describeNative("LocationWatcher", () => {
),
)
it.live("detects creation of a missing directory target", () =>
it.live("publishes root create, update, and delete events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const file = path.join(directory, "watch.txt")
yield* ready(directory)
for (const item of [
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
{ event: "unlink" as const, trigger: fs.remove(file) },
]) {
expect(
yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
).toEqual({
file,
event: item.event,
})
}
}),
{ vcs: "git" },
),
)
it.live("skips non-git roots", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const watcher = yield* Watcher.Service
const target = path.join(directory, "generated")
const updates = yield* watcher.subscribe({ path: target, type: "file" })
const update = yield* updates.pipe(
Stream.take(1),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const creates = yield* Effect.suspend(() =>
fs.remove(target, { recursive: true, force: true }).pipe(Effect.andThen(fs.ensureDir(target))),
).pipe(Effect.repeat(Schedule.spaced("10 millis")), Effect.forkScoped)
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(creates)))
const file = path.join(directory, "plain.txt")
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
}),
),
)
expect(event.valueOrUndefined?.path).toBe(target)
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
it.live("ignores dependency, VCS, and build directories at any depth", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
yield* ready(directory)
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
const files = roots.map((root) => path.join(root, "package", "index.js"))
yield* noUpdate(
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
concurrency: "unbounded",
discard: true,
}),
)
}),
{ vcs: "git" },
),
)
it.live("cleanup stops publishing events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* ready(tmp.path).pipe(
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
Effect.scoped,
)
const file = path.join(tmp.path, "after-dispose.txt")
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))),
)
it.live("ignores .git/index changes", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const index = path.join(directory, ".git", "index")
yield* ready(directory)
yield* noUpdate(
(event) => event.file === index,
fs
.writeFileString(path.join(directory, "tracked.txt"), "a")
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
)
}),
{ vcs: "git" },
),
)
@@ -341,11 +374,11 @@ describeNative("LocationWatcher", () => {
const fs = yield* FSUtil.Service
const head = path.join(directory, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* ready(head)
yield* ready(directory)
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toEqual({ file: head, event: "change" })
).toMatchObject({ file: head })
}),
{ vcs: "git" },
),
@@ -360,8 +393,8 @@ describeNative("LocationWatcher", () => {
const afs = yield* FSUtil.Service
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
yield* ready(directory)
const head = path.join(directory, ".git", "HEAD")
yield* ready(head, path.join(actual, "HEAD"))
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
@@ -389,7 +422,7 @@ describeNative("LocationWatcher", () => {
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
yield* ready(directory)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
@@ -1,169 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
import { it } from "./effect"
export interface EnvironmentHarness {
readonly files: Files
readonly root: string
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
readonly dispose?: Effect.Effect<void>
}
export const environmentConformance = <E>(
name: string,
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
skip = false,
) => {
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
it.live(title, () =>
Effect.gen(function* () {
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
)
const bytes = (value: string) => new TextEncoder().encode(value)
const text = (value: Uint8Array) => new TextDecoder().decode(value)
const suite = skip ? describe.skip : describe
suite(name, () => {
check("writes, stats, and reads a file with its info", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/hello.txt`
yield* harness.files.write(target, bytes("hello"))
const result = yield* harness.files.read(target)
expect(text(result.bytes)).toBe("hello")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(5)
expect(yield* harness.files.stat(target)).toEqual(result.info)
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
}),
)
check("reports the actual kind", (harness) =>
Effect.gen(function* () {
const directory = `${harness.root}/directory`
const file = `${harness.root}/file`
yield* harness.files.mkdir(directory)
yield* harness.files.write(file, bytes("data"))
const readError = yield* Effect.flip(harness.files.read(directory))
const listError = yield* Effect.flip(harness.files.list(file))
expect(readError).toBeInstanceOf(WrongKind)
expect((readError as WrongKind).actual).toBe("directory")
expect(listError).toBeInstanceOf(WrongKind)
expect((listError as WrongKind).actual).toBe("file")
}),
)
check("write creates parent directories", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/one/two/file`
yield* harness.files.write(target, bytes("nested"))
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
}),
)
check("reads byte ranges", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/range`
yield* harness.files.write(target, bytes("0123456789"))
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
}),
)
check("lists immediate entries with their kinds", (harness) =>
Effect.gen(function* () {
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
const entries = yield* harness.files.list(harness.root)
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
{ name: "directory", type: "directory" },
{ name: "file name", type: "file" },
])
}),
)
check("preserves symlink metadata while following symlinks for content", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
yield* harness.symlink("../target", `${harness.root}/target-dir/entry-link`)
yield* harness.symlink("target", `${harness.root}/link`)
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
expect(
(yield* harness.files.list(`${harness.root}/link-dir`)).toSorted((a, b) => a.name.localeCompare(b.name)),
).toEqual([
{ name: "entry-link", type: "symlink" },
{ name: "file", type: "file" },
])
const fileError = yield* Effect.flip(harness.files.list(`${harness.root}/link`))
expect(fileError).toBeInstanceOf(WrongKind)
expect((fileError as WrongKind).actual).toBe("file")
expect(yield* Effect.flip(harness.files.list(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
}),
)
check("follows symlinks when reading", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.symlink("target", `${harness.root}/file-link`)
yield* harness.symlink("directory", `${harness.root}/directory-link`)
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
const result = yield* harness.files.read(`${harness.root}/file-link`)
expect(text(result.bytes)).toBe("target content")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(bytes("target content").length)
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
expect(directoryError).toBeInstanceOf(WrongKind)
expect((directoryError as WrongKind).actual).toBe("directory")
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
}),
)
check("moves files and removes trees idempotently", (harness) =>
Effect.gen(function* () {
const source = `${harness.root}/source/file`
const destination = `${harness.root}/destination`
yield* harness.files.write(source, bytes("moved"))
yield* harness.files.move(source, destination)
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
yield* harness.files.remove(`${harness.root}/source`)
yield* harness.files.remove(`${harness.root}/source`)
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
}),
)
})
}
@@ -13,7 +13,6 @@ import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -128,34 +127,23 @@ describe("SessionExecution lifecycle", () => {
it.effect("resumes each suspended Session at most once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const first = Session.ID.make("ses_resume_first")
const second = Session.ID.make("ses_resume_second")
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
const drained: string[] = []
const continued: SessionEvent.Synthetic[] = []
const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
yield* restart.resumeSuspendedSessions
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([first, second])
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
[first, second].map((sessionID) => ({
sessionID,
text: "The server restarted while you were working. Continue from where you left off without repeating completed work.",
description: "Continuing after restart",
})),
)
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
yield* restart.resumeSuspendedSessions
expect(drained.length).toBe(2)
expect(continued.length).toBe(2)
yield* Scope.close(scope, Exit.void)
}),
)
-74
View File
@@ -1086,78 +1086,4 @@ describe("Session.pending", () => {
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
it.effect("cancels pending input and allows its ID to be admitted again", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
yield* session.cancelPending({ sessionID, inputID })
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
}),
)
it.effect("moves pending input between steer and queue delivery", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const queued = yield* session.synthetic({
sessionID,
text: "Steer this",
delivery: "queue",
resume: false,
})
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
wakeCalls.length = 0
yield* session.steerPending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([sessionID])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
wakeCalls.length = 0
yield* session.queuePending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "queue" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
expect(
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
}),
)
})
+17 -218
View File
@@ -6,10 +6,11 @@ import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -24,15 +25,8 @@ const discovery = Layer.succeed(
},
}),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
[SkillDiscovery.node, discovery],
[Watcher.node, watcherLayer],
]),
watcherLayer,
),
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [[SkillDiscovery.node, discovery]]),
)
function write(directory: string, name: string, description: string) {
@@ -59,24 +53,6 @@ function waitForSkillUpdate() {
})
}
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
})
}
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
})
}
describe("Skill", () => {
it.live("publishes updates when skill sources change", () =>
Effect.gen(function* () {
@@ -113,7 +89,6 @@ describe("Skill", () => {
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => {
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(first) })
@@ -144,21 +119,6 @@ describe("Skill", () => {
content: "# review",
},
])
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
yield* Effect.promise(() => write(second, "review", "Updated Second"))
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
}),
),
),
@@ -238,7 +198,7 @@ metadata:
),
)
it.live("clears cached skills when sources reload", () =>
it.live("invalidates cached skills and publishes updates for watcher changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -250,187 +210,26 @@ metadata:
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
let refreshed: Skill.Info[] = []
const unsubscribe = yield* bus.listen((event) => {
if (event.type !== Skill.Event.Updated.type) return Effect.void
return skill.list().pipe(
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
Effect.asVoid,
)
})
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* skill.reload().pipe(Effect.timeout("1 second"))
yield* unsubscribe
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
expect(yield* watcher.subscriptions()).toEqual([
{ path: tmp.path, type: "directory" },
{ path: tmp.path, type: "directory" },
])
}),
),
),
)
it.live("reloads project sources created after their missing parent", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const file = path.join(source, "deploy", "SKILL.md")
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file), { recursive: true })
await write(source, "deploy", "Deploy production")
})
yield* emitAndWait({ type: "create", path: source })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
{ path: source, type: "directory" },
])
}),
),
),
)
it.live("watches directory sources for added and changed skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
const file = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review changes")
})
const review = path.join(tmp.path, "review", "SKILL.md")
yield* emitAndWait({ type: "create", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) =>
bus
.publish(FileSystem.Event.Changed, { file, event: "change" })
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"))
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("invalidates symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source)
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: source, type: "file" },
])
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy")
}),
),
),
+33 -68
View File
@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
@@ -23,15 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [
Tool.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
Location.node,
Permission.node,
],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_edit_tool_test")
@@ -80,28 +72,29 @@ const reset = () => {
formatFile = () => Effect.succeed(false)
}
const environment = Layer.effect(
Environment.Service,
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const current = yield* Environment.Service
return Environment.Service.of({
...current,
files: {
...current.files,
read: (target, range) =>
current.files
.read(target, range)
.pipe(
Effect.tap((result) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
),
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readFile: (target) =>
fs
.readFile(target)
.pipe(
Effect.tap((content) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
),
write: (target, content) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
},
),
writeWithDirs: (target, content, mode) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
writeFile: (target, content, options) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))),
writeFileString: (target, content, options) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
@@ -113,9 +106,15 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
LayerNode.group([
Tool.node,
Tool.node,
LocationMutation.node,
FileMutation.node,
editToolNode,
]),
[
[Environment.node, environment],
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
@@ -472,7 +471,10 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
yield* executeTool(
registry,
call({ path: "missing.ts", oldString: "before", newString: "after" }),
),
).toEqual({
status: "error",
error: { type: "tool.execution", message: "File not found: missing.ts" },
@@ -643,43 +645,6 @@ describe("EditTool", () => {
),
)
it.live("serializes concurrent edit transactions", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "concurrent.txt")
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.all(
[
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
),
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
),
],
{ concurrency: "unbounded" },
),
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("applies the edit when content changes after matching", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+42 -65
View File
@@ -2,12 +2,11 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { systemError } from "effect/PlatformError"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -82,33 +81,48 @@ const reset = () => {
formatFile = () => Effect.succeed(false)
}
const environment = Layer.effect(
Environment.Service,
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const current = yield* Environment.Service
return Environment.Service.of({
...current,
files: {
...current.files,
read: (target, range) =>
Effect.sync(() => {
if (!editApproved) readsBeforeEditApproval++
}).pipe(Effect.andThen(current.files.read(target, range))),
remove: (target) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
return current.files.remove(target)
},
write: (target, content) => {
if (failWriteTarget && path.basename(target) === failWriteTarget)
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
return current.files.write(target, content)
},
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readFile: (target) =>
Effect.sync(() => {
if (!editApproved) readsBeforeEditApproval++
}).pipe(Effect.andThen(fs.readFile(target))),
remove: (target, options) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
return Effect.fail(
systemError({
_tag: "Unknown",
module: "FileSystem",
method: "remove",
description: "forced remove failure",
pathOrDescriptor: target,
}),
)
}
return fs.remove(target, options)
},
writeWithDirs: (target, content, mode) => {
if (failWriteTarget && path.basename(target) === failWriteTarget) {
return Effect.fail(
systemError({
_tag: "Unknown",
module: "FileSystem",
method: "writeWithDirs",
description: "forced write failure",
pathOrDescriptor: target,
}),
)
}
return fs.writeWithDirs(target, content, mode)
},
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(
directory: string,
@@ -125,8 +139,8 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
[Environment.node, environment],
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
@@ -248,43 +262,6 @@ describe("PatchTool", () => {
),
)
it.live("serializes concurrent patch transactions", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "concurrent.txt")
afterEditApproval = () =>
assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
Effect.all(
[
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
"call-patch-one",
),
),
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
"call-patch-two",
),
),
],
{ concurrency: "unbounded" },
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
}),
)
it.live("returns file diffs for final formatted content", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "formatted.txt")
+51 -161
View File
@@ -1,65 +1,66 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Environment } from "@opencode-ai/core/environment"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, FileSystem } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem])))
const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])))
const fixture = Effect.gen(function* () {
const fs = yield* FSUtil.Service
const files = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = yield* files.makeTempDirectoryScoped()
return { environment: Environment.makeFiles(Environment.makeLocalDriver(spawner)), files, directory }
return { fs, files, directory }
})
const absolute = (value: string) => AbsolutePath.make(value)
describe("ReadToolFileSystem", () => {
it.effect("preserves the environment not-found error", () =>
it.effect("fails with a typed filesystem error when a resolved file disappears", () =>
Effect.gen(function* () {
const { environment, directory } = yield* fixture
const { fs, directory } = yield* fixture
const file = path.join(directory, "missing.txt")
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "missing.txt").pipe(Effect.flip)
const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip)
expect(error).toBeInstanceOf(Environment.NotFound)
expect(error).toMatchObject({ _tag: "PlatformError" })
}),
)
it.effect("returns a listing when read reports a directory", () =>
it.effect("fails when a file becomes the wrong path kind", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
const { fs, directory } = yield* fixture
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip)
expect(result).toMatchObject({
type: "list-page",
entries: [
{ path: `folder${path.sep}`, type: "directory" },
{ path: "file.txt", type: "file" },
],
})
expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError)
}),
)
it.effect("fails with a typed filesystem error when directory listing fails", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "file.txt")
yield* files.writeFileString(file, "hello")
const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip)
expect(error).toBeInstanceOf(FSUtil.FileSystemError)
if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries")
}),
)
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const binary = path.join(directory, "archive.dat")
const malformed = path.join(directory, "malformed.txt")
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
const binaryError = yield* ReadToolFileSystem.read(environment, absolute(binary), "archive.dat").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(environment, absolute(malformed), "malformed.txt")
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
@@ -69,11 +70,11 @@ describe("ReadToolFileSystem", () => {
it.effect("reads text despite a binary-associated extension", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "notes.docx")
yield* files.writeFileString(file, "plain text")
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "notes.docx")
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
}),
@@ -82,17 +83,15 @@ describe("ReadToolFileSystem", () => {
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { environment, files, directory } = yield* fixture
const { fs: service, files, directory } = yield* fixture
const outside = yield* files.makeTempDirectoryScoped()
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
const result = yield* ReadToolFileSystem.list(service, directory)
expect(result.type).toBe("list-page")
if (result.type !== "list-page") return
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
{ path: `folder${path.sep}`, type: "directory" },
{ path: "broken", type: "symlink" },
@@ -102,154 +101,45 @@ describe("ReadToolFileSystem", () => {
}),
)
it.effect("reads a symlinked directory as a listing", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { environment, files, directory } = yield* fixture
const target = path.join(directory, "target")
const link = path.join(directory, "link")
yield* files.makeDirectory(target)
yield* files.writeFileString(path.join(target, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(target, link))
const result = yield* ReadToolFileSystem.read(environment, absolute(link), "link")
expect(result).toMatchObject({
type: "list-page",
entries: [{ path: "file.txt", type: "file" }],
})
}),
)
it.effect("reports out-of-range pagination as a typed error", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "short.txt")
yield* files.writeFileString(file, "one\n")
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "short.txt", { offset: 2 }).pipe(
Effect.flip,
)
const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip)
expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError)
expect(error.message).toBe("Offset 2 is out of range")
}),
)
it.effect("pages text with one-based offsets", () =>
it.effect("stops reading after the requested page is complete", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "lines.txt")
yield* files.writeFileString(file, "one\r\ntwo\nthree")
const { fs, files, directory } = yield* fixture
const prefix = new TextEncoder().encode("one\n")
for (const [name, trailing] of [
["malformed.txt", 0x80],
["nul.txt", 0],
] as const) {
const file = path.join(directory, name)
yield* files.writeFile(file, Uint8Array.from([...prefix, trailing]))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "lines.txt", {
offset: 2,
limit: 1,
})
const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 })
expect(result).toMatchObject({ type: "text-page", content: "two", offset: 2, truncated: true, next: 3 })
}),
)
it.effect("truncates long lines", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "long.txt")
yield* files.writeFileString(file, "a".repeat(2_001))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "long.txt", { limit: 1 })
expect(result).toMatchObject({
type: "text-page",
content: `${"a".repeat(2_000)}... (line truncated to 2000 chars)`,
truncated: false,
})
}),
)
it.effect("enforces line and byte budgets with continuation offsets", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const linesFile = path.join(directory, "many-lines.txt")
const bytesFile = path.join(directory, "many-bytes.txt")
yield* files.writeFileString(linesFile, Array.from({ length: 2_001 }, (_, index) => String(index)).join("\n"))
yield* files.writeFileString(bytesFile, Array.from({ length: 200 }, () => "a".repeat(2_000)).join("\n"))
const ranges: Array<{ readonly offset: number; readonly length: number } | undefined> = []
const tracked = {
...environment,
read: (path: string, range?: { readonly offset: number; readonly length: number }) =>
Effect.sync(() => ranges.push(range)).pipe(Effect.andThen(environment.read(path, range))),
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
}
const lines = yield* ReadToolFileSystem.read(environment, absolute(linesFile), "many-lines.txt", { limit: 2_000 })
const bytes = yield* ReadToolFileSystem.read(tracked, absolute(bytesFile), "many-bytes.txt", {})
expect(lines).toMatchObject({ type: "text-page", truncated: true, next: 2_001 })
expect(lines.type === "text-page" ? lines.content.split("\n") : []).toHaveLength(2_000)
expect(bytes).toMatchObject({ type: "text-page", truncated: true, next: 26 })
expect(bytes.type === "text-page" ? Buffer.byteLength(bytes.content) : Infinity).toBeLessThanOrEqual(
ReadToolFileSystem.MAX_READ_BYTES,
)
expect(ranges).toEqual([{ offset: 0, length: 256 * 1024 }])
}),
)
it.effect("sorts and pages directory entries", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
yield* files.makeDirectory(path.join(directory, "z"))
yield* files.makeDirectory(path.join(directory, "a"))
yield* files.writeFileString(path.join(directory, "b.txt"), "")
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({
type: "list-page",
entries: [{ path: `z${path.sep}`, type: "directory" }],
truncated: true,
next: 3,
})
}),
)
it.effect("stops checking for null bytes after the requested page", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "nul.txt")
yield* files.writeFile(file, Uint8Array.from([...new TextEncoder().encode("one\n"), 0]))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "nul.txt", { limit: 1 })
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
}),
)
it.effect("reads page two after fetching more than the first 256KB range", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "large.txt")
yield* files.writeFileString(file, `${"a".repeat(300 * 1024)}\nsecond\n`)
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "large.txt", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({ type: "text-page", content: "second", offset: 2, truncated: false })
}),
)
it.effect("preserves the media ingestion limit message", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "oversized.png")
yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1)
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "oversized.png").pipe(Effect.flip)
const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip)
expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError)
expect(error.message).toBe(
@@ -260,11 +150,11 @@ describe("ReadToolFileSystem", () => {
it.effect("reads PDFs as bounded media", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "document.pdf")
yield* files.writeFileString(file, "%PDF-1.7\ncontent")
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "document.pdf")
const result = yield* ReadToolFileSystem.read(fs, file, "document.pdf")
expect(result).toMatchObject({
type: "file",
+48 -23
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, Stream } from "effect"
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigMedia } from "@opencode-ai/schema/config/media"
@@ -21,7 +21,6 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { Environment } from "@opencode-ai/core/environment"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
@@ -43,13 +42,24 @@ const readToolNode = makeLocationNode({
const assertions: Permission.AssertInput[] = []
const missingPath = "__missing_read_target__.txt"
const missingAbsolutePath = path.join(process.cwd(), missingPath)
const notFound = (target: string) =>
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "stat",
pathOrDescriptor: target,
})
const readCalls: {
input: AbsolutePath
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: ReadToolFileSystem.PageInput[] = []
let listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
let resolvedType: "file" | "directory" = "file"
let resolveFailure: unknown
let inspectFailure: ReadToolFileSystem.InspectError | undefined
let directoryEntries: string[] = []
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
type: "file",
uri: "file:///README.md",
name: "README.md",
@@ -61,12 +71,22 @@ let readFailure: ReadToolFileSystem.ReadError | undefined
const reader = Layer.succeed(
ReadToolFileSystem.Service,
ReadToolFileSystem.Service.of({
inspect: () =>
resolveFailure !== undefined
? Effect.die(resolveFailure)
: inspectFailure !== undefined
? Effect.fail(inspectFailure)
: Effect.succeed(resolvedType),
read: (input, _resource, page = {}) => {
readCalls.push({ input, page })
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
if (readFailure !== undefined) return Effect.fail(readFailure)
return Effect.succeed(readResult)
},
list: (_path, input = {}) =>
Effect.sync(() => {
listCalls.push(input)
return listResult
}),
}),
)
let allow = true
@@ -105,6 +125,17 @@ const testFileSystem = Layer.effect(
FSUtil.Service.of({
...fs,
readDirectory: () => Effect.succeed(directoryEntries),
realPath: (path) =>
path === missingAbsolutePath
? Effect.fail(
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "realPath",
pathOrDescriptor: path,
}),
)
: Effect.succeed(path),
}),
),
),
@@ -164,8 +195,11 @@ describe("ReadTool", () => {
beforeEach(() => {
assertions.length = 0
readCalls.length = 0
listCalls.length = 0
allow = true
resolvedType = "file"
resolveFailure = undefined
inspectFailure = undefined
directoryEntries = []
readResult = {
type: "file",
@@ -176,6 +210,7 @@ describe("ReadTool", () => {
mime: "text/plain",
}
readFailure = undefined
listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
})
it.effect("registers, authorizes, and reads through the location filesystem", () =>
@@ -637,7 +672,7 @@ describe("ReadTool", () => {
it.effect("returns missing paths as model-visible tool failures", () =>
Effect.gen(function* () {
readFailure = new Environment.NotFound({ path: missingAbsolutePath })
inspectFailure = notFound(missingAbsolutePath)
directoryEntries = [
"__missing_read_target__.txt.bak",
"copy___missing_read_target__.txt",
@@ -661,18 +696,14 @@ describe("ReadTool", () => {
},
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }])
expect(readCalls).toEqual([
{
input: AbsolutePath.make(missingAbsolutePath),
page: { offset: undefined, limit: undefined },
},
])
expect(readCalls).toEqual([])
}),
)
it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () {
readResult = new ReadToolFileSystem.ListPage({
resolvedType = "directory"
listResult = new ReadToolFileSystem.ListPage({
type: "list-page",
entries: [
FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }),
@@ -695,7 +726,7 @@ describe("ReadTool", () => {
})
expect(result).toMatchObject({
status: "completed",
output: { entries: readResult.entries, truncated: true, next: 4 },
output: { entries: listResult.entries, truncated: true, next: 4 },
})
if (result.status !== "completed") return
expect(result.metadata).toEqual({ truncated: true })
@@ -706,15 +737,14 @@ describe("ReadTool", () => {
},
])
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 2, limit: 10 } },
])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}),
)
it.effect("does not list a directory when permission is denied", () =>
Effect.gen(function* () {
allow = false
resolvedType = "directory"
const registry = yield* Tool.Service
expect(
@@ -724,7 +754,7 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}),
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
expect(readCalls).toEqual([])
expect(listCalls).toEqual([])
}),
)
@@ -743,12 +773,7 @@ describe("ReadTool", () => {
),
).toBe(true)
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
page: { offset: undefined, limit: undefined },
},
])
expect(readCalls).toEqual([])
}),
)
+19 -6
View File
@@ -5,8 +5,8 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
@@ -24,12 +24,19 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [
Tool.node,
FSUtil.node,
Ripgrep.node,
Location.node,
LocationMutation.node,
Permission.node,
],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
})
const sessionID = Session.ID.make("ses_search_tool_test")
@@ -179,7 +186,9 @@ describe("search tools", () => {
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result).toMatchObject({
@@ -288,7 +297,9 @@ describe("search tools", () => {
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
withTools(tmp.path, (registry) =>
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
),
Effect.tap((result) =>
Effect.sync(() => {
@@ -320,7 +331,9 @@ describe("search tools", () => {
Effect.sync(() => {
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
expect(assertions[0]?.resources).toEqual([
path.join(outside.path, "*").replaceAll("\\", "/"),
])
}),
),
)
+1 -2
View File
@@ -524,8 +524,7 @@ describe("ShellTool", () => {
const content = settled.content?.[0]
if (!content || content.type !== "text") throw new Error("Expected text content")
expect(content.text).not.toContain("one")
// Windows shells emit CRLF; the assertion targets line limits, not line endings.
expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
expect(content.text).toStartWith("two\nthree")
expect(content.text).toContain("output truncated; full output saved to:")
})
},
+11 -14
View File
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
@@ -68,20 +68,17 @@ const reset = () => {
denyAction = undefined
}
const environment = Layer.effect(
Environment.Service,
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const current = yield* Environment.Service
return Environment.Service.of({
...current,
files: {
...current.files,
write: (target, content) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
},
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
writeWithDirs: (target, content, mode) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
@@ -95,7 +92,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
[
[Environment.node, environment],
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
-26
View File
@@ -1,26 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/merman",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./markdown": "./src/markdown.ts",
"./plugin": "./src/plugin.ts"
},
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
@@ -1,97 +0,0 @@
import {
TextBufferRenderable,
type ColorInput,
type RenderContext,
type RGBA,
type StyledText,
type TextBufferOptions,
} from "@opentui/core"
import type { DiagramCanvas, DiagramCanvasTextOptions } from "../canvas.js"
import { setDiagramRenderableColor } from "./renderable-color.js"
import { DiagramRenderablePipeline } from "./renderable-pipeline.js"
interface DiagramRenderableOptions<Diagram, Grid extends DiagramCanvas<any, any>> {
parse: () => Diagram
draw: (diagram: Diagram) => Grid
publish: (grid: Grid) => StyledText
measure?: DiagramCanvasTextOptions
}
export abstract class DiagramRenderable<Diagram, Grid extends DiagramCanvas<any, any>> extends TextBufferRenderable {
private _content: string
private _renderedWidth = 0
private _renderedHeight = 0
private _pipeline?: DiagramRenderablePipeline<Diagram, Grid>
protected constructor(ctx: RenderContext, options: TextBufferOptions & { content?: string }) {
super(ctx, { ...options, wrapMode: options.wrapMode ?? "none" })
this._content = options.content ?? ""
}
protected initializeDiagram(options: DiagramRenderableOptions<Diagram, Grid>): void {
this._pipeline = new DiagramRenderablePipeline({
parse: options.parse,
draw: options.draw,
didDraw: (grid) => {
const size = grid.getTextSize(options.measure)
this._renderedWidth = size.width
this._renderedHeight = size.height
},
publish: (grid) => {
this.textBuffer.setStyledText(options.publish(grid))
this.updateTextInfo()
},
})
this._pipeline.invalidateParsedDiagram()
}
get content(): string {
return this._content
}
set content(value: string) {
if (this._content === value) return
this._content = value
this.contentChanged()
this.pipeline.invalidateParsedDiagram()
}
get renderedWidth(): number {
return this._renderedWidth
}
get renderedHeight(): number {
return this._renderedHeight
}
batchUpdate(update: () => void): void {
this.pipeline.batchUpdate(update)
}
protected contentChanged(): void {}
protected parsedDiagram(): Diagram {
return this.pipeline.diagram()
}
protected invalidateGrid(): void {
this.pipeline.invalidateGrid()
}
protected invalidateStyle(): void {
this.pipeline.invalidateStyle()
}
protected setColor(
current: RGBA | undefined,
value: ColorInput | undefined,
assign: (color: RGBA | undefined) => void,
): void {
setDiagramRenderableColor(current, value, assign, () => this.invalidateStyle())
}
private get pipeline(): DiagramRenderablePipeline<Diagram, Grid> {
if (!this._pipeline) throw new Error("Diagram renderable was not initialized")
return this._pipeline
}
}
@@ -1,18 +0,0 @@
import { parseColor, type ColorInput, type RGBA } from "@opentui/core"
import { colorsEqual } from "../color/style.js"
export function parseDiagramRenderableColor(value: ColorInput | undefined): RGBA | undefined {
return value ? parseColor(value) : undefined
}
export function setDiagramRenderableColor(
current: RGBA | undefined,
value: ColorInput | undefined,
assign: (color: RGBA | undefined) => void,
invalidate: () => void,
): void {
const next = parseDiagramRenderableColor(value)
if (colorsEqual(current, next)) return
assign(next)
invalidate()
}
@@ -1,48 +0,0 @@
import { describe, expect, test } from "bun:test"
import { DiagramRenderablePipeline } from "./renderable-pipeline.js"
describe("DiagramRenderablePipeline", () => {
test("caches the parsed diagram and grid across style repaints", () => {
let parseCount = 0
let drawCount = 0
let publishCount = 0
const pipeline = new DiagramRenderablePipeline({
parse: () => ({ version: ++parseCount }),
draw: (diagram) => ({ version: diagram.version, draw: ++drawCount }),
publish: () => {
publishCount += 1
},
})
pipeline.invalidateParsedDiagram()
pipeline.invalidateStyle()
pipeline.invalidateGrid()
expect({ parseCount, drawCount, publishCount }).toEqual({ parseCount: 1, drawCount: 2, publishCount: 3 })
})
test("reparses source changes and coalesces nested invalidations", () => {
const events: string[] = []
const pipeline = new DiagramRenderablePipeline({
parse: () => {
events.push("parse")
return {}
},
draw: (diagram) => {
events.push("draw")
return diagram
},
publish: () => events.push("publish"),
})
pipeline.invalidateParsedDiagram()
events.length = 0
pipeline.batchUpdate(() => {
pipeline.invalidateStyle()
pipeline.batchUpdate(() => pipeline.invalidateGrid())
pipeline.invalidateParsedDiagram()
})
expect(events).toEqual(["parse", "draw", "publish"])
})
})
@@ -1,68 +0,0 @@
export interface DiagramRenderablePipelineOptions<Diagram, Grid> {
parse: () => Diagram
draw: (diagram: Diagram) => Grid
publish: (grid: Grid) => void
didDraw?: (grid: Grid) => void
}
type DiagramRenderableInvalidation = "grid" | "style"
export class DiagramRenderablePipeline<Diagram, Grid> {
private _diagram?: Diagram
private _grid?: Grid
private _batchDepth = 0
private _pending?: DiagramRenderableInvalidation
constructor(private readonly options: DiagramRenderablePipelineOptions<Diagram, Grid>) {}
diagram(): Diagram {
this._diagram ??= this.options.parse()
return this._diagram
}
batchUpdate(update: () => void): void {
this._batchDepth += 1
try {
update()
} finally {
this._batchDepth -= 1
if (this._batchDepth === 0 && this._pending) {
const pending = this._pending
this._pending = undefined
this.render(pending)
}
}
}
invalidateParsedDiagram(): void {
this._diagram = undefined
this.invalidateGrid()
}
invalidateGrid(): void {
this._grid = undefined
this.invalidate("grid")
}
invalidateStyle(): void {
this.invalidate("style")
}
private invalidate(level: DiagramRenderableInvalidation): void {
if (this._batchDepth > 0) {
if (level === "grid" || !this._pending) this._pending = level
return
}
this.render(level)
}
private render(level: DiagramRenderableInvalidation): void {
let grid = this._grid
if (level === "grid" || !grid) {
grid = this.options.draw(this.diagram())
this._grid = grid
this.options.didDraw?.(grid)
}
this.options.publish(grid)
}
}
-92
View File
@@ -1,92 +0,0 @@
import { describe, expect, test } from "bun:test"
import stringWidth from "string-width"
import { DiagramCanvas, type DiagramCanvasCell } from "./canvas.js"
describe("DiagramCanvas", () => {
test("writes cells and text while clipping out-of-bounds positions", () => {
const canvas = new DiagramCanvas<"label">(5, 2)
canvas.setCell(0, 0, "A", "label")
canvas.setCell(9, 0, "X", "label")
canvas.setText(2, 1, "hey", "label")
expect(canvas.toString()).toBe("A\n hey")
})
test("uses measured character widths for text placement", () => {
const canvas = new DiagramCanvas<"label">(5, 1)
canvas.setText(0, 0, "a界b", "label")
expect(canvas.toString()).toBe("a界b")
expect(stringWidth(canvas.toString())).toBe(4)
})
test("preserves combined graphemes while placing later text", () => {
const canvas = new DiagramCanvas<"label">(4, 1)
canvas.setText(0, 0, "e\u0301x", "label")
expect(canvas.toString()).toBe("e\u0301x")
expect(stringWidth(canvas.toString())).toBe(2)
})
test("merges cells through the adapter-provided merge function", () => {
type Style = "line"
const canvas = new DiagramCanvas<Style>(3, 1, {
mergeCell: (existing, incoming): DiagramCanvasCell<Style> => ({
...incoming,
char: existing.char === "─" && incoming.char === "│" ? "┼" : incoming.char,
}),
})
canvas.setCell(1, 0, "─", "line")
canvas.setCell(1, 0, "│", "line")
expect(canvas.toString()).toBe(" ┼")
})
test("iterates style and metadata runs", () => {
interface Metadata {
stateId?: string
}
const canvas = new DiagramCanvas<"state", Metadata>(4, 1)
const runs: string[] = []
canvas.setText(0, 0, "AB", "state", { stateId: "A" })
canvas.setText(2, 0, "CD", "state", { stateId: "B" })
canvas.forEachRun(
(run) => runs.push(`${run.text}:${run.style}:${run.cell.stateId}`),
() => runs.push("newline"),
{ key: (cell) => [cell.style, cell.stateId] },
)
expect(runs).toEqual(["AB:state:A", "CD:state:B"])
})
test("can trim bottom whitespace for renderers with dynamic height", () => {
const canvas = new DiagramCanvas<"label">(3, 3)
const runs: string[] = []
canvas.setText(0, 0, "top", "label")
expect(canvas.toString()).toBe("top\n\n")
expect(canvas.toString({ trimBottom: true })).toBe("top")
expect(canvas.getTextSize()).toEqual({ width: 3, height: 3 })
expect(canvas.getTextSize({ trimBottom: true })).toEqual({ width: 3, height: 1 })
canvas.forEachRun(
(run) => runs.push(run.text),
() => runs.push("newline"),
{ trimBottom: true },
)
expect(runs).toEqual(["top"])
})
test("can trim unused leading whitespace reserved by layout", () => {
const canvas = new DiagramCanvas<"label">(3, 3)
canvas.setText(0, 2, "end", "label")
expect(canvas.toString({ trimTop: true })).toBe("end")
expect(canvas.getTextSize({ trimTop: true })).toEqual({ width: 3, height: 1 })
})
})
-169
View File
@@ -1,169 +0,0 @@
import stringWidth from "string-width"
import { diagramTextGraphemes } from "./text.js"
export type DiagramCanvasCell<Style extends string, Metadata extends object = object> = {
char: string
style?: Style
} & Partial<Metadata>
export interface DiagramCanvasRun<Style extends string, Metadata extends object = object> {
text: string
style: Style | undefined
cell: DiagramCanvasCell<Style, Metadata>
}
export interface DiagramCanvasOptions<Style extends string, Metadata extends object = object> {
measure?: (text: string) => number
mergeCell?: (
existing: DiagramCanvasCell<Style, Metadata>,
incoming: DiagramCanvasCell<Style, Metadata>,
) => DiagramCanvasCell<Style, Metadata>
}
export interface DiagramCanvasTextOptions {
trimTop?: boolean
trimBottom?: boolean
}
export interface DiagramCanvasTextSize {
width: number
height: number
}
export type DiagramCanvasTextMetadata<Metadata extends object> =
| Partial<Metadata>
| ((x: number, y: number) => Partial<Metadata>)
export interface DiagramCanvasRunOptions<Style extends string, Metadata extends object = object> {
key?: (cell: DiagramCanvasCell<Style, Metadata>) => readonly unknown[]
trimTop?: boolean
trimBottom?: boolean
}
function createEmptyCell<Style extends string, Metadata extends object>(): DiagramCanvasCell<Style, Metadata> {
return { char: " " } as DiagramCanvasCell<Style, Metadata>
}
function sameKey(left: readonly unknown[] | undefined, right: readonly unknown[]): boolean {
return Boolean(left && left.length === right.length && left.every((value, index) => Object.is(value, right[index])))
}
export class DiagramCanvas<Style extends string, Metadata extends object = object> {
readonly rows: Array<Array<DiagramCanvasCell<Style, Metadata>>>
private readonly measure: (text: string) => number
private readonly mergeCell?: DiagramCanvasOptions<Style, Metadata>["mergeCell"]
constructor(
readonly width: number,
readonly height: number,
options: DiagramCanvasOptions<Style, Metadata> = {},
) {
this.measure = options.measure ?? stringWidth
this.mergeCell = options.mergeCell
this.rows = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
}
private rowTextEnd(row: Array<DiagramCanvasCell<Style, Metadata>>): number {
let rowEnd = row.length
while (rowEnd > 0 && row[rowEnd - 1]?.char === " ") rowEnd -= 1
return rowEnd
}
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd = this.rowTextEnd(row)): string {
return row
.slice(0, rowEnd)
.map((cell) => cell.char)
.join("")
}
private textRowRange(trimTop: boolean, trimBottom: boolean): { start: number; end: number } {
let start = 0
let end = this.rows.length
if (trimTop) while (start < end && this.rowTextEnd(this.rows[start]!) === 0) start += 1
if (trimBottom) while (end > start && this.rowTextEnd(this.rows[end - 1]!) === 0) end -= 1
return { start, end }
}
setCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
if (y < 0 || y >= this.rows.length || x < 0 || x >= this.rows[y]!.length) return
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
this.rows[y]![x] = this.mergeCell?.(this.rows[y]![x]!, incoming) ?? incoming
}
getCell(x: number, y: number): DiagramCanvasCell<Style, Metadata> | undefined {
return this.rows[y]?.[x]
}
setText(x: number, y: number, text: string, style?: Style, metadata?: DiagramCanvasTextMetadata<Metadata>): void {
let offset = 0
for (const grapheme of diagramTextGraphemes(text)) {
const width = Math.max(1, this.measure(grapheme))
const metadataAt = (cellX: number) => (typeof metadata === "function" ? metadata(cellX, y) : metadata)
this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
for (let continuation = 1; continuation < width; continuation++) {
this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))
}
offset += width
}
}
toString(options: DiagramCanvasTextOptions = {}): string {
const lines: string[] = []
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
lines.push(this.rowText(this.rows[rowIndex]!))
}
return lines.join("\n")
}
getTextSize(options: DiagramCanvasTextOptions = {}): DiagramCanvasTextSize {
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
let width = 0
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
const row = this.rows[rowIndex]!
const rowEnd = this.rowTextEnd(row)
if (rowEnd > 0) width = Math.max(width, this.measure(this.rowText(row, rowEnd)))
}
return { width, height: rows.end - rows.start }
}
forEachRun(
onRun: (run: DiagramCanvasRun<Style, Metadata>) => void,
onLineEnd: () => void,
options: DiagramCanvasRunOptions<Style, Metadata> = {},
): void {
const key = options.key
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
const row = this.rows[rowIndex]!
const rowEnd = this.rowTextEnd(row)
let currentCell: DiagramCanvasCell<Style, Metadata> | undefined
let currentKey: readonly unknown[] | undefined
let currentText = ""
const flush = () => {
if (!currentText || !currentCell) return
onRun({ text: currentText, style: currentCell.style, cell: currentCell })
currentText = ""
}
for (let x = 0; x < rowEnd; x++) {
const cell = row[x]!
const nextKey = key?.(cell)
const sameRun = currentCell && (key ? sameKey(currentKey, nextKey!) : currentCell.style === cell.style)
if (!sameRun) {
flush()
currentCell = cell
currentKey = nextKey
}
currentText += cell.char
}
flush()
if (rowIndex < rows.end - 1) onLineEnd()
}
}
}
-59
View File
@@ -1,59 +0,0 @@
import { parseColor, RGBA, type ColorInput } from "@opentui/core"
import { colorsEqual } from "./style.js"
export type DiagramColorMapInput = Record<string, ColorInput | undefined> | ReadonlyMap<string, ColorInput | undefined>
export const DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR = "::cell:"
export const DIAGRAM_CELL_COLOR_LEVEL_COUNT = 6
export function diagramCellColorKey(id: string, level: number): string {
const normalizedLevel = Math.max(0, Math.min(DIAGRAM_CELL_COLOR_LEVEL_COUNT - 1, Math.round(level)))
return `${id}${DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR}${normalizedLevel}`
}
export function diagramRadialCellColorLevel(
bounds: { width: number; height: number; centerX: number; centerY: number },
x: number,
y: number,
border = false,
): number {
const halfWidth = Math.max(1, (bounds.width - 1) / 2)
const halfHeight = Math.max(1, (bounds.height - 1) / 2)
const dx = (x - bounds.centerX) / halfWidth
const dy = (y - bounds.centerY) / halfHeight
const distance = Math.sqrt(dx * dx + dy * dy)
const level = Math.round((1 - Math.min(1, distance)) * (DIAGRAM_CELL_COLOR_LEVEL_COUNT - 1))
return border ? Math.min(1, level) : level
}
export function baseDiagramCellColorKey(id: string): string {
const index = id.lastIndexOf(DIAGRAM_CELL_COLOR_LEVEL_SEPARATOR)
return index === -1 ? id : id.slice(0, index)
}
export function mappedDiagramColor(
colors: ReadonlyMap<string, RGBA> | undefined,
id: string | undefined,
): RGBA | undefined {
return id ? (colors?.get(id) ?? colors?.get(baseDiagramCellColorKey(id))) : undefined
}
export function normalizeDiagramColorMap(value: DiagramColorMapInput | undefined): Map<string, RGBA> {
const colors = new Map<string, RGBA>()
if (!value) return colors
const entries = value instanceof Map ? value.entries() : Object.entries(value)
for (const [id, color] of entries) {
if (color !== undefined) colors.set(id, parseColor(color))
}
return colors
}
export function diagramColorMapsEqual(left: ReadonlyMap<string, RGBA>, right: ReadonlyMap<string, RGBA>): boolean {
if (left.size !== right.size) return false
for (const [id, color] of left) {
if (!colorsEqual(color, right.get(id))) return false
}
return true
}
@@ -1,82 +0,0 @@
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import {
baseDiagramCellColorKey,
diagramCellColorKey,
diagramColorMapsEqual,
diagramRadialCellColorLevel,
mappedDiagramColor,
normalizeDiagramColorMap,
} from "./map.js"
import {
ansiBg,
ansiFg,
blendColor,
brightenColor,
createAnsiRampTheme,
mixRgb,
numberedStyleKeys,
rgba,
} from "./style.js"
describe("diagram style helpers", () => {
test("mixes rgb values and emits truecolor ANSI", () => {
expect(mixRgb([0, 10, 20], [10, 30, 60], 0.5)).toEqual([5, 20, 40])
expect(ansiFg([1, 2, 3])).toBe("\x1b[38;2;1;2;3m")
expect(ansiBg([4, 5, 6])).toBe("\x1b[48;2;4;5;6m")
})
test("converts rgb tuples and blends optional RGBA values", () => {
const black = RGBA.fromInts(0, 0, 0, 255)
const white = RGBA.fromInts(10, 20, 30, 255)
expect(rgba([1, 2, 3]).equals(RGBA.fromInts(1, 2, 3, 255))).toBe(true)
expect(blendColor(black, white, 0.5).equals(RGBA.fromInts(5, 10, 15, 255))).toBe(true)
expect(blendColor(undefined, white, 0.5)?.equals(white)).toBe(true)
expect(blendColor(undefined, undefined, 0.5)).toBeUndefined()
})
test("brightens colors toward white", () => {
const color = brightenColor(RGBA.fromInts(100, 150, 200, 255), 0.5)
expect(color?.equals(RGBA.fromInts(178, 203, 228, 255))).toBe(true)
expect(brightenColor(undefined)).toBeUndefined()
})
test("creates numbered style keys and ANSI ramps", () => {
const styles = numberedStyleKeys("requestFade", [1, 2, 3] as const)
const theme = createAnsiRampTheme(styles, [0, 0, 0], [12, 24, 36])
expect(styles).toEqual(["requestFade1", "requestFade2", "requestFade3"])
expect(theme.requestFade1).toBe("\x1b[38;2;3;6;9m")
expect(theme.requestFade2).toBe("\x1b[38;2;6;12;18m")
expect(theme.requestFade3).toBe("\x1b[38;2;9;18;27m")
})
test("normalizes and resolves leveled diagram color maps", () => {
const red = RGBA.fromInts(255, 0, 0, 255)
const blue = RGBA.fromInts(0, 0, 255, 255)
const colors = normalizeDiagramColorMap({
A: red,
B: undefined,
[diagramCellColorKey("C", 2)]: blue,
})
expect(colors.size).toBe(2)
expect(diagramCellColorKey("A", -1)).toBe("A::cell:0")
expect(diagramCellColorKey("A", 10)).toBe("A::cell:5")
expect(baseDiagramCellColorKey("A::cell:3")).toBe("A")
expect(mappedDiagramColor(colors, "A")?.equals(red)).toBe(true)
expect(mappedDiagramColor(colors, "C::cell:2")?.equals(blue)).toBe(true)
expect(mappedDiagramColor(colors, "C::cell:4")).toBeUndefined()
expect(diagramColorMapsEqual(colors, normalizeDiagramColorMap(new Map(colors)))).toBe(true)
})
test("computes radial diagram cell color levels", () => {
const bounds = { width: 9, height: 5, centerX: 4, centerY: 2 }
expect(diagramRadialCellColorLevel(bounds, 4, 2)).toBe(5)
expect(diagramRadialCellColorLevel(bounds, 0, 0)).toBe(0)
expect(diagramRadialCellColorLevel(bounds, 4, 2, true)).toBe(1)
})
})
-93
View File
@@ -1,93 +0,0 @@
import { RGBA } from "@opentui/core"
export type DiagramRgb = readonly [number, number, number]
export type DiagramFadeStep = 1 | 2 | 3 | 4 | 5
export const DIAGRAM_FADE_STEPS = [1, 2, 3, 4, 5] as const satisfies readonly DiagramFadeStep[]
export function numberedStyleKeys<Prefix extends string, Step extends number>(
prefix: Prefix,
steps: readonly Step[],
): Array<`${Prefix}${Step}`> {
return steps.map((step) => `${prefix}${step}` as `${Prefix}${Step}`)
}
export function mixChannel(left: number, right: number, amount: number): number {
return Math.round(left + (right - left) * amount)
}
export function mixRgb(left: DiagramRgb, right: DiagramRgb, amount: number): DiagramRgb {
return [
mixChannel(left[0], right[0], amount),
mixChannel(left[1], right[1], amount),
mixChannel(left[2], right[2], amount),
]
}
export function ansiFg(rgb: DiagramRgb): string {
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
}
export function ansiBg(rgb: DiagramRgb): string {
return `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
}
export function rgba(rgb: DiagramRgb): RGBA {
return RGBA.fromInts(rgb[0], rgb[1], rgb[2], 255)
}
export function blendColor(from: RGBA, to: RGBA, amount: number): RGBA
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined {
if (!from && !to) return undefined
if (!from) return to
if (!to) return from
const [fromR, fromG, fromB, fromA] = from.toInts()
const [toR, toG, toB, toA] = to.toInts()
const mix = (left: number, right: number) => left + (right - left) * amount
return RGBA.fromInts(mix(fromR, toR), mix(fromG, toG), mix(fromB, toB), mix(fromA, toA))
}
export function colorsEqual(left?: RGBA, right?: RGBA): boolean {
if (!left || !right) return left === right
return left.equals(right)
}
export function brightenColor(color: RGBA | undefined, amount: number = 0.35): RGBA | undefined {
if (!color) return undefined
const [r, g, b, a] = color.toInts()
return RGBA.fromInts(mixChannel(r, 255, amount), mixChannel(g, 255, amount), mixChannel(b, 255, amount), a)
}
export function createAnsiRampTheme<Style extends string>(
styles: readonly Style[],
from: DiagramRgb,
to: DiagramRgb,
): Record<Style, string> {
return Object.fromEntries(
styles.map((style, index) => [style, ansiFg(mixRgb(from, to, (index + 1) / (styles.length + 1)))]),
) as Record<Style, string>
}
export function createColorRampTheme<Style extends string>(
styles: readonly Style[],
from: RGBA,
to: RGBA,
): Record<Style, RGBA>
export function createColorRampTheme<Style extends string>(
styles: readonly Style[],
from: RGBA | undefined,
to: RGBA | undefined,
): Record<Style, RGBA | undefined>
export function createColorRampTheme<Style extends string>(
styles: readonly Style[],
from: RGBA | undefined,
to: RGBA | undefined,
): Record<Style, RGBA | undefined> {
return Object.fromEntries(
styles.map((style, index) => [style, blendColor(from, to, (index + 1) / (styles.length + 1))]),
) as Record<Style, RGBA | undefined>
}
-116
View File
@@ -1,116 +0,0 @@
import { describe, expect, test } from "bun:test"
import { BorderChars } from "@opentui/core"
import { DiagramCanvas } from "./canvas.js"
import {
diagramArrowHead,
drawDiagramDiamond,
drawDiagramFrame,
drawOrthogonalPath,
mergeDiagramLineGlyph,
} from "./drawing.js"
describe("diagram drawing", () => {
test("merges line glyphs with square and rounded corners", () => {
expect(mergeDiagramLineGlyph("─", "│")).toBe("┼")
expect(mergeDiagramLineGlyph("─", "│", "rounded")).toBe("┼")
expect(mergeDiagramLineGlyph("─", "╭", "rounded")).toBe("┬")
expect(mergeDiagramLineGlyph("a", "│")).toBeUndefined()
})
test("draws frames through caller-provided cell writers", () => {
const canvas = new DiagramCanvas<"frame">(8, 4)
drawDiagramFrame(
{ left: 1, top: 0, width: 6, height: 4, centerX: 4, centerY: 2 },
BorderChars.rounded,
(x, y, char) => canvas.setCell(x, y, char, "frame"),
)
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n │ │\n ╰────╯")
})
test("draws diamond frames through caller-provided cell writers", () => {
const canvas = new DiagramCanvas<"frame">(9, 5)
drawDiagramDiamond({ left: 0, top: 0, width: 9, height: 5, centerX: 4, centerY: 2 }, (x, y, char) =>
canvas.setCell(x, y, char, "frame"),
)
expect(canvas.toString({ trimBottom: true })).toBe(" ╭───╮\n╭─╯ ╰─╮\n│ │\n╰─╮ ╭─╯\n ╰───╯")
expect(canvas.toString()).not.toMatch(/[╱╲\\/]/)
})
test("draws orthogonal paths with selected corner style", () => {
const canvas = new DiagramCanvas<"edge">(7, 4)
drawOrthogonalPath(
[
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 3, y: 2 },
{ x: 6, y: 2 },
],
(x, y, char) => canvas.setCell(x, y, char, "edge"),
{ cornerStyle: "rounded" },
)
expect(canvas.toString({ trimBottom: true })).toBe("───╮\n │\n ╰──")
})
test("draws orthogonal paths with heavy line style", () => {
const canvas = new DiagramCanvas<"edge">(7, 4)
drawOrthogonalPath(
[
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 3, y: 2 },
{ x: 6, y: 2 },
],
(x, y, char) => canvas.setCell(x, y, char, "edge"),
{ lineStyle: "heavy" },
)
expect(canvas.toString({ trimBottom: true })).toBe("━━━┓\n ┃\n ┗━━")
})
test("draws orthogonal paths with dashed line style", () => {
const canvas = new DiagramCanvas<"edge">(9, 1)
drawOrthogonalPath(
[
{ x: 0, y: 0 },
{ x: 8, y: 0 },
],
(x, y, char) => canvas.setCell(x, y, char, "edge"),
{ lineStyle: "dashed" },
)
expect(canvas.toString({ trimBottom: true })).toBe("─ ─ ─ ─")
})
test("keeps container frame policy separate from edge drawing policy", () => {
const canvas = new DiagramCanvas<"group" | "edge">(8, 5, {
mergeCell: (existing, incoming) => {
if (incoming.style === "edge") return incoming
return existing.char === " " ? incoming : existing
},
})
drawDiagramFrame(
{ left: 1, top: 0, width: 6, height: 5, centerX: 4, centerY: 2 },
BorderChars.rounded,
(x, y, char) => canvas.setCell(x, y, char, "group"),
)
drawOrthogonalPath(
[
{ x: 0, y: 2 },
{ x: 7, y: 2 },
],
(x, y, char) => canvas.setCell(x, y, char, "edge"),
)
canvas.setCell(7, 2, "▶", "edge")
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n───────▶\n │ │\n ╰────╯")
})
test("selects filled and line arrow heads", () => {
expect(diagramArrowHead("right")).toBe("▶")
expect(diagramArrowHead("left", "line")).toBe("←")
})
})
-295
View File
@@ -1,295 +0,0 @@
import { BorderChars, type BorderCharacters } from "@opentui/core"
import {
directionBetween,
walkOrthogonalSegment,
type DiagramBounds,
type DiagramDirection,
type DiagramPoint,
} from "./geometry.js"
export type DiagramLineCornerStyle = "square" | "rounded"
export type DiagramLineStyle = "single" | "heavy" | "dashed"
export type DiagramArrowHeadStyle = "filled" | "line"
export interface DiagramDiamondCharacters {
topLeft: string
topRight: string
upperLeft: string
upperLeftJoin: string
upperRightJoin: string
upperRight: string
vertical: string
lowerLeft: string
lowerLeftJoin: string
lowerRightJoin: string
lowerRight: string
bottomLeft: string
bottomRight: string
horizontal: string
}
export const DIAGRAM_ARROW_HEADS = new Set(["▶", "◀", "▼", "▲", "→", "←", "↓", "↑"])
const HEAVY_LINE_GLYPHS = new Set(Object.values(BorderChars.heavy))
export const DIAGRAM_DIAMOND_CHARS = {
topLeft: "╭",
topRight: "╮",
upperLeft: "╭",
upperLeftJoin: "╯",
upperRightJoin: "╰",
upperRight: "╮",
vertical: "│",
lowerLeft: "╰",
lowerLeftJoin: "╮",
lowerRightJoin: "╭",
lowerRight: "╯",
bottomLeft: "╰",
bottomRight: "╯",
horizontal: "─",
} as const satisfies DiagramDiamondCharacters
export function diagramDiamondCharactersFromBorder(chars: BorderCharacters): DiagramDiamondCharacters {
return {
topLeft: chars.topLeft,
topRight: chars.topRight,
upperLeft: chars.topLeft,
upperLeftJoin: chars.bottomRight,
upperRightJoin: chars.bottomLeft,
upperRight: chars.topRight,
vertical: chars.vertical,
lowerLeft: chars.bottomLeft,
lowerLeftJoin: chars.topRight,
lowerRightJoin: chars.topLeft,
lowerRight: chars.bottomRight,
bottomLeft: chars.bottomLeft,
bottomRight: chars.bottomRight,
horizontal: chars.horizontal,
}
}
function lineDirections(char: string): readonly DiagramDirection[] | undefined {
switch (char) {
case "─":
return ["left", "right"]
case "│":
return ["up", "down"]
case "━":
return ["left", "right"]
case "┃":
return ["up", "down"]
case "┌":
case "╭":
case "┏":
return ["right", "down"]
case "┐":
case "╮":
case "┓":
return ["left", "down"]
case "└":
case "╰":
case "┗":
return ["up", "right"]
case "┘":
case "╯":
case "┛":
return ["up", "left"]
case "├":
case "┣":
return ["up", "down", "right"]
case "┤":
case "┫":
return ["up", "down", "left"]
case "┬":
case "┳":
return ["left", "right", "down"]
case "┴":
case "┻":
return ["left", "right", "up"]
case "┼":
case "╋":
return ["up", "down", "left", "right"]
default:
return undefined
}
}
export function diagramLineGlyph(
directions: ReadonlySet<DiagramDirection>,
cornerStyle: DiagramLineCornerStyle = "square",
lineStyle: DiagramLineStyle = "single",
): string {
const up = directions.has("up")
const down = directions.has("down")
const left = directions.has("left")
const right = directions.has("right")
if (lineStyle === "heavy") {
const chars = BorderChars.heavy
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
}
if (up && down && left && right) return "┼"
if (up && down && right) return "├"
if (up && down && left) return "┤"
if (left && right && down) return "┬"
if (left && right && up) return "┴"
if (up && right) return cornerStyle === "rounded" ? "╰" : "└"
if (up && left) return cornerStyle === "rounded" ? "╯" : "┘"
if (down && right) return cornerStyle === "rounded" ? "╭" : "┌"
if (down && left) return cornerStyle === "rounded" ? "╮" : "┐"
if (up || down) return "│"
return "─"
}
function isHeavyLineGlyph(char: string): boolean {
return HEAVY_LINE_GLYPHS.has(char)
}
function segmentGlyph(direction: DiagramDirection, lineStyle: DiagramLineStyle | undefined): string {
const directions = new Set<DiagramDirection>(
direction === "left" || direction === "right" ? ["left", "right"] : ["up", "down"],
)
return diagramLineGlyph(directions, "square", lineStyle === "heavy" ? "heavy" : "single")
}
export function mergeDiagramLineGlyph(
existing: string,
incoming: string,
cornerStyle: DiagramLineCornerStyle = "square",
): string | undefined {
const existingDirections = lineDirections(existing)
const incomingDirections = lineDirections(incoming)
if (!existingDirections || !incomingDirections) return undefined
return diagramLineGlyph(
new Set([...existingDirections, ...incomingDirections]),
cornerStyle,
isHeavyLineGlyph(existing) && isHeavyLineGlyph(incoming) ? "heavy" : "single",
)
}
export function diagramArrowHead(direction: DiagramDirection, style: DiagramArrowHeadStyle = "filled"): string {
if (style === "line") {
if (direction === "right") return "→"
if (direction === "left") return "←"
if (direction === "up") return "↑"
return "↓"
}
if (direction === "right") return "▶"
if (direction === "left") return "◀"
if (direction === "up") return "▲"
return "▼"
}
export function diagramArrowHeadBetween(
from: DiagramPoint,
to: DiagramPoint,
style: DiagramArrowHeadStyle = "filled",
): string {
const direction = directionBetween(from, to)
return direction ? diagramArrowHead(direction, style) : diagramArrowHead("right", style)
}
export function drawDiagramFrame(
bounds: DiagramBounds,
chars: BorderCharacters,
setCell: (x: number, y: number, char: string) => void,
): void {
setCell(bounds.left, bounds.top, chars.topLeft)
setCell(bounds.left + bounds.width - 1, bounds.top, chars.topRight)
setCell(bounds.left, bounds.top + bounds.height - 1, chars.bottomLeft)
setCell(bounds.left + bounds.width - 1, bounds.top + bounds.height - 1, chars.bottomRight)
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
setCell(x, bounds.top, chars.horizontal)
setCell(x, bounds.top + bounds.height - 1, chars.horizontal)
}
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
setCell(bounds.left, y, chars.vertical)
setCell(bounds.left + bounds.width - 1, y, chars.vertical)
}
}
export function drawDiagramDiamond(
bounds: DiagramBounds,
setCell: (x: number, y: number, char: string) => void,
chars: DiagramDiamondCharacters = DIAGRAM_DIAMOND_CHARS,
): void {
const left = bounds.left
const right = bounds.left + bounds.width - 1
const top = bounds.top
const bottom = bounds.top + bounds.height - 1
const capInset = Math.min(2, Math.max(1, Math.floor((bounds.width - 1) / 2)))
const capLeft = left + capInset
const capRight = right - capInset
setCell(capLeft, top, chars.topLeft)
for (let x = capLeft + 1; x < capRight; x++) setCell(x, top, chars.horizontal)
setCell(capRight, top, chars.topRight)
setCell(left, top + 1, chars.upperLeft)
for (let x = left + 1; x < capLeft; x++) setCell(x, top + 1, chars.horizontal)
setCell(capLeft, top + 1, chars.upperLeftJoin)
setCell(capRight, top + 1, chars.upperRightJoin)
for (let x = capRight + 1; x < right; x++) setCell(x, top + 1, chars.horizontal)
setCell(right, top + 1, chars.upperRight)
for (let y = top + 2; y < bottom - 1; y++) {
setCell(left, y, chars.vertical)
setCell(right, y, chars.vertical)
}
setCell(left, bottom - 1, chars.lowerLeft)
for (let x = left + 1; x < capLeft; x++) setCell(x, bottom - 1, chars.horizontal)
setCell(capLeft, bottom - 1, chars.lowerLeftJoin)
setCell(capRight, bottom - 1, chars.lowerRightJoin)
for (let x = capRight + 1; x < right; x++) setCell(x, bottom - 1, chars.horizontal)
setCell(right, bottom - 1, chars.lowerRight)
setCell(capLeft, bottom, chars.bottomLeft)
for (let x = capLeft + 1; x < capRight; x++) setCell(x, bottom, chars.horizontal)
setCell(capRight, bottom, chars.bottomRight)
}
export function drawOrthogonalPath(
points: readonly DiagramPoint[],
setCell: (x: number, y: number, char: string) => void,
options: { cornerStyle?: DiagramLineCornerStyle; lineStyle?: DiagramLineStyle } = {},
): void {
for (let index = 1; index < points.length; index++) {
const from = points[index - 1]!
const to = points[index]!
const direction = directionBetween(from, to)
if (!direction) continue
const glyph = segmentGlyph(direction, options.lineStyle)
let step = index === 1 ? 0 : 1
walkOrthogonalSegment(from, to, index === 1, (point) => {
if (options.lineStyle !== "dashed" || step % 2 === 0) setCell(point.x, point.y, glyph)
step += 1
})
}
for (let index = 1; index < points.length - 1; index++) {
const previous = points[index - 1]!
const current = points[index]!
const next = points[index + 1]!
const fromDirection = directionBetween(current, previous)
const toDirection = directionBetween(current, next)
const directions = new Set<DiagramDirection>()
if (fromDirection) directions.add(fromDirection)
if (toDirection) directions.add(toDirection)
setCell(
current.x,
current.y,
diagramLineGlyph(directions, options.cornerStyle, options.lineStyle === "heavy" ? "heavy" : "single"),
)
}
}
-112
View File
@@ -1,112 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
diagramBoundsFromBounds,
diagramBoundsFromPoints,
diagramBoundsFromRect,
boundsSidePoint,
directionBetween,
lane,
orthogonalPath,
orthogonalPathPoints,
pathThrough,
pathViaLane,
point,
translateDiagramBounds,
walkOrthogonalSegment,
} from "./geometry.js"
describe("diagram geometry", () => {
test("directions are only defined for orthogonal point pairs", () => {
expect(directionBetween(point(1, 2), point(5, 2))).toBe("right")
expect(directionBetween(point(1, 2), point(1, 0))).toBe("up")
expect(directionBetween(point(1, 2), point(5, 4))).toBeUndefined()
expect(directionBetween(point(1, 2), point(1, 2))).toBeUndefined()
})
test("bounds side points describe border and outside ports", () => {
const bounds = { left: 10, top: 4, width: 8, height: 5, centerX: 14, centerY: 6 }
expect(boundsSidePoint(bounds, "left", "border")).toEqual(point(10, 6))
expect(boundsSidePoint(bounds, "left")).toEqual(point(9, 6))
expect(boundsSidePoint(bounds, "right", "border")).toEqual(point(17, 6))
expect(boundsSidePoint(bounds, "right")).toEqual(point(18, 6))
expect(boundsSidePoint(bounds, "top", "border")).toEqual(point(14, 4))
expect(boundsSidePoint(bounds, "bottom")).toEqual(point(14, 9))
})
test("bounds helpers create, translate, and union bounds", () => {
const bounds = diagramBoundsFromRect(2, 3, 5, 4)
expect(bounds).toEqual({ left: 2, top: 3, width: 5, height: 4, centerX: 4, centerY: 5 })
translateDiagramBounds(bounds, 3, -1)
expect(bounds).toEqual({ left: 5, top: 2, width: 5, height: 4, centerX: 7, centerY: 4 })
expect(diagramBoundsFromBounds([bounds, diagramBoundsFromRect(0, 0, 2, 2)])).toEqual({
left: 0,
top: 0,
width: 10,
height: 6,
centerX: 5,
centerY: 3,
})
expect(diagramBoundsFromPoints([point(2, 2), point(4, 5)])).toEqual({
left: 2,
top: 2,
width: 3,
height: 4,
centerX: 3,
centerY: 4,
})
})
test("paths compose through lanes while removing duplicate joints", () => {
expect(pathThrough([point(0, 0), point(0, 0), point(3, 0)])).toEqual([point(0, 0), point(3, 0)])
expect(pathViaLane(point(0, 0), lane("x", 4), point(8, 3))).toEqual([
point(0, 0),
point(4, 0),
point(4, 3),
point(8, 3),
])
})
test("composed paths do not alias caller-owned points", () => {
const start = point(0, 0)
const end = point(8, 3)
const firstPath = pathViaLane(start, lane("x", 4), end)
const secondPath = pathViaLane(start, lane("x", 5), end)
expect(firstPath[0]).not.toBe(start)
expect(firstPath[firstPath.length - 1]).not.toBe(end)
expect(firstPath[0]).not.toBe(secondPath[0])
firstPath[0]!.x += 10
firstPath[firstPath.length - 1]!.y += 10
expect(start).toEqual(point(0, 0))
expect(end).toEqual(point(8, 3))
expect(secondPath[0]).toEqual(point(0, 0))
expect(secondPath[secondPath.length - 1]).toEqual(point(8, 3))
})
test("orthogonal paths choose a terminal lane on the dominant axis", () => {
expect(orthogonalPath(point(0, 0), point(10, 4))).toEqual([point(0, 0), point(6, 0), point(6, 4), point(10, 4)])
})
test("orthogonal segment walkers exclude endpoints", () => {
const visited: Array<{ x: number; y: number }> = []
walkOrthogonalSegment(point(0, 0), point(3, 0), false, (next) => {
visited.push(next)
})
expect(visited).toEqual([point(1, 0), point(2, 0)])
})
test("orthogonal path points include endpoints without duplicating joints", () => {
expect(orthogonalPathPoints([point(0, 0), point(3, 0), point(3, 2)])).toEqual([
point(0, 0),
point(1, 0),
point(2, 0),
point(3, 0),
point(3, 1),
point(3, 2),
])
})
})
-347
View File
@@ -1,347 +0,0 @@
export type DiagramAxis = "x" | "y"
export type DiagramDirection = "up" | "down" | "left" | "right"
export type DiagramSide = "left" | "right" | "top" | "bottom"
export interface DiagramPoint {
x: number
y: number
}
export interface DiagramBounds {
left: number
top: number
width: number
height: number
centerX: number
centerY: number
}
export interface DiagramSegment {
from: DiagramPoint
to: DiagramPoint
axis: DiagramAxis
direction: DiagramDirection
length: number
}
export interface DiagramLane {
axis: DiagramAxis
coordinate: number
}
export interface DiagramSpan {
start: number
end: number
}
const DIRECTION_AXIS = {
left: "x",
right: "x",
up: "y",
down: "y",
} as const satisfies Record<DiagramDirection, DiagramAxis>
const DIRECTION_SIGN = {
left: -1,
right: 1,
up: -1,
down: 1,
} as const satisfies Record<DiagramDirection, -1 | 1>
const DIRECTION_SIDE = {
left: "left",
right: "right",
up: "top",
down: "bottom",
} as const satisfies Record<DiagramDirection, DiagramSide>
const OPPOSITE_SIDE = {
left: "right",
right: "left",
top: "bottom",
bottom: "top",
} as const satisfies Record<DiagramSide, DiagramSide>
export function point(x: number, y: number): DiagramPoint {
return { x, y }
}
export function diagramBoundsFromRect(left: number, top: number, width: number, height: number): DiagramBounds {
return {
left,
top,
width,
height,
centerX: left + Math.floor(width / 2),
centerY: top + Math.floor(height / 2),
}
}
export function translateDiagramBounds(bounds: DiagramBounds, dx: number, dy: number): void {
bounds.left += dx
bounds.top += dy
bounds.centerX += dx
bounds.centerY += dy
}
export function diagramBoundsFromBounds(bounds: readonly DiagramBounds[]): DiagramBounds | undefined {
if (bounds.length === 0) return undefined
const left = Math.min(...bounds.map((bound) => bound.left))
const top = Math.min(...bounds.map((bound) => bound.top))
const right = Math.max(...bounds.map((bound) => bound.left + bound.width))
const bottom = Math.max(...bounds.map((bound) => bound.top + bound.height))
return diagramBoundsFromRect(left, top, right - left, bottom - top)
}
export function diagramBoundsFromPoints(points: readonly DiagramPoint[]): DiagramBounds | undefined {
if (points.length === 0) return undefined
const left = Math.min(...points.map((point) => point.x))
const top = Math.min(...points.map((point) => point.y))
const right = Math.max(...points.map((point) => point.x))
const bottom = Math.max(...points.map((point) => point.y))
return diagramBoundsFromRect(left, top, right - left + 1, bottom - top + 1)
}
export function coordinate(point: DiagramPoint, axis: DiagramAxis): number {
return point[axis]
}
export function withCoordinate(point: DiagramPoint, axis: DiagramAxis, value: number): DiagramPoint {
return axis === "x" ? { x: value, y: point.y } : { x: point.x, y: value }
}
export function shiftPoint(point: DiagramPoint, direction: DiagramDirection, distance = 1): DiagramPoint {
switch (direction) {
case "left":
return { x: point.x - distance, y: point.y }
case "right":
return { x: point.x + distance, y: point.y }
case "up":
return { x: point.x, y: point.y - distance }
case "down":
return { x: point.x, y: point.y + distance }
}
}
export function clampPoint(point: DiagramPoint, min: DiagramPoint = { x: 0, y: 0 }): DiagramPoint {
return { x: Math.max(min.x, point.x), y: Math.max(min.y, point.y) }
}
export function samePoint(left: DiagramPoint, right: DiagramPoint): boolean {
return left.x === right.x && left.y === right.y
}
export function directionAxis(direction: DiagramDirection): DiagramAxis {
return DIRECTION_AXIS[direction]
}
export function directionSign(direction: DiagramDirection): -1 | 1 {
return DIRECTION_SIGN[direction]
}
export function sideForDirection(direction: DiagramDirection): DiagramSide {
return DIRECTION_SIDE[direction]
}
export function oppositeSide(side: DiagramSide): DiagramSide {
return OPPOSITE_SIDE[side]
}
export function directionBetween(from: DiagramPoint, to: DiagramPoint): DiagramDirection | undefined {
if (from.y === to.y) {
if (to.x > from.x) return "right"
if (to.x < from.x) return "left"
}
if (from.x === to.x) {
if (to.y > from.y) return "down"
if (to.y < from.y) return "up"
}
return undefined
}
export function boundsCenter(bounds: DiagramBounds): DiagramPoint {
return point(bounds.centerX, bounds.centerY)
}
export function boundsSidePoint(
bounds: DiagramBounds,
side: DiagramSide,
surface: "border" | "outside" = "outside",
): DiagramPoint {
switch (side) {
case "left":
return point(bounds.left - (surface === "outside" ? 1 : 0), bounds.centerY)
case "right":
return point(bounds.left + bounds.width - (surface === "outside" ? 0 : 1), bounds.centerY)
case "top":
return point(bounds.centerX, bounds.top - (surface === "outside" ? 1 : 0))
case "bottom":
return point(bounds.centerX, bounds.top + bounds.height - (surface === "outside" ? 0 : 1))
}
}
export function centerCoordinate(bounds: DiagramBounds, axis: DiagramAxis): number {
return axis === "x" ? bounds.centerX : bounds.centerY
}
export function snapCoordinate(source: number, target: number, tolerance: number): number {
return Math.abs(source - target) <= tolerance ? target : source
}
export function pathThrough(points: readonly DiagramPoint[]): DiagramPoint[] {
const path: DiagramPoint[] = []
for (const next of points) {
if (!path.length || !samePoint(path[path.length - 1]!, next)) path.push(point(next.x, next.y))
}
return path
}
export function lane(axis: DiagramAxis, coordinate: number): DiagramLane {
return { axis, coordinate }
}
export function pathViaLane(start: DiagramPoint, routeLane: DiagramLane, end: DiagramPoint): DiagramPoint[] {
return pathThrough([
start,
withCoordinate(start, routeLane.axis, routeLane.coordinate),
withCoordinate(end, routeLane.axis, routeLane.coordinate),
end,
])
}
function dominantAxis(start: DiagramPoint, end: DiagramPoint): DiagramAxis {
return Math.abs(end.x - start.x) >= Math.abs(end.y - start.y) ? "x" : "y"
}
function terminalLane(start: DiagramPoint, end: DiagramPoint, axis: DiagramAxis, terminalClearance: number): number {
const startCoordinate = coordinate(start, axis)
const endCoordinate = coordinate(end, axis)
const delta = endCoordinate - startCoordinate
const sign = Math.sign(delta)
if (sign === 0) return endCoordinate
return endCoordinate - sign * Math.min(terminalClearance, Math.max(1, Math.abs(delta) - 1))
}
export function orthogonalPath(
start: DiagramPoint,
end: DiagramPoint,
options: { preferredAxis?: DiagramAxis; terminalClearance?: number } = {},
): DiagramPoint[] {
if (start.x === end.x || start.y === end.y) return pathThrough([start, end])
const laneAxis = options.preferredAxis ?? dominantAxis(start, end)
return pathViaLane(start, lane(laneAxis, terminalLane(start, end, laneAxis, options.terminalClearance ?? 4)), end)
}
export function segmentBetween(from: DiagramPoint, to: DiagramPoint): DiagramSegment | undefined {
const direction = directionBetween(from, to)
if (!direction) return undefined
return {
from,
to,
axis: directionAxis(direction),
direction,
length: Math.abs(coordinate(to, directionAxis(direction)) - coordinate(from, directionAxis(direction))),
}
}
export function walkOrthogonalSegment(
from: DiagramPoint,
to: DiagramPoint,
includeStart: boolean,
visit: (point: DiagramPoint) => boolean | void,
): void {
const direction = directionBetween(from, to)
if (!direction) return
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
let cursor = includeStart ? from : point(from.x + dx, from.y + dy)
while (!samePoint(cursor, to)) {
if (visit(cursor) === false) return
cursor = point(cursor.x + dx, cursor.y + dy)
}
}
export function orthogonalPathPoints(points: readonly DiagramPoint[]): DiagramPoint[] {
const path: DiagramPoint[] = []
for (let index = 1; index < points.length; index++) {
const from = points[index - 1]!
const to = points[index]!
const direction = directionBetween(from, to)
if (!direction) continue
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
let cursor = path.length === 0 ? point(from.x, from.y) : point(from.x + dx, from.y + dy)
while (true) {
path.push(cursor)
if (samePoint(cursor, to)) break
cursor = point(cursor.x + dx, cursor.y + dy)
}
}
return path
}
export function orderedSpan(left: number, right: number): DiagramSpan {
return left <= right ? { start: left, end: right } : { start: right, end: left }
}
export function segmentSpan(segment: DiagramSegment): DiagramSpan {
return orderedSpan(coordinate(segment.from, segment.axis), coordinate(segment.to, segment.axis))
}
export function pointOnSegment(segment: DiagramSegment, coordinateValue: number): DiagramPoint {
return withCoordinate(segment.from, segment.axis, coordinateValue)
}
export function insetSpan(span: DiagramSpan, amount: number): DiagramSpan {
return { start: span.start + amount, end: span.end - amount }
}
export function spanCapacity(span: DiagramSpan): number {
return Math.max(0, span.end - span.start + 1)
}
export function centeredSpanStart(span: DiagramSpan, width: number): number {
return span.start + Math.floor((spanCapacity(span) - width) / 2)
}
export function midpoint(span: DiagramSpan): number {
return Math.round((span.start + span.end) / 2)
}
export function advanceCoordinate(origin: number, direction: DiagramDirection, distance: number): number {
return origin + directionSign(direction) * distance
}
export function beforeNearestCoordinate(
points: readonly DiagramPoint[],
axis: DiagramAxis,
direction: DiagramDirection,
clearance: number,
): number {
const coordinates = points.map((point) => coordinate(point, axis))
const nearest = directionSign(direction) > 0 ? Math.min(...coordinates) : Math.max(...coordinates)
return advanceCoordinate(nearest, direction, -clearance)
}
export function afterFarthestCoordinate(
points: readonly DiagramPoint[],
axis: DiagramAxis,
direction: DiagramDirection,
clearance: number,
): number {
const coordinates = points.map((point) => coordinate(point, axis))
const farthest = directionSign(direction) > 0 ? Math.max(...coordinates) : Math.min(...coordinates)
return advanceCoordinate(farthest, direction, clearance)
}
export function keepBefore(preferred: number, boundary: number, direction: DiagramDirection): number {
return directionSign(direction) > 0 ? Math.min(preferred, boundary) : Math.max(preferred, boundary)
}
export function keepAfter(preferred: number, boundary: number, direction: DiagramDirection): number {
return directionSign(direction) > 0 ? Math.max(preferred, boundary) : Math.min(preferred, boundary)
}
-33
View File
@@ -1,33 +0,0 @@
export interface NumberedMermaidLine {
readonly lineNumber: number
readonly text: string
}
export function* numberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
const lines = content.split(/\r?\n/)
for (let index = 0; index < lines.length; index += 1) {
yield { lineNumber: index + 1, text: lines[index]!.trim() }
}
}
export function* meaningfulNumberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
for (const line of numberedMermaidLines(content)) {
if (line.text && !line.text.startsWith("%%")) yield line
}
}
export function firstMeaningfulMermaidLine(content: string): string | undefined {
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (line && !line.startsWith("%%")) return line
}
return undefined
}
export function stripMermaidQuotes(value: string): string {
const trimmed = value.trim()
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1)
}
return trimmed
}
-54
View File
@@ -1,54 +0,0 @@
import { StyledText, type TextChunk } from "@opentui/core"
import type { DiagramCanvas, DiagramCanvasRun, DiagramCanvasRunOptions } from "./canvas.js"
import { ANSI } from "./terminal/ansi.js"
export interface RenderDiagramGridAnsiOptions<
Style extends string,
Metadata extends object = object,
> extends DiagramCanvasRunOptions<Style, Metadata> {
trimOutputEnd?: boolean
}
export function renderDiagramGridAnsi<Style extends string, Metadata extends object = object>(
grid: DiagramCanvas<Style, Metadata>,
styleAnsi: (run: DiagramCanvasRun<Style, Metadata>) => string | undefined,
options: RenderDiagramGridAnsiOptions<Style, Metadata> = {},
): string {
const { trimOutputEnd, ...runOptions } = options
const output: string[] = []
grid.forEachRun(
(run) => {
const ansi = styleAnsi(run)
output.push(ansi ? `${ansi}${run.text}${ANSI.reset}` : run.text)
},
() => {
output.push("\n")
},
runOptions,
)
const text = output.join("")
return trimOutputEnd ? text.trimEnd() : text
}
export function renderDiagramGridStyledText<Style extends string, Metadata extends object = object>(
grid: DiagramCanvas<Style, Metadata>,
fg: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["fg"],
bg?: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["bg"],
options?: DiagramCanvasRunOptions<Style, Metadata>,
): StyledText {
const chunks: TextChunk[] = []
grid.forEachRun(
(run) => {
chunks.push({ __isChunk: true, text: run.text, fg: fg(run), bg: bg?.(run) })
},
() => {
chunks.push({ __isChunk: true, text: "\n" })
},
options,
)
return new StyledText(chunks)
}
@@ -1,3 +0,0 @@
export const ANSI = {
reset: "\x1b[0m",
}
-3
View File
@@ -1,3 +0,0 @@
export function splitDiagramLines(value: string): string[] {
return value.split(/<br\s*\/?>/i).map((line) => line.trim())
}
-20
View File
@@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "./text.js"
describe("diagram text helpers", () => {
test("splits Mermaid-style line breaks", () => {
expect(splitDiagramLines("one<br/> two <br>three")).toEqual(["one", "two", "three"])
})
test("measures padded text boxes", () => {
expect(measureDiagramTextBox("wide<br/>x", { paddingX: 2, paddingY: 1 })).toEqual({
width: 8,
height: 4,
lines: ["wide", "x"],
})
})
test("measures terminal cell width", () => {
expect(diagramTextWidth("abc")).toBe(3)
})
})
-33
View File
@@ -1,33 +0,0 @@
import stringWidth from "string-width"
import { splitDiagramLines } from "./text-lines.js"
export { splitDiagramLines } from "./text-lines.js"
export interface DiagramTextBoxSize {
width: number
height: number
lines: string[]
}
export function diagramTextWidth(value: string): number {
return stringWidth(value)
}
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
export function* diagramTextGraphemes(value: string): Generator<string> {
for (const { segment } of graphemeSegmenter.segment(value)) yield segment
}
export function measureDiagramTextBox(
value: string,
options: { paddingX?: number; paddingY?: number; minInnerWidth?: number } = {},
): DiagramTextBoxSize {
const lines = splitDiagramLines(value)
const innerWidth = Math.max(...lines.map(diagramTextWidth), options.minInnerWidth ?? 1)
return {
width: innerWidth + (options.paddingX ?? 0) * 2,
height: lines.length + (options.paddingY ?? 0) * 2,
lines,
}
}
-11
View File
@@ -1,11 +0,0 @@
import type { MermaidDiagramKind } from "./diagnostics.js"
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
import { isMermaidStateDiagram } from "./state/parser.js"
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
if (isMermaidFlowchartDiagram(content)) return "flowchart"
if (isMermaidSequenceDiagram(content)) return "sequence"
if (isMermaidStateDiagram(content)) return "state"
return undefined
}
-16
View File
@@ -1,16 +0,0 @@
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
/** An otherwise valid diagram contains syntax that merman does not support. */
export class MermaidSyntaxError extends Error {
readonly _tag = "MermaidSyntaxError"
constructor(
readonly kind: MermaidDiagramKind,
readonly lineNumber: number,
readonly sourceLine: string,
reason = "Unsupported syntax",
) {
super(`${reason} in ${kind} diagram at line ${lineNumber}: "${sourceLine}"`)
this.name = "MermaidSyntaxError"
}
}
-401
View File
@@ -1,401 +0,0 @@
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
import { orthogonalPathPoints, walkOrthogonalSegment } from "../core/geometry.js"
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
import { diagramRadialCellColorLevel } from "../core/color/map.js"
import { splitDiagramLines } from "../core/text.js"
import {
DIAGRAM_ARROW_HEADS,
diagramArrowHeadBetween,
diagramDiamondCharactersFromBorder,
diagramLineGlyph,
drawDiagramDiamond,
drawDiagramFrame,
drawOrthogonalPath,
mergeDiagramLineGlyph,
} from "../core/drawing.js"
import { layoutFlowchartDiagram, visualLength } from "./layout.js"
import { flowchartEdgeLabelLayout } from "./labels.js"
import type { FlowchartDiagramRenderOptions } from "./options.js"
import { flowchartDirectionBetween, flowchartSourceConnector } from "./routing.js"
import {
DATABASE_EDGE_FADE_STYLES,
flowchartNodeColorKey,
NODE_EDGE_FADE_STYLES,
type FlowchartCellMetadata,
type FlowchartCellStyle,
type FlowchartEdgeFadeStyle,
type FlowchartGrid,
} from "./style.js"
import type {
FlowchartDiagram,
FlowchartActiveEdgeSelection,
FlowchartEdgeRoute,
FlowchartNode,
FlowchartNodeBounds,
FlowchartPoint,
FlowchartSubgraphBounds,
} from "./types.js"
export const DEFAULT_BORDER_STYLE = "rounded" satisfies BorderStyle
function mergeFlowchartCell(
existing: DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>,
incoming: DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>,
): DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata> {
if (incoming.style !== "edge" && incoming.style !== "activeEdge") return incoming
if (existing.style === "label") return incoming.style === "activeEdge" ? incoming : existing
if (incoming.char === " ") return existing
if ((existing.style !== "edge" && existing.style !== "activeEdge") || existing.char === " ") return incoming
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
return {
...incoming,
char: mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char,
} as DiagramCanvasCell<FlowchartCellStyle, FlowchartCellMetadata>
}
function nodeMetadataForCell(
bounds: FlowchartNodeBounds,
nodeId: string,
x: number,
y: number,
border = false,
): FlowchartCellMetadata {
const key = flowchartNodeColorKey(nodeId, diagramRadialCellColorLevel(bounds, x, y, border))
return { nodeId: key, bgNodeId: key }
}
function setNodeText(
grid: FlowchartGrid,
bounds: FlowchartNodeBounds,
nodeId: string,
x: number,
y: number,
text: string,
style: FlowchartCellStyle,
): void {
grid.setText(x, y, text, style, (cellX, cellY) => nodeMetadataForCell(bounds, nodeId, cellX, cellY))
}
function fillNodeInterior(
grid: FlowchartGrid,
bounds: FlowchartNodeBounds,
nodeId: string,
style: FlowchartCellStyle,
): void {
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
grid.setCell(x, y, " ", style, nodeMetadataForCell(bounds, nodeId, x, y))
}
}
}
function drawNode(
grid: FlowchartGrid,
node: FlowchartNode,
bounds: FlowchartNodeBounds,
borderStyle: BorderStyle,
active: boolean,
): void {
const chars = BorderChars[borderStyle]
const style: FlowchartCellStyle = active ? "activeNode" : node.shape === "database" ? "database" : "node"
if (node.shape === "decision") {
drawDiagramDiamond(
bounds,
(x, y, char) => grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, node.id, x, y, true)),
diagramDiamondCharactersFromBorder(chars),
)
} else if (node.shape === "subroutine") {
fillNodeInterior(grid, bounds, node.id, style)
drawSubroutineNode(grid, bounds, chars, style, node.id)
} else if (node.shape === "database") {
fillNodeInterior(grid, bounds, node.id, style)
drawDatabaseNode(grid, bounds, chars, style, node.id)
} else {
fillNodeInterior(grid, bounds, node.id, style)
drawDiagramFrame(bounds, chars, (x, y, char) =>
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, node.id, x, y, true)),
)
}
const textTop =
node.shape === "decision"
? bounds.top + Math.floor((bounds.height - bounds.lines.length) / 2)
: node.shape === "database"
? bounds.top + 2
: bounds.top + 1
for (const [index, line] of bounds.lines.entries()) {
const lineX =
node.shape === "subroutine"
? bounds.left + 3
: bounds.left + Math.max(1, Math.floor((bounds.width - visualLength(line)) / 2))
setNodeText(grid, bounds, node.id, lineX, textTop + index, line, style)
}
}
function drawSubroutineNode(
grid: FlowchartGrid,
bounds: FlowchartNodeBounds,
chars: BorderCharacters,
style: FlowchartCellStyle,
nodeId: string,
): void {
drawDiagramFrame(bounds, chars, (x, y, char) =>
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, nodeId, x, y, true)),
)
const leftRailX = bounds.left + 2
const rightRailX = bounds.left + bounds.width - 3
grid.setCell(
leftRailX,
bounds.top,
chars.topT,
style,
nodeMetadataForCell(bounds, nodeId, leftRailX, bounds.top, true),
)
grid.setCell(
rightRailX,
bounds.top,
chars.topT,
style,
nodeMetadataForCell(bounds, nodeId, rightRailX, bounds.top, true),
)
grid.setCell(
leftRailX,
bounds.top + bounds.height - 1,
chars.bottomT,
style,
nodeMetadataForCell(bounds, nodeId, leftRailX, bounds.top + bounds.height - 1, true),
)
grid.setCell(
rightRailX,
bounds.top + bounds.height - 1,
chars.bottomT,
style,
nodeMetadataForCell(bounds, nodeId, rightRailX, bounds.top + bounds.height - 1, true),
)
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
grid.setCell(leftRailX, y, chars.vertical, style, nodeMetadataForCell(bounds, nodeId, leftRailX, y, true))
grid.setCell(rightRailX, y, chars.vertical, style, nodeMetadataForCell(bounds, nodeId, rightRailX, y, true))
}
}
function drawDatabaseNode(
grid: FlowchartGrid,
bounds: FlowchartNodeBounds,
chars: BorderCharacters,
style: FlowchartCellStyle,
nodeId: string,
): void {
drawDiagramFrame(bounds, chars, (x, y, char) =>
grid.setCell(x, y, char, style, nodeMetadataForCell(bounds, nodeId, x, y, true)),
)
const topRailY = bounds.top + 1
const bottomRailY = bounds.top + bounds.height - 2
for (const y of [topRailY, bottomRailY]) {
grid.setCell(bounds.left, y, chars.leftT, style, nodeMetadataForCell(bounds, nodeId, bounds.left, y, true))
grid.setCell(
bounds.left + bounds.width - 1,
y,
chars.rightT,
style,
nodeMetadataForCell(bounds, nodeId, bounds.left + bounds.width - 1, y, true),
)
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
grid.setCell(x, y, chars.horizontal, style, nodeMetadataForCell(bounds, nodeId, x, y, true))
}
}
}
function drawSubgraphFrame(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds, borderStyle: BorderStyle): void {
const chars = BorderChars[borderStyle]
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, "group"))
}
function drawSubgraphLabel(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds): void {
if (bounds.label) {
const lines = splitDiagramLines(bounds.label)
const labelY = bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length
for (const [index, line] of lines.entries()) {
grid.setText(bounds.left + 2, labelY + index, ` ${line} `, "group")
}
}
}
function drawEdgeLabel(grid: FlowchartGrid, route: FlowchartEdgeRoute, style: FlowchartCellStyle): void {
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
for (const [index, line] of label.lines.entries()) {
grid.setText(label.point.x, label.point.y + index, line, style)
}
}
function drawRoutedEdge(grid: FlowchartGrid, route: FlowchartEdgeRoute, active = false): void {
const { edge, points } = route
if (points.length < 2) return
const style: FlowchartCellStyle = active ? "activeEdge" : "edge"
drawOrthogonalPath(points, (x, y, char) => grid.setCell(x, y, char, style), {
cornerStyle: "rounded",
lineStyle: edge.style === "thick" ? "heavy" : edge.style === "dashed" ? "dashed" : "single",
})
const end = points[points.length - 1]!
const arrowFrom = points[points.length - 2]!
grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style)
if (edge.label) {
drawEdgeLabel(grid, route, active ? "activeEdge" : "label")
}
}
function activeEdgeMatches(
route: FlowchartEdgeRoute,
edgeIndex: number,
activeEdge: FlowchartActiveEdgeSelection,
): boolean {
return (
route.edge.from === activeEdge.from &&
route.edge.to === activeEdge.to &&
(activeEdge.index ?? edgeIndex) === edgeIndex
)
}
function activeRoute(
routes: readonly FlowchartEdgeRoute[],
diagram: FlowchartDiagram,
activeEdge: FlowchartActiveEdgeSelection | undefined,
): FlowchartEdgeRoute | undefined {
if (!activeEdge) return undefined
const edgeIndexes = new Map(diagram.edges.map((edge, index) => [edge, index]))
for (let index = 0; index < routes.length; index++) {
const route = routes[index]!
if (activeEdgeMatches(route, edgeIndexes.get(route.edge) ?? index, activeEdge)) return route
}
return undefined
}
function activeRoutePoints(
route: FlowchartEdgeRoute,
from: FlowchartNodeBounds | undefined,
): readonly FlowchartPoint[] {
const sourcePoint = route.points[0]
return from && sourcePoint ? [{ ...flowchartSourceConnector(from, sourcePoint) }, ...route.points] : route.points
}
function styleActivePathCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartCellStyle): void {
const cell = grid.getCell(x, y)
if (cell && cell.char !== " ") cell.style = style
}
function drawActiveRoute(grid: FlowchartGrid, route: FlowchartEdgeRoute, from: FlowchartNodeBounds | undefined): void {
for (const point of orthogonalPathPoints(activeRoutePoints(route, from))) {
styleActivePathCell(grid, point.x, point.y, "activeEdge")
}
if (route.edge.label) {
drawEdgeLabel(grid, route, "activeEdge")
}
}
function flowchartNodeStyle(node: FlowchartNode | undefined): "node" | "database" {
return node?.shape === "database" ? "database" : "node"
}
function sourceFadeStyles(sourceStyle: "node" | "database"): readonly FlowchartEdgeFadeStyle[] {
return sourceStyle === "database" ? DATABASE_EDGE_FADE_STYLES : NODE_EDGE_FADE_STYLES
}
function styleExistingEdgeCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartEdgeFadeStyle): boolean {
const cell = grid.getCell(x, y)
if (!cell || cell.char === " " || cell.style === "label" || DIAGRAM_ARROW_HEADS.has(cell.char)) return false
grid.setCell(x, y, cell.char, style)
return true
}
function fadeSourcePath(
grid: FlowchartGrid,
points: FlowchartPoint[],
styles: readonly FlowchartEdgeFadeStyle[],
): void {
let styleIndex = 1
const seen = new Set<string>()
for (let index = 1; index < points.length && styleIndex < styles.length; index++) {
const from = points[index - 1]!
const to = points[index]!
const direction = flowchartDirectionBetween(from, to)
if (!direction) continue
walkOrthogonalSegment(from, to, index === 1, (point) => {
if (styleIndex >= styles.length) return false
const key = `${point.x}:${point.y}`
if (!seen.has(key)) {
seen.add(key)
if (styleExistingEdgeCell(grid, point.x, point.y, styles[styleIndex]!)) styleIndex += 1
}
return styleIndex < styles.length
})
}
}
function drawSourceConnectors(
grid: FlowchartGrid,
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
routes: readonly FlowchartEdgeRoute[],
): void {
const nodesById = new Map(diagram.nodes.map((node) => [node.id, node]))
for (const route of routes) {
const from = bounds.get(route.edge.from)
const sourcePoint = route.points[0]
if (!from || !sourcePoint) continue
const styles = sourceFadeStyles(flowchartNodeStyle(nodesById.get(route.edge.from)))
const connector = flowchartSourceConnector(from, sourcePoint)
grid.setCell(connector.x, connector.y, connector.char, styles[0])
const routeDirection = route.points[1] ? flowchartDirectionBetween(sourcePoint, route.points[1]!) : undefined
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
if (routeDirection && connectorDirection) {
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
if (cell) {
cell.char = diagramLineGlyph(
new Set([routeDirection, connectorDirection]),
"rounded",
route.edge.style === "thick" ? "heavy" : "single",
)
cell.style = "edge"
}
}
fadeSourcePath(grid, route.points, styles)
}
}
export function drawFlowchartDiagramGrid(
diagram: FlowchartDiagram,
options: FlowchartDiagramRenderOptions = {},
): FlowchartGrid {
const borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
const layout = layoutFlowchartDiagram(diagram, options)
const { bounds, routes, subgraphBounds, width, height } = layout
diagram = layout.diagram
const grid = new DiagramCanvas<FlowchartCellStyle, FlowchartCellMetadata>(width, height, {
mergeCell: mergeFlowchartCell,
})
const selectedRoute = activeRoute(routes, diagram, options.activeEdge)
for (const subgraph of diagram.subgraphs ?? []) {
const bound = subgraphBounds.get(subgraph.id)
if (bound) drawSubgraphFrame(grid, bound, borderStyle)
}
for (const route of routes) drawRoutedEdge(grid, route)
for (const node of diagram.nodes) {
const bound = bounds.get(node.id)
if (bound) drawNode(grid, node, bound, borderStyle, node.id === options.activeNode)
}
drawSourceConnectors(grid, diagram, bounds, routes)
if (selectedRoute) {
const from = bounds.get(selectedRoute.edge.from)
drawActiveRoute(grid, selectedRoute, from)
}
for (const subgraph of diagram.subgraphs ?? []) {
const bound = subgraphBounds.get(subgraph.id)
if (bound) drawSubgraphLabel(grid, bound)
}
return grid
}
File diff suppressed because it is too large Load Diff
-43
View File
@@ -1,43 +0,0 @@
import { renderFlowchartDiagram, renderFlowchartDiagramAnsi } from "./render.js"
import type { FlowchartDiagramAnsiOptions, FlowchartDiagramRenderOptions } from "./options.js"
export type {
FlowchartDiagram as Diagram,
FlowchartActiveEdgeSelection as ActiveEdgeSelection,
FlowchartDirection as Direction,
FlowchartEdge as Edge,
FlowchartEdgeDirection as EdgeDirection,
FlowchartEdgeRoute as EdgeRoute,
FlowchartNode as Node,
FlowchartNodeBounds as NodeBounds,
FlowchartNodeShape as NodeShape,
FlowchartPoint as Point,
FlowchartSubgraph as Subgraph,
FlowchartSubgraphBounds as SubgraphBounds,
} from "./types.js"
export type {
FlowchartDiagramAnsiOptions as AnsiRenderOptions,
FlowchartDiagramOptions as RenderableOptions,
FlowchartDiagramRenderOptions as PlainRenderOptions,
} from "./options.js"
export type { FlowchartDiagramAnsiTheme as Theme, FlowchartNodeColors as NodeColors } from "./style.js"
export { flowchartNodeColorKey as nodeColorKey } from "./style.js"
export { isMermaidFlowchartDiagram as is, parseMermaidFlowchartDiagram as parse } from "./parser.js"
export { FlowchartDiagramRenderable as Renderable } from "./renderable.js"
export interface RenderOptions extends FlowchartDiagramAnsiOptions {
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
color?: boolean
}
/**
* Render a Mermaid flowchart string for the terminal.
*
* Defaults to ANSI-colored output. Pass `{ color: false }` for plain text.
*/
export function render(content: string, options: RenderOptions = {}): string {
const { color = true, ...rest } = options
return color
? renderFlowchartDiagramAnsi(content, rest)
: renderFlowchartDiagram(content, rest as FlowchartDiagramRenderOptions)
}
@@ -1,111 +0,0 @@
import { describe, expect, test } from "bun:test"
import { flowchartEdgeLabelLayout } from "./labels.js"
const measure = (text: string): number => text.length
describe("flowchart edge labels", () => {
test("places vertical-route labels beside the bus", () => {
expect(
flowchartEdgeLabelLayout(
[
{ x: 22, y: 3 },
{ x: 22, y: 7 },
],
"rollback",
measure,
).point,
).toEqual({ x: 23, y: 5 })
})
test("places labels inline only when padded text fits with clearance", () => {
expect(
flowchartEdgeLabelLayout(
[
{ x: 0, y: 2 },
{ x: 13, y: 2 },
],
"rollback",
measure,
).point,
).toEqual({ x: 2, y: 2 })
expect(
flowchartEdgeLabelLayout(
[
{ x: 0, y: 2 },
{ x: 9, y: 2 },
],
"rollback",
measure,
).point,
).toEqual({ x: 2, y: 1 })
expect(
flowchartEdgeLabelLayout(
[
{ x: 0, y: 2 },
{ x: 7, y: 2 },
],
"rollback",
measure,
).point,
).toEqual({ x: 2, y: 1 })
})
test("uses vertical bus labels before short terminal branches", () => {
expect(
flowchartEdgeLabelLayout(
[
{ x: 155, y: 5 },
{ x: 150, y: 5 },
{ x: 150, y: 9 },
{ x: 146, y: 9 },
],
"rollback",
measure,
).point,
).toEqual({ x: 151, y: 7 })
})
test("measures br-delimited edge label lines as a block", () => {
const layout = flowchartEdgeLabelLayout(
[
{ x: 0, y: 2 },
{ x: 20, y: 2 },
],
"first<br/>second line",
measure,
)
expect(layout.lines).toEqual([" first ", " second line "])
expect(layout.width).toBe(13)
expect(layout.height).toBe(2)
})
test("places multiline horizontal edge labels outside the route row", () => {
const layout = flowchartEdgeLabelLayout(
[
{ x: 0, y: 5 },
{ x: 20, y: 5 },
],
"first<br/>second",
measure,
)
expect(layout.point.y + layout.height).toBeLessThanOrEqual(5)
})
test("centers multiline vertical edge labels beside their route", () => {
const layout = flowchartEdgeLabelLayout(
[
{ x: 22, y: 2 },
{ x: 22, y: 10 },
],
"one<br/>two<br/>three",
measure,
)
expect(layout.point).toEqual({ x: 23, y: 5 })
expect(layout.height).toBe(3)
})
})
-104
View File
@@ -1,104 +0,0 @@
import {
clampPoint,
centeredSpanStart,
insetSpan,
midpoint,
point,
pointOnSegment,
segmentBetween,
segmentSpan,
shiftPoint,
spanCapacity,
type DiagramSegment,
} from "../core/geometry.js"
import { splitDiagramLines } from "../core/text.js"
import type { FlowchartPoint } from "./types.js"
const LABEL_BUS_CLEARANCE = 3
const LABEL_NODE_CLEARANCE = 2
const LABEL_LINE_CLEARANCE = 2
const LABEL_PADDING = 1
export interface FlowchartEdgeLabelLayout {
lines: string[]
point: FlowchartPoint
width: number
height: number
}
export function flowchartLabelText(label: string): string {
return `${" ".repeat(LABEL_PADDING)}${label}${" ".repeat(LABEL_PADDING)}`
}
export function flowchartLabelWidth(label: string, measure: (text: string) => number): number {
return Math.max(...splitDiagramLines(label).map((line) => measure(line) + LABEL_PADDING * 2))
}
function minimumInlineLabelLength(labelWidth: number): number {
return labelWidth + LABEL_LINE_CLEARANCE * 2 - 1
}
export function flowchartHorizontalLabelRankGap(labelWidth: number): number {
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + 1
}
export function flowchartVerticalBranchLabelGap(labelWidth: number): number {
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + LABEL_NODE_CLEARANCE
}
function inlineLabelSlot(segment: DiagramSegment, labelWidth: number): { x: number; fits: boolean } {
const slot = insetSpan(segmentSpan(segment), LABEL_LINE_CLEARANCE)
return { x: centeredSpanStart(slot, labelWidth), fits: spanCapacity(slot) >= labelWidth }
}
function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHeight: number): FlowchartPoint {
if (segment.axis === "x") {
const slot = inlineLabelSlot(segment, labelWidth)
if (labelHeight === 1 && slot.fits) return point(slot.x, segment.from.y)
if (labelHeight > 1) {
return shiftPoint(point(slot.x, segment.from.y), "up", labelHeight)
}
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
}
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
}
function bestLabelSegment(points: readonly FlowchartPoint[], labelWidth: number): DiagramSegment | undefined {
let roomyHorizontal: DiagramSegment | undefined
let verticalBus: DiagramSegment | undefined
let longest: DiagramSegment | undefined
for (let index = 1; index < points.length; index++) {
const segment = segmentBetween(points[index - 1]!, points[index]!)
if (!segment) continue
if (!roomyHorizontal && segment.axis === "x" && inlineLabelSlot(segment, labelWidth).fits) roomyHorizontal = segment
if (!verticalBus && segment.axis === "y") verticalBus = segment
if (!longest || segment.length > longest.length) longest = segment
}
return roomyHorizontal ?? verticalBus ?? longest
}
function flowchartLabelPoint(
points: readonly FlowchartPoint[],
labelWidth: number,
labelHeight: number,
): FlowchartPoint {
const segment = bestLabelSegment(points, labelWidth)
return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0))
}
export function flowchartEdgeLabelLayout(
points: readonly FlowchartPoint[],
label: string,
measure: (text: string) => number,
): FlowchartEdgeLabelLayout {
const lines = splitDiagramLines(label).map(flowchartLabelText)
const width = flowchartLabelWidth(label, measure)
const height = lines.length
return { lines, point: flowchartLabelPoint(points, width, height), width, height }
}
-639
View File
@@ -1,639 +0,0 @@
import {
diagramBoundsFromBounds,
diagramBoundsFromPoints,
segmentBetween,
segmentSpan,
translateDiagramBounds,
} from "../core/geometry.js"
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
import {
flowchartEdgeLabelLayout,
flowchartHorizontalLabelRankGap,
flowchartLabelWidth,
flowchartVerticalBranchLabelGap,
} from "./labels.js"
import type { FlowchartDiagramRenderOptions } from "./options.js"
import { routeFlowchartEdges } from "./routing.js"
import type {
FlowchartDiagram,
FlowchartDirection,
FlowchartEdge,
FlowchartEdgeRoute,
FlowchartNode,
FlowchartNodeBounds,
FlowchartNodeSize,
FlowchartSubgraphBounds,
} from "./types.js"
export const DEFAULT_MIN_NODE_GAP = 5
export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
export const DEFAULT_MIN_RANK_GAP = 10
export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
export const COMPACT_MIN_RANK_GAP = 4
export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
const SUBGRAPH_PADDING_X = 2
const SUBGRAPH_PADDING_TOP = 1
const SUBGRAPH_PADDING_BOTTOM = 1
export interface FlowchartLayout {
diagram: FlowchartDiagram
bounds: Map<string, FlowchartNodeBounds>
routes: FlowchartEdgeRoute[]
subgraphBounds: Map<string, FlowchartSubgraphBounds>
width: number
height: number
}
type FlowchartBounds = Pick<FlowchartSubgraphBounds, "left" | "top" | "width" | "height" | "centerX" | "centerY">
function horizontalRankGaps(
diagram: FlowchartDiagram,
normalizedRanks: ReadonlyMap<string, number>,
rankKeys: readonly number[],
fallback: number,
): number[] {
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
for (const edge of diagram.edges) {
if (!edge.label) continue
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength))
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
gaps[index] = Math.max(gaps[index]!, labelGap)
}
}
return gaps
}
function verticalRankGaps(
diagram: FlowchartDiagram,
normalizedRanks: ReadonlyMap<string, number>,
rankKeys: readonly number[],
fallback: number,
): number[] {
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
for (const edge of diagram.edges) {
if (!edge.label) continue
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
const labelHeight = splitDiagramLines(edge.label).length
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
gaps[index] = Math.max(gaps[index]!, labelHeight + 2)
}
}
return gaps
}
function isHorizontalDirection(direction: FlowchartDirection): boolean {
return direction === "LR" || direction === "RL"
}
export function visualLength(value: string): number {
return diagramTextWidth(value)
}
export function normalizePositiveInt(value: number | undefined, fallback: number): number {
if (value === undefined || !Number.isFinite(value)) return fallback
return Math.max(1, Math.trunc(value))
}
function nodeSize(node: FlowchartNode): FlowchartNodeSize {
const { lines, width } = measureDiagramTextBox(node.label, { paddingX: 2 })
const innerWidth = width - 4
if (node.shape === "decision") {
const width = innerWidth + 6
return {
width: width % 2 === 0 ? width + 1 : width,
height: Math.max(5, lines.length + 4),
lines,
}
}
if (node.shape === "database") return { width: innerWidth + 4, height: lines.length + 4, lines }
if (node.shape === "subroutine") return { width: innerWidth + 6, height: lines.length + 2, lines }
return { width: innerWidth + 4, height: lines.length + 2, lines }
}
function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
const ranks = new Map<string, number>()
const outgoing = new Map<string, string[]>()
const incoming = new Set<string>()
const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0]))
for (const edge of diagram.edges) {
const list = outgoing.get(edge.from) ?? []
list.push(edge.to)
outgoing.set(edge.from, list)
incoming.add(edge.to)
incomingCounts.set(edge.to, (incomingCounts.get(edge.to) ?? 0) + 1)
}
const starts = diagram.nodes.filter((node) => !incoming.has(node.id))
if (starts.length === 0 && diagram.nodes[0]) starts.push(diagram.nodes[0])
const queue = starts.map((node) => node.id)
for (const node of starts) ranks.set(node.id, 0)
for (let index = 0; index < queue.length; index++) {
const id = queue[index]!
const rank = ranks.get(id) ?? 0
for (const to of outgoing.get(id) ?? []) {
const nextRank = rank + 1
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
ranks.set(to, nextRank)
queue.push(to)
}
}
for (const node of diagram.nodes) {
if (!ranks.has(node.id)) ranks.set(node.id, ranks.size)
}
const acyclicRanks = new Map<string, number>()
const acyclicQueue = diagram.nodes.filter((node) => incomingCounts.get(node.id) === 0).map((node) => node.id)
for (const id of acyclicQueue) acyclicRanks.set(id, 0)
for (let index = 0; index < acyclicQueue.length; index++) {
const id = acyclicQueue[index]!
const rank = acyclicRanks.get(id) ?? 0
for (const to of outgoing.get(id) ?? []) {
acyclicRanks.set(to, Math.max(acyclicRanks.get(to) ?? 0, rank + 1))
const remainingIncoming = (incomingCounts.get(to) ?? 0) - 1
incomingCounts.set(to, remainingIncoming)
if (remainingIncoming === 0) acyclicQueue.push(to)
}
}
for (const [id, rank] of acyclicRanks) ranks.set(id, rank)
return ranks
}
function translateBounds(bounds: FlowchartBounds, dx: number, dy: number): void {
translateDiagramBounds(bounds, dx, dy)
}
function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy: number): void {
for (const route of routes) {
for (const point of route.points) {
point.x += dx
point.y += dy
}
}
}
function boundsFromChildren(children: readonly FlowchartBounds[]): FlowchartBounds | undefined {
return diagramBoundsFromBounds(children)
}
function subgraphBoundFromChildren(
id: string,
label: string,
children: readonly FlowchartBounds[],
): FlowchartSubgraphBounds {
const labelLines = splitDiagramLines(label)
const labelHeight = labelLines.length
let left = Math.min(...children.map((child) => child.left)) - SUBGRAPH_PADDING_X
const top = Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight)
let right = Math.max(...children.map((child) => child.left + child.width)) + SUBGRAPH_PADDING_X
const bottom =
Math.max(...children.map((child) => child.top + child.height)) + Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight)
const minWidth = Math.max(...labelLines.map(visualLength)) + 5
if (right - left < minWidth) {
const extra = minWidth - (right - left)
left -= Math.floor(extra / 2)
right += Math.ceil(extra / 2)
}
const width = right - left
const height = Math.max(3, bottom - top)
return {
id,
label,
left,
top,
width,
height,
centerX: left + Math.floor(width / 2),
centerY: top + Math.floor(height / 2),
labelSide: "top",
}
}
function spansOverlap(leftStart: number, leftEnd: number, rightStart: number, rightEnd: number): boolean {
return leftStart <= rightEnd && rightStart <= leftEnd
}
function labelSlot(bounds: FlowchartSubgraphBounds, side: FlowchartSubgraphBounds["labelSide"]): FlowchartBounds {
const lines = splitDiagramLines(bounds.label)
const left = bounds.left + 2
const height = lines.length
const top = side === "top" ? bounds.top : bounds.top + bounds.height - height
const width = Math.max(...lines.map((line) => visualLength(` ${line} `)))
return { left, top, width, height, centerX: left + Math.floor(width / 2), centerY: top + Math.floor(height / 2) }
}
function segmentOverlapsSlot(
from: { x: number; y: number },
to: { x: number; y: number },
slot: FlowchartBounds,
): boolean {
const segment = segmentBetween(from, to)
if (!segment) return false
const slotRight = slot.left + slot.width - 1
const slotBottom = slot.top + slot.height - 1
const span = segmentSpan(segment)
if (segment.axis === "x") {
return (
segment.from.y >= slot.top &&
segment.from.y <= slotBottom &&
spansOverlap(span.start, span.end, slot.left, slotRight)
)
}
return (
segment.from.x >= slot.left &&
segment.from.x <= slotRight &&
spansOverlap(span.start, span.end, slot.top, slotBottom)
)
}
function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): boolean {
for (let index = 1; index < route.points.length; index++) {
if (segmentOverlapsSlot(route.points[index - 1]!, route.points[index]!, slot)) return true
}
const routeLabelBounds = labelBounds(route)
if (
!routeLabelBounds ||
!spansOverlap(
routeLabelBounds.top,
routeLabelBounds.top + routeLabelBounds.height - 1,
slot.top,
slot.top + slot.height - 1,
)
) {
return false
}
return spansOverlap(
routeLabelBounds.left,
routeLabelBounds.left + routeLabelBounds.width - 1,
slot.left,
slot.left + slot.width - 1,
)
}
function chooseSubgraphLabelSide(
bounds: FlowchartSubgraphBounds,
routes: readonly FlowchartEdgeRoute[],
): FlowchartSubgraphBounds["labelSide"] {
const topSlot = labelSlot(bounds, "top")
if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top"
const bottomSlot = labelSlot(bounds, "bottom")
return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom"
}
function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined {
return diagramBoundsFromPoints(points)
}
function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
if (!route.edge.label) return undefined
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
const { point, width, height } = label
return {
left: point.x,
top: point.y,
width,
height,
centerX: point.x + Math.floor(width / 2),
centerY: point.y + Math.floor(height / 2),
}
}
function subgraphRouteBounds(subgraphNodeIds: Set<string>, routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
return routeRenderBounds(
routes.filter((route) => subgraphNodeIds.has(route.edge.from) && subgraphNodeIds.has(route.edge.to)),
)
}
function routeRenderBounds(routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
const bounds: FlowchartBounds[] = []
for (const route of routes) {
const routeBounds = pathBounds(route.points)
if (routeBounds) bounds.push(routeBounds)
const routeLabelBounds = labelBounds(route)
if (routeLabelBounds) bounds.push(routeLabelBounds)
}
return bounds
}
function layoutRankedNodes(
diagram: FlowchartDiagram,
direction: FlowchartDirection,
sizes: ReadonlyMap<string, FlowchartNodeSize>,
minNodeGap: number,
requestedMinRankGap: number,
): Map<string, FlowchartNodeBounds> {
const horizontal = isHorizontalDirection(direction)
let widestPaddedEdgeLabel = 0
for (const edge of diagram.edges) {
if (edge.label)
widestPaddedEdgeLabel = Math.max(widestPaddedEdgeLabel, flowchartLabelWidth(edge.label, visualLength))
}
const rankNodeGap = horizontal
? minNodeGap
: Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP, flowchartVerticalBranchLabelGap(widestPaddedEdgeLabel))
const ranks = rankNodes(diagram)
const maxRank = Math.max(0, ...ranks.values())
const ranksByIndex = new Map<number, FlowchartNode[]>()
const normalizedRanks = new Map<string, number>()
for (const node of diagram.nodes) {
const rank = ranks.get(node.id) ?? 0
const normalizedRank = direction === "RL" || direction === "BT" ? maxRank - rank : rank
normalizedRanks.set(node.id, normalizedRank)
const nodes = ranksByIndex.get(normalizedRank) ?? []
nodes.push(node)
ranksByIndex.set(normalizedRank, nodes)
}
const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
const bounds = new Map<string, FlowchartNodeBounds>()
if (horizontal) {
const columnWidths = rankKeys.map((rank) =>
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.width)),
)
const columnHeights = rankKeys.map((rank) => {
const nodes = ranksByIndex.get(rank)!
return (
nodes.reduce((total, node) => total + sizes.get(node.id)!.height, 0) +
Math.max(0, nodes.length - 1) * rankNodeGap
)
})
const canvasHeight = Math.max(1, ...columnHeights)
let x = 0
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
const rank = rankKeys[rankIndex]!
const nodes = ranksByIndex.get(rank)!
const columnWidth = columnWidths[rankIndex]!
let y = Math.floor((canvasHeight - columnHeights[rankIndex]!) / 2)
for (const node of nodes) {
const size = sizes.get(node.id)!
const left = x + Math.floor((columnWidth - size.width) / 2)
bounds.set(node.id, {
id: node.id,
...size,
left,
top: y,
centerX: left + Math.floor(size.width / 2),
centerY: y + Math.floor(size.height / 2),
})
y += size.height + rankNodeGap
}
x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
}
} else {
const rowHeights = rankKeys.map((rank) =>
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.height)),
)
const rowWidths = rankKeys.map((rank) => {
const nodes = ranksByIndex.get(rank)!
return (
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
Math.max(0, nodes.length - 1) * rankNodeGap
)
})
const canvasWidth = Math.max(1, ...rowWidths)
let y = 0
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
const rank = rankKeys[rankIndex]!
const nodes = ranksByIndex.get(rank)!
const rowHeight = rowHeights[rankIndex]!
let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
for (const node of nodes) {
const size = sizes.get(node.id)!
const top = y + Math.floor((rowHeight - size.height) / 2)
bounds.set(node.id, {
id: node.id,
...size,
left: x,
top,
centerX: x + Math.floor(size.width / 2),
centerY: top + Math.floor(size.height / 2),
})
x += size.width + rankNodeGap
}
y += rowHeight + (verticalGaps[rankIndex] ?? 0)
}
}
return bounds
}
function layoutLocalSubgraphDirections(
diagram: FlowchartDiagram,
nodeBounds: Map<string, FlowchartNodeBounds>,
sizes: ReadonlyMap<string, FlowchartNodeSize>,
minNodeGap: number,
requestedMinRankGap: number,
): void {
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
if (!subgraph.direction || subgraph.direction === diagram.direction) continue
const nodeIds = new Set(subgraph.nodeIds)
const nodes = diagram.nodes.filter((node) => nodeIds.has(node.id))
if (nodes.length === 0) continue
const currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
if (!currentBounds) continue
const localDiagram: FlowchartDiagram = {
direction: subgraph.direction,
nodes,
edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
subgraphs: [],
}
const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
const localExtent = boundsFromChildren([...localBounds.values()])
if (!localExtent) continue
const targetLeft = currentBounds.left + Math.floor((currentBounds.width - localExtent.width) / 2)
const targetTop = currentBounds.top + Math.floor((currentBounds.height - localExtent.height) / 2)
const dx = targetLeft - localExtent.left
const dy = targetTop - localExtent.top
for (const [nodeId, bound] of localBounds) {
translateBounds(bound, dx, dy)
nodeBounds.set(nodeId, bound)
}
}
}
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
if (!subgraph.direction) continue
if (subgraph.nodeIds.includes(edge.from) && subgraph.nodeIds.includes(edge.to)) return subgraph.direction
}
return diagram.direction
}
function hasLocalSubgraphDirection(diagram: FlowchartDiagram): boolean {
return (diagram.subgraphs ?? []).some((subgraph) => subgraph.direction && subgraph.direction !== diagram.direction)
}
function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string): Set<string> {
const nodeIds = new Set<string>()
for (const subgraph of diagram.subgraphs ?? []) {
if (subgraph.id !== subgraphId && subgraph.parentId !== subgraphId) continue
for (const nodeId of subgraph.nodeIds) nodeIds.add(nodeId)
if (subgraph.parentId === subgraphId) {
for (const nodeId of collectSubgraphNodeIds(diagram, subgraph.id)) nodeIds.add(nodeId)
}
}
return nodeIds
}
function separateLocalSubgraphItems(
diagram: FlowchartDiagram,
nodeBounds: Map<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
gap: number,
): void {
if (!hasLocalSubgraphDirection(diagram)) return
const coveredNodeIds = new Set<string>()
const items: { bounds: FlowchartBounds; nodeIds: Set<string> }[] = []
for (const subgraph of diagram.subgraphs ?? []) {
if (subgraph.parentId) continue
const bounds = subgraphBounds.get(subgraph.id)
const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
if (!bounds || nodeIds.size === 0) continue
items.push({ bounds, nodeIds })
for (const nodeId of nodeIds) coveredNodeIds.add(nodeId)
}
for (const node of diagram.nodes) {
if (coveredNodeIds.has(node.id)) continue
const bounds = nodeBounds.get(node.id)
if (bounds) items.push({ bounds, nodeIds: new Set([node.id]) })
}
const horizontal = isHorizontalDirection(diagram.direction)
items.sort((a, b) => (horizontal ? a.bounds.left - b.bounds.left : a.bounds.top - b.bounds.top))
let cursor: number | undefined
for (const item of items) {
const start = horizontal ? item.bounds.left : item.bounds.top
const size = horizontal ? item.bounds.width : item.bounds.height
if (cursor === undefined) {
cursor = start + size + gap
continue
}
const shift = cursor - start
if (shift !== 0) {
for (const nodeId of item.nodeIds) {
const bounds = nodeBounds.get(nodeId)
if (bounds) translateBounds(bounds, horizontal ? shift : 0, horizontal ? 0 : shift)
}
}
cursor = start + shift + size + gap
}
}
function layoutSubgraphs(
diagram: FlowchartDiagram,
nodeBounds: Map<string, FlowchartNodeBounds>,
routes: readonly FlowchartEdgeRoute[],
): Map<string, FlowchartSubgraphBounds> {
const subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
const subgraphs = diagram.subgraphs ?? []
for (const subgraph of [...subgraphs].reverse()) {
const children: FlowchartBounds[] = []
for (const nodeId of subgraph.nodeIds) {
const bound = nodeBounds.get(nodeId)
if (bound) children.push(bound)
}
children.push(...subgraphRouteBounds(new Set(subgraph.nodeIds), routes))
for (const childSubgraph of subgraphs) {
if (childSubgraph.parentId !== subgraph.id) continue
const bound = subgraphBounds.get(childSubgraph.id)
if (bound) children.push(bound)
}
if (children.length > 0) {
const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children)
bound.labelSide = chooseSubgraphLabelSide(bound, routes)
subgraphBounds.set(subgraph.id, bound)
}
}
return subgraphBounds
}
function layoutFlowchartWithDirection(
sourceDiagram: FlowchartDiagram,
options: FlowchartDiagramRenderOptions,
direction: FlowchartDirection,
): FlowchartLayout {
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
const horizontal = isHorizontalDirection(direction)
const minNodeGap = normalizePositiveInt(options.minNodeGap, DEFAULT_MIN_NODE_GAP)
const requestedMinRankGap = normalizePositiveInt(
options.minRankGap,
options.compact
? horizontal
? COMPACT_MIN_RANK_GAP
: COMPACT_MIN_VERTICAL_RANK_GAP
: horizontal
? DEFAULT_MIN_RANK_GAP
: DEFAULT_MIN_VERTICAL_RANK_GAP,
)
const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
let routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
let subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
separateLocalSubgraphItems(diagram, bounds, subgraphBounds, Math.max(1, Math.floor(requestedMinRankGap / 2)))
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
subgraphBounds = layoutSubgraphs(diagram, bounds, 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)))
if (dx > 0 || dy > 0) {
for (const bound of allBounds) translateBounds(bound, dx, dy)
translateRoutes(routes, dx, dy)
}
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
const maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
return { diagram, bounds, routes, subgraphBounds, width: maxX + 4, height: maxY + 4 }
}
export function layoutFlowchartDiagram(
sourceDiagram: FlowchartDiagram,
options: FlowchartDiagramRenderOptions = {},
): FlowchartLayout {
const direction = options.direction ?? sourceDiagram.direction
const layout = layoutFlowchartWithDirection(sourceDiagram, options, direction)
const maxWidth = options.layoutMaxWidth
if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD")
}
-32
View File
@@ -1,32 +0,0 @@
import type { BorderStyle, ColorInput, TextBufferOptions } from "@opentui/core"
import type { FlowchartDiagramAnsiTheme, FlowchartNodeColors } from "./style.js"
import type { FlowchartActiveEdgeSelection, FlowchartDirection } from "./types.js"
export interface FlowchartDiagramRenderOptions {
compact?: boolean
direction?: FlowchartDirection
borderStyle?: BorderStyle
minNodeGap?: number
minRankGap?: number
/** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
layoutMaxWidth?: number
activeNode?: string
activeEdge?: FlowchartActiveEdgeSelection
}
export interface FlowchartDiagramAnsiOptions extends FlowchartDiagramRenderOptions {
theme?: FlowchartDiagramAnsiTheme
}
export interface FlowchartDiagramOptions extends TextBufferOptions, FlowchartDiagramRenderOptions {
content?: string
nodeColor?: ColorInput
nodeColors?: FlowchartNodeColors
nodeBgColors?: FlowchartNodeColors
databaseColor?: ColorInput
edgeColor?: ColorInput
activeNodeColor?: ColorInput
activeEdgeColor?: ColorInput
labelColor?: ColorInput
groupColor?: ColorInput
}
-215
View File
@@ -1,215 +0,0 @@
import type {
FlowchartDiagram,
FlowchartDirection,
FlowchartEdge,
FlowchartEdgeStyle,
FlowchartNode,
FlowchartSubgraph,
} from "./types.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import {
firstMeaningfulMermaidLine,
meaningfulNumberedMermaidLines,
stripMermaidQuotes as stripQuotes,
} from "../core/mermaid.js"
const DEFAULT_DIRECTION = "TD" satisfies FlowchartDirection
const FLOWCHART_HEADER_RE = /^(flowchart|graph)(?:\s+(TB|TD|BT|LR|RL))?$/i
const ID_RE = "[A-Za-z_][A-Za-z0-9_.-]*"
const SUBGRAPH_RE = /^subgraph\s+(.+)$/i
const SUBGRAPH_WITH_LABEL_RE = new RegExp(`^(${ID_RE})\\s*\\[(.+)\\]$`)
const SUBGRAPH_DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
const IGNORED_PRESENTATION_RE = /^(?:classDef|class|style|linkStyle)\b/i
const DATABASE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\((.+)\\)\\]$`)
const SUBROUTINE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\[(.+)\\]\\]$`)
const ROUNDED_BRACKET_NODE_RE = new RegExp(`^(${ID_RE})\\(\\[(.+)\\]\\)$`)
const ROUNDED_NODE_RE = new RegExp(`^(${ID_RE})\\((.+)\\)$`)
const DECISION_NODE_RE = new RegExp(`^(${ID_RE})\\{(.+)\\}$`)
const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
function normalizeDirection(value?: string): FlowchartDirection {
const upper = value?.toUpperCase()
if (upper === "TB" || upper === "TD" || upper === "BT" || upper === "LR" || upper === "RL") return upper
return DEFAULT_DIRECTION
}
function normalizeSubgraphId(value: string, index: number): string {
const stripped = stripQuotes(value)
return ID_ONLY_RE.test(stripped) ? stripped : `subgraph_${index + 1}`
}
function parseSubgraphToken(token: string, index: number): Pick<FlowchartSubgraph, "id" | "label"> {
const trimmed = token
.trim()
.replace(/\s*:::.*$/, "")
.replace(/;$/, "")
const withLabel = trimmed.match(SUBGRAPH_WITH_LABEL_RE)
if (withLabel) {
return { id: withLabel[1]!, label: stripQuotes(withLabel[2]!) }
}
const label = stripQuotes(trimmed)
return { id: normalizeSubgraphId(trimmed, index), label }
}
function parseNodeToken(token: string): FlowchartNode {
const trimmed = token.trim().replace(/;$/, "")
const database = trimmed.match(DATABASE_NODE_RE)
if (database) return { id: database[1]!, label: stripQuotes(database[2]!), shape: "database" }
const subroutine = trimmed.match(SUBROUTINE_NODE_RE)
if (subroutine) return { id: subroutine[1]!, label: stripQuotes(subroutine[2]!), shape: "subroutine" }
const roundedBracket = trimmed.match(ROUNDED_BRACKET_NODE_RE)
if (roundedBracket) return { id: roundedBracket[1]!, label: stripQuotes(roundedBracket[2]!), shape: "rounded" }
const rounded = trimmed.match(ROUNDED_NODE_RE)
if (rounded) return { id: rounded[1]!, label: stripQuotes(rounded[2]!), shape: "rounded" }
const decision = trimmed.match(DECISION_NODE_RE)
if (decision) return { id: decision[1]!, label: stripQuotes(decision[2]!), shape: "decision" }
const box = trimmed.match(BOX_NODE_RE)
if (box) return { id: box[1]!, label: stripQuotes(box[2]!), shape: "box" }
return { id: trimmed, label: trimmed, shape: "box" }
}
function hasExplicitNodeShape(token: string): boolean {
return EXPLICIT_NODE_SHAPE_RE.test(token.trim())
}
function ensureNode(nodes: Map<string, FlowchartNode>, token: string): FlowchartNode {
const node = parseNodeToken(token)
const existing = nodes.get(node.id)
if (!existing) {
nodes.set(node.id, node)
return node
}
if (hasExplicitNodeShape(token)) {
existing.label = node.label
existing.shape = node.shape
}
return existing
}
function addNodeToSubgraph(subgraph: FlowchartSubgraph | undefined, nodeId: string): void {
if (!subgraph || subgraph.nodeIds.includes(nodeId)) return
subgraph.nodeIds.push(nodeId)
}
function stripNodeToken(token: string): string {
return token
.replace(/\s*:::.*$/, "")
.replace(/;$/, "")
.trim()
}
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
return undefined
}
function createEdge(from: string, to: string, label: string, style: FlowchartEdgeStyle | undefined): FlowchartEdge {
return style ? { from, to, label, style } : { from, to, label }
}
export function isMermaidFlowchartDiagram(content: string): boolean {
return FLOWCHART_HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
}
export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram {
const nodes = new Map<string, FlowchartNode>()
const edges: FlowchartEdge[] = []
const subgraphs: FlowchartSubgraph[] = []
const subgraphStack: Array<{ subgraph: FlowchartSubgraph; lineNumber: number; sourceLine: string }> = []
let direction: FlowchartDirection = DEFAULT_DIRECTION
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = source.text
const header = line.match(FLOWCHART_HEADER_RE)
if (header) {
direction = normalizeDirection(header[2])
continue
}
// Mermaid CSS styling does not apply to terminal theme rendering.
if (IGNORED_PRESENTATION_RE.test(line)) continue
const subgraphMatch = line.match(SUBGRAPH_RE)
if (subgraphMatch) {
const parsed = parseSubgraphToken(subgraphMatch[1]!, subgraphs.length)
const subgraph: FlowchartSubgraph = {
...parsed,
nodeIds: [],
parentId: subgraphStack[subgraphStack.length - 1]?.subgraph.id,
}
subgraphs.push(subgraph)
subgraphStack.push({ subgraph, lineNumber: source.lineNumber, sourceLine: line })
continue
}
if (/^end$/i.test(line)) {
if (subgraphStack.length === 0) {
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, 'Unexpected "end" without an open subgraph')
}
subgraphStack.pop()
continue
}
const currentSubgraph = subgraphStack[subgraphStack.length - 1]?.subgraph
const subgraphDirection = line.match(SUBGRAPH_DIRECTION_RE)
if (subgraphDirection) {
if (!currentSubgraph) {
throw new MermaidSyntaxError(
"flowchart",
source.lineNumber,
line,
'A "direction" statement requires an open subgraph',
)
}
currentSubgraph.direction = normalizeDirection(subgraphDirection[1])
continue
}
const pipeEdge = line.match(/^(.+?)\s*(-->|==>|-\.->)\s*(?:\|([^|]*)\|\s*)?(.+)$/)
const textEdge = line.match(/^(.+?)\s*(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->)\s*(.+)$/)
const edgeMatch = textEdge ?? pipeEdge
if (edgeMatch) {
const from = ensureNode(nodes, stripNodeToken(edgeMatch[1]!))
const toToken = textEdge ? edgeMatch[5]! : edgeMatch[4]!
const to = ensureNode(nodes, stripNodeToken(toToken))
addNodeToSubgraph(currentSubgraph, from.id)
addNodeToSubgraph(currentSubgraph, to.id)
const arrow = textEdge ? edgeMatch[4]! : edgeMatch[2]!
const label = textEdge ? edgeMatch[3]! : (edgeMatch[3] ?? "")
edges.push(createEdge(from.id, to.id, label.trim(), edgeStyleFromArrow(textEdge ? edgeMatch[2]! : arrow, arrow)))
continue
}
if (hasExplicitNodeShape(line)) {
const node = ensureNode(nodes, line)
addNodeToSubgraph(currentSubgraph, node.id)
continue
}
throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
}
const unclosedSubgraph = subgraphStack[subgraphStack.length - 1]
if (unclosedSubgraph) {
throw new MermaidSyntaxError(
"flowchart",
unclosedSubgraph.lineNumber,
unclosedSubgraph.sourceLine,
'Unclosed subgraph; expected "end"',
)
}
return { direction, nodes: [...nodes.values()], edges, subgraphs }
}
-15
View File
@@ -1,15 +0,0 @@
import { drawFlowchartDiagramGrid } from "./drawing.js"
import type { FlowchartDiagramAnsiOptions, FlowchartDiagramRenderOptions } from "./options.js"
import { parseMermaidFlowchartDiagram } from "./parser.js"
import { renderGridAnsi } from "./style.js"
export function renderFlowchartDiagram(content: string, options: FlowchartDiagramRenderOptions = {}): string {
return drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options).toString({
trimTop: true,
trimBottom: true,
})
}
export function renderFlowchartDiagramAnsi(content: string, options: FlowchartDiagramAnsiOptions = {}): string {
return renderGridAnsi(drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options), options.theme)
}
-269
View File
@@ -1,269 +0,0 @@
import { RGBA, type BorderStyle, type ColorInput, type RenderContext } from "@opentui/core"
import { DiagramRenderable } from "../core/adapter/diagram-renderable.js"
import { parseDiagramRenderableColor } from "../core/adapter/renderable-color.js"
import { diagramColorMapsEqual, normalizeDiagramColorMap } from "../core/color/map.js"
import { DEFAULT_BORDER_STYLE, drawFlowchartDiagramGrid } from "./drawing.js"
import type { FlowchartDiagramRenderOptions, FlowchartDiagramOptions } from "./options.js"
import { parseMermaidFlowchartDiagram } from "./parser.js"
import {
renderGridStyledText,
resolveFlowchartStyleColors,
type FlowchartGrid,
type FlowchartNodeColors,
} from "./style.js"
import type { FlowchartActiveEdgeSelection, FlowchartDiagram, FlowchartDirection, FlowchartEdge } from "./types.js"
interface IndexedFlowchartEdge {
edge: FlowchartEdge
index: number
}
function flowchartActiveEdgesEqual(
left: FlowchartActiveEdgeSelection | undefined,
right: FlowchartActiveEdgeSelection | undefined,
): boolean {
return left?.from === right?.from && left?.to === right?.to && left?.index === right?.index
}
export class FlowchartDiagramRenderable extends DiagramRenderable<FlowchartDiagram, FlowchartGrid> {
private _compact: boolean
private _direction?: FlowchartDirection
private _borderStyle: BorderStyle
private _minNodeGap?: number
private _minRankGap?: number
private _layoutMaxWidth?: number
private _nodeColor?: RGBA
private _databaseColor?: RGBA
private _edgeColor?: RGBA
private _activeNodeColor?: RGBA
private _activeEdgeColor?: RGBA
private _nodeColors: Map<string, RGBA>
private _nodeBgColors: Map<string, RGBA>
private _labelColor?: RGBA
private _groupColor?: RGBA
private _activeNode?: string
private _activeEdge?: FlowchartActiveEdgeSelection
private _selectedConnectionIndex = 0
constructor(ctx: RenderContext, options: FlowchartDiagramOptions = {}) {
super(ctx, options)
this._compact = options.compact ?? false
this._direction = options.direction
this._borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
this._minNodeGap = options.minNodeGap
this._minRankGap = options.minRankGap
this._layoutMaxWidth = options.layoutMaxWidth
this._nodeColor = parseDiagramRenderableColor(options.nodeColor)
this._databaseColor = parseDiagramRenderableColor(options.databaseColor)
this._edgeColor = parseDiagramRenderableColor(options.edgeColor)
this._activeNodeColor = parseDiagramRenderableColor(options.activeNodeColor)
this._activeEdgeColor = parseDiagramRenderableColor(options.activeEdgeColor)
this._nodeColors = normalizeDiagramColorMap(options.nodeColors)
this._nodeBgColors = normalizeDiagramColorMap(options.nodeBgColors)
this._labelColor = parseDiagramRenderableColor(options.labelColor)
this._groupColor = parseDiagramRenderableColor(options.groupColor)
this._activeNode = options.activeNode
this._activeEdge = options.activeEdge
this.initializeDiagram({
parse: () => parseMermaidFlowchartDiagram(this.content),
draw: (diagram) => drawFlowchartDiagramGrid(diagram, this.renderOptions()),
publish: (grid) => this.styledText(grid),
measure: { trimTop: true, trimBottom: true },
})
}
protected override contentChanged(): void {
this._activeNode = undefined
this._activeEdge = undefined
this._selectedConnectionIndex = 0
}
get compact(): boolean {
return this._compact
}
set compact(value: boolean) {
if (this._compact === value) return
this._compact = value
this.invalidateGrid()
}
set direction(value: FlowchartDirection | undefined) {
if (this._direction === value) return
this._direction = value
this.invalidateGrid()
}
set borderStyle(value: BorderStyle | undefined) {
const next = value ?? DEFAULT_BORDER_STYLE
if (this._borderStyle === next) return
this._borderStyle = next
this.invalidateGrid()
}
get layoutMaxWidth(): number | undefined {
return this._layoutMaxWidth
}
set layoutMaxWidth(value: number | undefined) {
if (this._layoutMaxWidth === value) return
this._layoutMaxWidth = value
this.invalidateGrid()
}
set nodeColor(value: ColorInput | undefined) {
this.setColor(this._nodeColor, value, (color) => (this._nodeColor = color))
}
set databaseColor(value: ColorInput | undefined) {
this.setColor(this._databaseColor, value, (color) => (this._databaseColor = color))
}
set edgeColor(value: ColorInput | undefined) {
this.setColor(this._edgeColor, value, (color) => (this._edgeColor = color))
}
set activeNodeColor(value: ColorInput | undefined) {
this.setColor(this._activeNodeColor, value, (color) => (this._activeNodeColor = color))
}
set activeEdgeColor(value: ColorInput | undefined) {
this.setColor(this._activeEdgeColor, value, (color) => (this._activeEdgeColor = color))
}
set nodeColors(value: FlowchartNodeColors | undefined) {
const next = normalizeDiagramColorMap(value)
if (diagramColorMapsEqual(this._nodeColors, next)) return
this._nodeColors = next
this.invalidateStyle()
}
set nodeBgColors(value: FlowchartNodeColors | undefined) {
const next = normalizeDiagramColorMap(value)
if (diagramColorMapsEqual(this._nodeBgColors, next)) return
this._nodeBgColors = next
this.invalidateStyle()
}
get activeNode(): string | undefined {
return this._activeNode
}
set activeNode(value: string | undefined) {
if (this._activeNode === value) return
this._activeNode = value
this._activeEdge = undefined
this._selectedConnectionIndex = 0
this.invalidateGrid()
}
get selectedConnectionIndex(): number {
return this._selectedConnectionIndex
}
get selectedConnection(): FlowchartActiveEdgeSelection | undefined {
const selected = this.selectedOutgoingEdge()
return selected ? { from: selected.edge.from, to: selected.edge.to, index: selected.index } : undefined
}
get activeEdge(): FlowchartActiveEdgeSelection | undefined {
return this._activeEdge
}
set activeEdge(value: FlowchartActiveEdgeSelection | undefined) {
if (flowchartActiveEdgesEqual(this._activeEdge, value)) return
this._activeEdge = value ? { ...value } : undefined
this.invalidateGrid()
}
set labelColor(value: ColorInput | undefined) {
this.setColor(this._labelColor, value, (color) => (this._labelColor = color))
}
set groupColor(value: ColorInput | undefined) {
this.setColor(this._groupColor, value, (color) => (this._groupColor = color))
}
activateFirstNode(): string | undefined {
if (this._activeNode) return this._activeNode
const node = this.parsedDiagram().nodes[0]
if (!node) return undefined
this.activeNode = node.id
return node.id
}
selectNextConnection(): FlowchartActiveEdgeSelection | undefined {
return this.selectConnection(1)
}
selectPreviousConnection(): FlowchartActiveEdgeSelection | undefined {
return this.selectConnection(-1)
}
private selectConnection(delta: 1 | -1): FlowchartActiveEdgeSelection | undefined {
this.activateFirstNode()
const outgoing = this.activeOutgoingEdges()
if (outgoing.length === 0) return undefined
this._activeEdge = undefined
if (outgoing.length === 1) {
this.invalidateGrid()
return this.selectedConnection
}
this._selectedConnectionIndex = (this._selectedConnectionIndex + delta + outgoing.length) % outgoing.length
this.invalidateGrid()
return this.selectedConnection
}
followSelectedConnection(): string | undefined {
const selected = this.selectedOutgoingEdge()
if (!selected) return undefined
this._activeNode = selected.edge.to
this._activeEdge = undefined
this._selectedConnectionIndex = 0
this.invalidateGrid()
return selected.edge.to
}
private activeOutgoingEdges(): IndexedFlowchartEdge[] {
if (!this._activeNode) return []
return this.parsedDiagram().edges.flatMap((edge, index) =>
edge.from === this._activeNode ? [{ edge, index }] : [],
)
}
private selectedOutgoingEdge(): IndexedFlowchartEdge | undefined {
const outgoing = this.activeOutgoingEdges()
if (outgoing.length === 0) return undefined
const index = ((this._selectedConnectionIndex % outgoing.length) + outgoing.length) % outgoing.length
return outgoing[index]
}
private renderOptions(): FlowchartDiagramRenderOptions {
return {
compact: this._compact,
direction: this._direction,
borderStyle: this._borderStyle,
minNodeGap: this._minNodeGap,
minRankGap: this._minRankGap,
layoutMaxWidth: this._layoutMaxWidth,
activeNode: this._activeNode,
activeEdge: this._activeEdge ?? this.selectedConnection,
}
}
private styledText(grid: FlowchartGrid) {
return renderGridStyledText(
grid,
resolveFlowchartStyleColors({
node: this._nodeColor,
database: this._databaseColor,
edge: this._edgeColor,
activeNode: this._activeNodeColor,
activeEdge: this._activeEdgeColor,
label: this._labelColor,
group: this._groupColor,
}),
this._nodeColors,
this._nodeBgColors,
)
}
}
@@ -1,272 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { FlowchartDiagram, FlowchartNodeBounds } from "./types.js"
import { routeFlowchartEdges } from "./routing.js"
function bounds(id: string, left: number, top: number): FlowchartNodeBounds {
const width = 5
const height = 3
return {
id,
width,
height,
lines: [id],
left,
top,
centerX: left + Math.floor(width / 2),
centerY: top + Math.floor(height / 2),
}
}
function diagram(direction: FlowchartDiagram["direction"], edges: FlowchartDiagram["edges"]): FlowchartDiagram {
return { direction, nodes: [], edges, subgraphs: [] }
}
describe("flowchart routing", () => {
test("routes a simple horizontal edge from source port to target port", () => {
const edge = { from: "A", to: "B", label: "" }
const routes = routeFlowchartEdges(
diagram("LR", [edge]),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 20, 0)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 5, y: 1 },
{ x: 19, y: 1 },
],
},
])
})
test("routes a simple reverse horizontal edge into the target right port", () => {
const edge = { from: "A", to: "B", label: "" }
const routes = routeFlowchartEdges(
diagram("RL", [edge]),
new Map([
["A", bounds("A", 20, 0)],
["B", bounds("B", 0, 0)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 19, y: 1 },
{ x: 5, y: 1 },
],
},
])
})
test("routes horizontal back-edges above forward lanes", () => {
const edge = { from: "B", to: "A", label: "" }
const routes = routeFlowchartEdges(
diagram("LR", [edge]),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 20, 0)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 22, y: -1 },
{ x: 22, y: -4 },
{ x: 2, y: -4 },
{ x: 2, y: -1 },
],
},
])
})
test("routes parallel horizontal edges on independent lanes", () => {
const edges = [
{ from: "A", to: "B", label: "first" },
{ from: "A", to: "B", label: "second" },
]
const routes = routeFlowchartEdges(
diagram("LR", edges),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 20, 0)],
]),
)
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 },
],
])
})
test("spaces parallel horizontal lanes for multiline labels", () => {
const edges = [
{ from: "A", to: "B", label: "first" },
{ from: "A", to: "B", label: "second 1<br/>second 2<br/>second 3" },
{ from: "A", to: "B", label: "third 1<br/>third 2<br/>third 3" },
]
const routes = routeFlowchartEdges(
diagram("LR", edges),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 20, 0)],
]),
)
expect(routes[2]!.points[1]!.y).toBeGreaterThan(routes[1]!.points[1]!.y + 3)
})
test("routes horizontal fan-out through a shared bus lane", () => {
const edges = [
{ from: "A", to: "B", label: "" },
{ from: "A", to: "C", label: "" },
]
const routes = routeFlowchartEdges(
diagram("LR", edges),
new Map([
["A", bounds("A", 0, 6)],
["B", bounds("B", 20, 0)],
["C", bounds("C", 20, 12)],
]),
)
expect(routes.map((route) => route.points)).toEqual([
[
{ x: 5, y: 7 },
{ x: 8, y: 7 },
{ x: 8, y: 1 },
{ x: 19, y: 1 },
],
[
{ x: 5, y: 7 },
{ x: 8, y: 7 },
{ x: 8, y: 13 },
{ x: 19, y: 13 },
],
])
})
test("routes each horizontal edge once when fan-in and fan-out overlap", () => {
const edges = [
{ from: "A", to: "C", label: "" },
{ from: "A", to: "D", label: "" },
{ from: "B", to: "C", label: "" },
{ from: "B", to: "D", label: "" },
]
const routes = routeFlowchartEdges(
diagram("LR", edges),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 0, 12)],
["C", bounds("C", 24, 0)],
["D", bounds("D", 24, 12)],
]),
)
expect(routes).toHaveLength(edges.length)
expect(routes.map((route) => `${route.edge.from}->${route.edge.to}`).sort()).toEqual([
"A->C",
"A->D",
"B->C",
"B->D",
])
})
test("routes vertical back-edges around the left side", () => {
const edge = { from: "B", to: "A", label: "" }
const routes = routeFlowchartEdges(
diagram("TD", [edge]),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 0, 12)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: -1, y: 13 },
{ x: -4, y: 13 },
{ x: -4, y: 1 },
{ x: -1, y: 1 },
],
},
])
})
test("routes self edges below the source node", () => {
const edge = { from: "A", to: "A", label: "" }
const routes = routeFlowchartEdges(diagram("TD", [edge]), new Map([["A", bounds("A", 0, 0)]]))
expect(routes).toEqual([
{
edge,
points: [
{ x: 5, y: 1 },
{ x: 8, y: 1 },
{ x: 8, y: 4 },
{ x: 2, y: 4 },
{ x: 2, y: 3 },
],
},
])
})
test("routes same-column horizontal-flow edges through vertical ports", () => {
const edge = { from: "A", to: "B", label: "rollback" }
const routes = routeFlowchartEdges(
diagram("LR", [edge]),
new Map([
["A", bounds("A", 20, 0)],
["B", bounds("B", 20, 8)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 22, y: 3 },
{ x: 22, y: 7 },
],
},
])
})
test("routes overlapping horizontal-flow columns through vertical ports", () => {
const edge = { from: "A", to: "B", label: "merge" }
const routes = routeFlowchartEdges(
diagram("RL", [edge]),
new Map([
["A", bounds("A", 1, 8)],
["B", bounds("B", 0, 0)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 2, y: 7 },
{ x: 2, y: 3 },
],
},
])
})
})
-703
View File
@@ -1,703 +0,0 @@
import {
advanceCoordinate,
afterFarthestCoordinate,
beforeNearestCoordinate,
boundsCenter,
boundsSidePoint,
centerCoordinate,
coordinate,
keepAfter,
keepBefore,
lane,
oppositeSide,
orthogonalPath,
pathThrough,
pathViaLane,
sideForDirection,
snapCoordinate,
withCoordinate,
type DiagramAxis,
type DiagramDirection,
type DiagramLane,
type DiagramSide,
} from "../core/geometry.js"
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
import { flowchartEdgeLabelLayout } from "./labels.js"
import type {
FlowchartDiagram,
FlowchartDirection,
FlowchartEdge,
FlowchartEdgeRoute,
FlowchartNodeBounds,
FlowchartPoint,
FlowchartSubgraph,
FlowchartSubgraphBounds,
} from "./types.js"
export { directionBetween as flowchartDirectionBetween } from "../core/geometry.js"
const BUS_CLEARANCE = 3
const NODE_CLEARANCE = 2
type HorizontalTravel = Extract<DiagramDirection, "left" | "right">
type VerticalTravel = Extract<DiagramDirection, "up" | "down">
type PortRole = "source" | "target"
interface EdgeRecord {
edge: FlowchartEdge
sourcePort: FlowchartPoint
targetPort: FlowchartPoint
}
function isVerticalDirection(direction: FlowchartDirection): boolean {
return direction === "TB" || direction === "TD" || direction === "BT"
}
function verticalTravel(from: FlowchartNodeBounds, to: FlowchartNodeBounds): VerticalTravel {
return centerCoordinate(to, "y") >= centerCoordinate(from, "y") ? "down" : "up"
}
function isVerticalBackEdge(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
): boolean {
return direction === "BT"
? centerCoordinate(to, "y") > centerCoordinate(from, "y")
: centerCoordinate(to, "y") < centerCoordinate(from, "y")
}
function isHorizontalBackEdge(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
): boolean {
return direction === "RL"
? centerCoordinate(to, "x") > centerCoordinate(from, "x")
: centerCoordinate(to, "x") < centerCoordinate(from, "x")
}
function horizontalTravel(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
): HorizontalTravel {
const targetIsRight = centerCoordinate(to, "x") > centerCoordinate(from, "x")
const targetIsSameOrRight = centerCoordinate(to, "x") >= centerCoordinate(from, "x")
return direction === "RL" ? (targetIsRight ? "right" : "left") : targetIsSameOrRight ? "right" : "left"
}
function verticalBackEdgePath(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
leftBoundary?: number,
): FlowchartPoint[] {
const start = boundsSidePoint(from, "left")
const end = boundsSidePoint(to, "left")
const busX = Math.min(
afterFarthestCoordinate([start, end], "x", "left", BUS_CLEARANCE),
leftBoundary === undefined ? Number.POSITIVE_INFINITY : leftBoundary - BUS_CLEARANCE * 2,
)
return pathViaLane(start, lane("x", busX), end)
}
function verticalForwardEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
const travel = verticalTravel(from, to)
const startSide = sideForDirection(travel)
const endSide = oppositeSide(startSide)
const sourceCenter = boundsCenter(from)
const targetCenter = boundsCenter(to)
const start = withCoordinate(boundsSidePoint(from, startSide), "x", snapCoordinate(sourceCenter.x, targetCenter.x, 1))
const end = boundsSidePoint(to, endSide)
return orthogonalPath(start, end, { preferredAxis: "y" })
}
function horizontalBackEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
const start = boundsSidePoint(from, "top")
const end = boundsSidePoint(to, "top")
const busY = afterFarthestCoordinate([start, end], "y", "up", BUS_CLEARANCE)
return pathViaLane(start, lane("y", busY), end)
}
function horizontalEdgePath(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
): FlowchartPoint[] {
const overlapsHorizontally = from.left < to.left + to.width && to.left < from.left + from.width
if (overlapsHorizontally) return verticalForwardEdgePath(from, to)
if (isHorizontalBackEdge(from, to, direction)) return horizontalBackEdgePath(from, to)
const travel = horizontalTravel(from, to, direction)
const startSide = sideForDirection(travel)
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)))
}
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
const start = boundsSidePoint(bounds, "right")
const end = boundsSidePoint(bounds, "bottom")
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE
const bottomLaneY = bounds.top + bounds.height + 1
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
}
function parallelEdgePath(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
laneCoordinate: number,
): FlowchartPoint[] {
if (!isVerticalDirection(direction)) {
const start = boundsSidePoint(from, "bottom")
const end = boundsSidePoint(to, "bottom")
return pathViaLane(start, lane("y", laneCoordinate), end)
}
const start = boundsSidePoint(from, "right")
const end = boundsSidePoint(to, "right")
return pathViaLane(start, lane("x", laneCoordinate), end)
}
function labelHeight(edge: FlowchartEdge): number {
return edge.label ? splitDiagramLines(edge.label).length : 0
}
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)
right = Math.max(right, label.point.x + label.width - 1)
}
return right
}
function edgePath(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
leftBoundary?: number,
): FlowchartPoint[] {
if (from.id === to.id) return selfEdgePath(from)
if (!isVerticalDirection(direction)) return horizontalEdgePath(from, to, direction)
return isVerticalBackEdge(from, to, direction)
? verticalBackEdgePath(from, to, leftBoundary)
: verticalForwardEdgePath(from, to)
}
function sourceFanOutLane(
sourcePort: FlowchartPoint,
targetPorts: readonly FlowchartPoint[],
axis: DiagramAxis,
travel: DiagramDirection,
): number {
const sourceCoordinate = coordinate(sourcePort, axis)
const unclamped = keepBefore(
advanceCoordinate(coordinate(sourcePort, axis), travel, BUS_CLEARANCE),
beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE),
travel,
)
return keepAfter(unclamped, sourceCoordinate, travel)
}
function targetFanInLane(
sourcePorts: readonly FlowchartPoint[],
targetPort: FlowchartPoint,
axis: DiagramAxis,
travel: DiagramDirection,
): number {
const targetCoordinate = coordinate(targetPort, axis)
const unclamped = keepAfter(
advanceCoordinate(coordinate(targetPort, axis), travel, -BUS_CLEARANCE),
afterFarthestCoordinate(sourcePorts, axis, travel, NODE_CLEARANCE),
travel,
)
return keepBefore(unclamped, targetCoordinate, travel)
}
function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint {
const side = role === "source" ? sideForDirection(travel) : oppositeSide(sideForDirection(travel))
return boundsSidePoint(bounds, side)
}
function horizontalForwardRecords(
edges: FlowchartEdge[],
bounds: Map<string, FlowchartNodeBounds>,
direction: FlowchartDirection,
): EdgeRecord[] {
const travel = direction === "RL" ? "left" : "right"
const records: EdgeRecord[] = []
for (const edge of edges) {
const source = bounds.get(edge.from)
const target = bounds.get(edge.to)
if (!source || !target) continue
const forward =
direction === "RL"
? centerCoordinate(target, "x") < centerCoordinate(source, "x")
: centerCoordinate(target, "x") > centerCoordinate(source, "x")
if (!forward) continue
records.push({
edge,
sourcePort: portForTravel(source, travel, "source"),
targetPort: portForTravel(target, travel, "target"),
})
}
return records
}
function verticalForwardRecords(
edges: FlowchartEdge[],
bounds: Map<string, FlowchartNodeBounds>,
direction: FlowchartDirection,
): EdgeRecord[] {
const travel = direction === "BT" ? "up" : "down"
const records: EdgeRecord[] = []
for (const edge of edges) {
const source = bounds.get(edge.from)
const target = bounds.get(edge.to)
if (!source || !target) continue
const forward =
direction === "BT"
? centerCoordinate(target, "y") < centerCoordinate(source, "y")
: centerCoordinate(target, "y") > centerCoordinate(source, "y")
if (!forward) continue
records.push({
edge,
sourcePort: portForTravel(source, travel, "source"),
targetPort: portForTravel(target, travel, "target"),
})
}
return records
}
function horizontalExitSubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
if (subgraph.nodeIds.includes(edge.from) && !subgraph.nodeIds.includes(edge.to)) return subgraph
}
return undefined
}
function horizontalEntrySubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
if (subgraph.nodeIds.includes(edge.to) && !subgraph.nodeIds.includes(edge.from)) return subgraph
}
return undefined
}
function horizontalSubgraphEntryTravel(subgraph: FlowchartSubgraph): HorizontalTravel {
return subgraph.direction === "RL" ? "left" : "right"
}
function horizontalSubgraphEntryLane(subgraph: FlowchartSubgraph, subgraphBound: FlowchartSubgraphBounds): number {
return subgraph.direction === "RL"
? subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
: subgraphBound.left - BUS_CLEARANCE
}
function horizontalSubgraphJoinY(from: FlowchartSubgraphBounds, targetSubgraphBound: FlowchartSubgraphBounds): number {
if (from.centerY <= targetSubgraphBound.centerY) {
const start = from.top + from.height
const end = targetSubgraphBound.top - 1
return start <= end ? Math.floor((start + end) / 2) : start
}
const start = targetSubgraphBound.top + targetSubgraphBound.height
const end = from.top - 1
return start <= end ? Math.floor((start + end) / 2) : end
}
function horizontalSubgraphExitJoinY(
from: FlowchartSubgraphBounds,
targetPort: FlowchartPoint,
targetBelow: boolean,
): number {
if (targetBelow) {
const outside = from.top + from.height
const beforeTarget = targetPort.y - 1
const preferred = targetPort.y - BUS_CLEARANCE
return outside <= beforeTarget ? Math.min(Math.max(outside, preferred), beforeTarget) : beforeTarget
}
const outside = from.top - 1
const afterTarget = targetPort.y + 1
const preferred = targetPort.y + BUS_CLEARANCE
return afterTarget <= outside ? Math.max(Math.min(outside, preferred), afterTarget) : afterTarget
}
function groupRecords<Record>(records: readonly Record[], key: (record: Record) => string): Map<string, Record[]> {
const groups = new Map<string, Record[]>()
for (const record of records) {
const groupKey = key(record)
const group = groups.get(groupKey) ?? []
group.push(record)
groups.set(groupKey, group)
}
return groups
}
function fanRoute(
edge: FlowchartEdge,
sourcePort: FlowchartPoint,
targetPort: FlowchartPoint,
routeLane: DiagramLane,
): FlowchartEdgeRoute {
return { edge, points: pathViaLane(sourcePort, routeLane, targetPort) }
}
function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeRecord[] {
const xs = records.map((record) => record.sourcePort.x)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
if (maxX - minX > 1) return [...records]
const x = Math.round(xs.reduce((total, value) => total + value, 0) / xs.length)
return records.map((record) => ({ ...record, sourcePort: { ...record.sourcePort, x } }))
}
function routeHorizontalFanOut(
records: readonly EdgeRecord[],
direction: FlowchartDirection,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
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)
for (const record of sourceRecords) {
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX)))
handled.add(record.edge)
}
}
}
function routeHorizontalFanIn(
records: readonly EdgeRecord[],
direction: FlowchartDirection,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
for (const targetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
if (targetRecords.length < 2) continue
const travel = direction === "RL" ? "left" : "right"
const targetPort = targetRecords[0]!.targetPort
const sourcePorts = targetRecords.map((record) => record.sourcePort)
const busX = targetFanInLane(sourcePorts, targetPort, "x", travel)
for (const record of targetRecords) {
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("x", busX)))
handled.add(record.edge)
}
}
}
function routeVerticalFanOut(
records: readonly EdgeRecord[],
direction: FlowchartDirection,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
if (sourceRecords.length < 2) continue
const travel = direction === "BT" ? "up" : "down"
const sourcePort = sourceRecords[0]!.sourcePort
const targetPorts = sourceRecords.map((record) => record.targetPort)
const busY = sourceFanOutLane(sourcePort, targetPorts, "y", travel)
for (const record of sourceRecords) {
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("y", busY)))
handled.add(record.edge)
}
}
}
function routeVerticalFanIn(
records: readonly EdgeRecord[],
direction: FlowchartDirection,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
for (const unalignedTargetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
const targetRecords = alignClusteredVerticalSources(unalignedTargetRecords)
if (targetRecords.length < 2) continue
const travel = direction === "BT" ? "up" : "down"
const targetPort = targetRecords[0]!.targetPort
const sourcePorts = targetRecords.map((record) => record.sourcePort)
const busY = targetFanInLane(sourcePorts, targetPort, "y", travel)
for (const record of targetRecords) {
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("y", busY)))
handled.add(record.edge)
}
}
}
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}`)
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 route = { edge, points: parallelEdgePath(from, to, direction, laneCoordinate) }
routes.push(route)
handled.add(edge)
previousRoute = route
}
}
}
function routeHorizontalSubgraphExitFanIn(
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
if (!subgraphBounds) return
const groups = new Map<string, { edge: FlowchartEdge; subgraph: FlowchartSubgraph; source: FlowchartNodeBounds }[]>()
for (const edge of diagram.edges) {
if (handled.has(edge)) continue
const subgraph = horizontalExitSubgraph(diagram, edge)
const source = bounds.get(edge.from)
const target = bounds.get(edge.to)
if (!subgraph || !source || !target) continue
const key = `${subgraph.id}:${edge.to}`
const group = groups.get(key) ?? []
group.push({ edge, subgraph, source })
groups.set(key, group)
}
for (const group of groups.values()) {
const subgraph = group[0]!.subgraph
const subgraphBound = subgraphBounds.get(subgraph.id)
const target = bounds.get(group[0]!.edge.to)
if (!subgraphBound || !target) continue
const travel: HorizontalTravel = subgraph.direction === "RL" ? "left" : "right"
const busX =
subgraph.direction === "RL"
? subgraphBound.left - BUS_CLEARANCE
: subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
const targetSubgraph = horizontalEntrySubgraph(diagram, group[0]!.edge)
const targetSubgraphBound = targetSubgraph ? subgraphBounds.get(targetSubgraph.id) : undefined
const targetBelow = target.centerY >= subgraphBound.centerY
const targetPort = targetSubgraph
? portForTravel(target, horizontalSubgraphEntryTravel(targetSubgraph), "target")
: boundsSidePoint(target, targetBelow ? "top" : "bottom")
const joinY = targetSubgraphBound
? horizontalSubgraphJoinY(subgraphBound, targetSubgraphBound)
: horizontalSubgraphExitJoinY(subgraphBound, targetPort, targetBelow)
const entryX =
targetSubgraph && targetSubgraphBound
? horizontalSubgraphEntryLane(targetSubgraph, targetSubgraphBound)
: targetPort.x
for (const record of group) {
const sourcePort = portForTravel(record.source, travel, "source")
routes.push({
edge: record.edge,
points: pathThrough([
sourcePort,
{ x: busX, y: sourcePort.y },
{ x: busX, y: joinY },
{ x: entryX, y: joinY },
{ x: entryX, y: targetPort.y },
targetPort,
]),
})
handled.add(record.edge)
}
}
}
function routeHorizontalSubgraphEntries(
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
if (!subgraphBounds) return
for (const edge of diagram.edges) {
if (handled.has(edge)) continue
const subgraph = horizontalEntrySubgraph(diagram, edge)
const subgraphBound = subgraph ? subgraphBounds.get(subgraph.id) : undefined
const from = bounds.get(edge.from)
const to = bounds.get(edge.to)
if (!subgraph || !subgraphBound || !from || !to) continue
const targetPort = portForTravel(to, horizontalSubgraphEntryTravel(subgraph), "target")
const entryX = horizontalSubgraphEntryLane(subgraph, subgraphBound)
const travel = verticalTravel(from, to)
const sourcePort = portForTravel(from, travel, "source")
routes.push({
edge,
points: pathThrough([sourcePort, { x: entryX, y: sourcePort.y }, { x: entryX, y: targetPort.y }, targetPort]),
})
handled.add(edge)
}
}
function pathIntersectsBounds(points: readonly FlowchartPoint[], bounds: FlowchartNodeBounds): boolean {
const right = bounds.left + bounds.width - 1
const bottom = bounds.top + bounds.height - 1
for (let index = 1; index < points.length; index++) {
const from = points[index - 1]!
const to = points[index]!
if (from.x === to.x) {
if (
from.x >= bounds.left &&
from.x <= right &&
Math.max(from.y, to.y) >= bounds.top &&
Math.min(from.y, to.y) <= bottom
) {
return true
}
continue
}
if (
from.y >= bounds.top &&
from.y <= bottom &&
Math.max(from.x, to.x) >= bounds.left &&
Math.min(from.x, to.x) <= right
) {
return true
}
}
return false
}
function avoidNodeObstacles(
route: FlowchartEdgeRoute,
bounds: Map<string, FlowchartNodeBounds>,
direction: FlowchartDirection,
): FlowchartEdgeRoute {
const obstacle = [...bounds.values()].some(
(bound) => bound.id !== route.edge.from && bound.id !== route.edge.to && pathIntersectsBounds(route.points, bound),
)
if (!obstacle) return route
const from = bounds.get(route.edge.from)
const to = bounds.get(route.edge.to)
if (!from || !to) return route
if (isVerticalDirection(direction)) {
const start = boundsSidePoint(from, "right")
const end = boundsSidePoint(to, "right")
const busX = Math.max(...[...bounds.values()].map((bound) => bound.left + bound.width - 1)) + BUS_CLEARANCE
return { edge: route.edge, points: pathViaLane(start, lane("x", busX), end) }
}
const start = boundsSidePoint(from, "top")
const end = boundsSidePoint(to, "top")
const busY = Math.min(...[...bounds.values()].map((bound) => bound.top)) - BUS_CLEARANCE
return { edge: route.edge, points: pathViaLane(start, lane("y", busY), end) }
}
export function routeFlowchartEdges(
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
): FlowchartEdgeRoute[] {
const handled = new Set<FlowchartEdge>()
const routes: FlowchartEdgeRoute[] = []
const leftBoundary = subgraphBounds
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
: undefined
routeParallelEdges(diagram, bounds, directionForEdge, leftBoundary, handled, routes)
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
const horizontalEdges = diagram.edges.filter((edge) => !handled.has(edge) && directionForEdge(edge) === direction)
if (horizontalEdges.length === 0) continue
const records = horizontalForwardRecords(horizontalEdges, bounds, direction)
routeHorizontalFanOut(records, direction, handled, routes)
routeHorizontalFanIn(records, direction, handled, routes)
}
routeHorizontalSubgraphExitFanIn(diagram, bounds, subgraphBounds, handled, routes)
routeHorizontalSubgraphEntries(diagram, bounds, subgraphBounds, handled, routes)
for (const direction of ["TD", "TB", "BT"] satisfies FlowchartDirection[]) {
const verticalEdges = diagram.edges.filter((edge) => !handled.has(edge) && directionForEdge(edge) === direction)
if (verticalEdges.length === 0) continue
const records = verticalForwardRecords(verticalEdges, bounds, direction)
routeVerticalFanOut(records, direction, handled, routes)
routeVerticalFanIn(records, direction, handled, routes)
}
for (const edge of diagram.edges) {
if (handled.has(edge)) continue
const from = bounds.get(edge.from)
const to = bounds.get(edge.to)
if (!from || !to) continue
routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
}
return routes.map((route) => avoidNodeObstacles(route, bounds, directionForEdge(route.edge)))
}
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
if (sourcePoint.x < bounds.left) return "left"
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
if (sourcePoint.y < bounds.top) return "top"
return "bottom"
}
function connectorChar(side: DiagramSide): string {
switch (side) {
case "left":
return "┤"
case "right":
return "├"
case "top":
return "┴"
case "bottom":
return "┬"
}
}
export function flowchartSourceConnector(
from: FlowchartNodeBounds,
sourcePoint: FlowchartPoint,
): { x: number; y: number; char: string } {
const side = sideForOutsidePoint(from, sourcePoint)
const connector = boundsSidePoint(from, side, "border")
return {
x: side === "top" || side === "bottom" ? sourcePoint.x : connector.x,
y: side === "left" || side === "right" ? sourcePoint.y : connector.y,
char: connectorChar(side),
}
}
-109
View File
@@ -1,109 +0,0 @@
import { RGBA, type ColorInput, type StyledText } from "@opentui/core"
import type { DiagramCanvas, DiagramCanvasRunOptions } from "../core/canvas.js"
import { diagramCellColorKey, mappedDiagramColor } from "../core/color/map.js"
import { renderDiagramGridAnsi, renderDiagramGridStyledText } from "../core/render-grid.js"
import {
ansiFg,
createColorRampTheme,
createAnsiRampTheme,
DIAGRAM_FADE_STEPS,
numberedStyleKeys,
rgba,
type DiagramFadeStep,
type DiagramRgb,
} from "../core/color/style.js"
export type FlowchartBaseCellStyle = "node" | "activeNode" | "database" | "edge" | "activeEdge" | "label" | "group"
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
export type FlowchartCellStyle = FlowchartBaseCellStyle | FlowchartEdgeFadeStyle
export interface FlowchartCellMetadata {
nodeId?: string
bgNodeId?: string
}
export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle, FlowchartCellMetadata>
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
export type FlowchartDiagramAnsiTheme = Partial<Record<FlowchartCellStyle, string>>
export type FlowchartNodeColorMap = ReadonlyMap<string, RGBA>
export type FlowchartNodeColors = Record<string, ColorInput | undefined> | ReadonlyMap<string, ColorInput | undefined>
export function flowchartNodeColorKey(nodeId: string, level: number): string {
return diagramCellColorKey(nodeId, level)
}
export const DEFAULT_THEME_RGB = {
node: [228, 239, 232],
activeNode: [221, 255, 246],
database: [228, 239, 232],
edge: [134, 225, 200],
activeEdge: [221, 255, 246],
label: [134, 225, 200],
group: [76, 99, 89],
} as const satisfies Record<FlowchartBaseCellStyle, DiagramRgb>
export const NODE_EDGE_FADE_STYLES = numberedStyleKeys("nodeEdgeFade", DIAGRAM_FADE_STEPS)
export const DATABASE_EDGE_FADE_STYLES = numberedStyleKeys("databaseEdgeFade", DIAGRAM_FADE_STEPS)
const DEFAULT_ANSI_THEME: Required<Record<FlowchartCellStyle, string>> = {
node: ansiFg(DEFAULT_THEME_RGB.node),
activeNode: ansiFg(DEFAULT_THEME_RGB.activeNode),
database: ansiFg(DEFAULT_THEME_RGB.database),
edge: ansiFg(DEFAULT_THEME_RGB.edge),
activeEdge: ansiFg(DEFAULT_THEME_RGB.activeEdge),
label: ansiFg(DEFAULT_THEME_RGB.label),
group: ansiFg(DEFAULT_THEME_RGB.group),
...createAnsiRampTheme(NODE_EDGE_FADE_STYLES, DEFAULT_THEME_RGB.node, DEFAULT_THEME_RGB.edge),
...createAnsiRampTheme(DATABASE_EDGE_FADE_STYLES, DEFAULT_THEME_RGB.database, DEFAULT_THEME_RGB.edge),
}
export function resolveFlowchartStyleColors(
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
): FlowchartStyleColors {
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
const activeNode = colors.activeNode ?? rgba(DEFAULT_THEME_RGB.activeNode)
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
const activeEdge = colors.activeEdge ?? rgba(DEFAULT_THEME_RGB.activeEdge)
return {
node,
activeNode,
database,
edge,
activeEdge,
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
...createColorRampTheme(NODE_EDGE_FADE_STYLES, node, edge),
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, database, edge),
}
}
function nodeMappedColor(colors: FlowchartNodeColorMap | undefined, nodeId: string | undefined): RGBA | undefined {
return mappedDiagramColor(colors, nodeId)
}
export function renderGridStyledText(
grid: FlowchartGrid,
colors: FlowchartStyleColors,
nodeColors?: FlowchartNodeColorMap,
nodeBgColors?: FlowchartNodeColorMap,
): StyledText {
const useNodeRuns = Boolean(nodeColors?.size || nodeBgColors?.size)
const runOptions: DiagramCanvasRunOptions<FlowchartCellStyle, FlowchartCellMetadata> = useNodeRuns
? { trimTop: true, trimBottom: true, key: (cell) => [cell.style, cell.nodeId, cell.bgNodeId] }
: { trimTop: true, trimBottom: true }
return renderDiagramGridStyledText(
grid,
(run) => nodeMappedColor(nodeColors, run.cell.nodeId) ?? (run.style ? colors[run.style] : undefined),
(run) => nodeMappedColor(nodeBgColors, run.cell.bgNodeId),
runOptions,
)
}
export function renderGridAnsi(grid: FlowchartGrid, theme: FlowchartDiagramAnsiTheme = {}): string {
const resolved = { ...DEFAULT_ANSI_THEME, ...theme }
return renderDiagramGridAnsi(grid, (run) => (run.style ? resolved[run.style] : undefined), {
trimTop: true,
trimBottom: true,
})
}
-64
View File
@@ -1,64 +0,0 @@
import type { DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
export type FlowchartDirection = "TB" | "TD" | "BT" | "LR" | "RL"
export type FlowchartNodeShape = "box" | "rounded" | "database" | "decision" | "subroutine"
export type FlowchartEdgeStyle = "thick" | "dashed"
export interface FlowchartNode {
id: string
label: string
shape: FlowchartNodeShape
}
export interface FlowchartEdge {
from: string
to: string
label: string
style?: FlowchartEdgeStyle
}
export interface FlowchartSubgraph {
id: string
label: string
nodeIds: string[]
parentId?: string
direction?: FlowchartDirection
}
export interface FlowchartDiagram {
direction: FlowchartDirection
nodes: FlowchartNode[]
edges: FlowchartEdge[]
subgraphs?: FlowchartSubgraph[]
}
export interface FlowchartNodeSize {
width: number
height: number
lines: string[]
}
export interface FlowchartNodeBounds extends FlowchartNodeSize, DiagramBounds {
id: string
}
export interface FlowchartSubgraphBounds extends DiagramBounds {
id: string
label: string
labelSide: "top" | "bottom"
}
export type FlowchartPoint = DiagramPoint
export interface FlowchartEdgeRoute {
edge: FlowchartEdge
points: FlowchartPoint[]
}
export interface FlowchartActiveEdgeSelection {
from: string
to: string
index?: number
}
export type FlowchartEdgeDirection = DiagramDirection
-97
View File
@@ -1,97 +0,0 @@
import * as Flowchart from "./flowchart/index.js"
import * as Sequence from "./sequence/index.js"
import * as State from "./state/index.js"
import { type MermaidDiagramKind } from "./diagnostics.js"
import { detectMermaidDiagram } from "./detect.js"
export { Flowchart, Sequence, State }
export { MermaidSyntaxError } from "./diagnostics.js"
export { createMermaidMarkdownRenderer, type MermaidMarkdownRendererOptions } from "./markdown.js"
export type DiagramKind = MermaidDiagramKind
export type ParsedDiagram =
| { readonly kind: "flowchart"; readonly diagram: Flowchart.Diagram }
| { readonly kind: "sequence"; readonly diagram: Sequence.Diagram }
| { readonly kind: "state"; readonly diagram: State.Diagram }
export interface RenderOptions {
/** Emit ANSI color escapes. Default: `true`. Pass `false` for plain text. */
color?: boolean
/** Theme override. Forwarded to the matching renderer. */
theme?: Flowchart.Theme | Sequence.Theme | State.Theme
}
export class UnknownDiagramError extends Error {
readonly _tag = "UnknownDiagramError"
constructor(content: string) {
const head = firstMeaningfulLine(content) ?? "(empty)"
super(
`Could not detect diagram kind. Expected the first non-empty line to start with ` +
`"flowchart", "graph", "sequenceDiagram", or "stateDiagram[-v2]". Got: "${head}"`,
)
this.name = "UnknownDiagramError"
}
}
function firstMeaningfulLine(content: string): string | undefined {
return content
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && !line.startsWith("%%"))
}
/**
* Detect the diagram kind from the leading Mermaid declaration.
*
* Returns `undefined` if the content does not look like any supported diagram.
*/
export function detect(content: string): DiagramKind | undefined {
return detectMermaidDiagram(content)
}
/** True if the content is recognizably a supported Mermaid diagram. */
export function isMermaid(content: string): boolean {
return detect(content) !== undefined
}
/**
* Render any supported Mermaid string for the terminal.
*
* The leading `flowchart`/`sequenceDiagram`/`stateDiagram-v2` line picks the
* right renderer. Defaults to ANSI-colored output; pass `{ color: false }`
* for plain text.
*
* @throws {UnknownDiagramError} if the diagram kind cannot be detected.
*/
export function render(content: string, options: RenderOptions = {}): string {
const kind = detect(content)
if (!kind) throw new UnknownDiagramError(content)
const opts = options as { color?: boolean; theme?: unknown }
switch (kind) {
case "flowchart":
return Flowchart.render(content, opts as Flowchart.RenderOptions)
case "sequence":
return Sequence.render(content, opts as Sequence.RenderOptions)
case "state":
return State.render(content, opts as State.RenderOptions)
}
}
/**
* Parse any supported Mermaid string into a discriminated union AST.
*
* @throws {UnknownDiagramError} if the diagram kind cannot be detected.
*/
export function parse(content: string): ParsedDiagram {
const kind = detect(content)
if (!kind) throw new UnknownDiagramError(content)
switch (kind) {
case "flowchart":
return { kind, diagram: Flowchart.parse(content) }
case "sequence":
return { kind, diagram: Sequence.parse(content) }
case "state":
return { kind, diagram: State.parse(content) }
}
}
-131
View File
@@ -1,131 +0,0 @@
import {
createMarkdownCodeBlockRenderer,
type ColorInput,
type MarkdownOptions,
type MouseEvent,
type RenderContext,
} from "@opentui/core"
import { MermaidSyntaxError } from "./diagnostics.js"
import { detectMermaidDiagram } from "./detect.js"
import { FlowchartDiagramRenderable } from "./flowchart/renderable.js"
import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { parseMermaidSequenceDiagram } from "./sequence/parser.js"
import { SequenceDiagramRenderable } from "./sequence/renderable.js"
import { parseMermaidStateDiagram } from "./state/parser.js"
import { StateDiagramRenderable } from "./state/renderable.js"
export interface MermaidMarkdownRendererOptions {
compact?: boolean
colors?: {
text?: ColorInput
primary?: ColorInput
secondary?: ColorInput
muted?: ColorInput
accent?: ColorInput
warning?: ColorInput
background?: ColorInput
}
}
/** Create an OpenTUI Markdown node renderer for fenced Mermaid diagrams. */
export function createMermaidMarkdownRenderer(
ctx: RenderContext,
input: MermaidMarkdownRendererOptions | (() => MermaidMarkdownRendererOptions) = {},
): NonNullable<MarkdownOptions["renderNode"]> {
return createMarkdownCodeBlockRenderer({
mermaid: (token) => {
const kind = detectMermaidDiagram(token.text)
if (!kind) return undefined
try {
switch (kind) {
case "flowchart":
parseMermaidFlowchartDiagram(token.text)
break
case "sequence":
parseMermaidSequenceDiagram(token.text)
break
case "state":
parseMermaidStateDiagram(token.text)
break
}
const options = typeof input === "function" ? input() : input
const colors = options.colors ?? {}
const diagram = (() => {
switch (kind) {
case "flowchart":
return new FlowchartDiagramRenderable(ctx, {
content: token.text,
compact: options.compact,
nodeColor: colors.primary,
databaseColor: colors.secondary,
edgeColor: colors.secondary,
labelColor: colors.text,
groupColor: colors.muted,
})
case "sequence":
return new SequenceDiagramRenderable(ctx, {
content: token.text,
compact: options.compact,
participantColor: colors.primary,
lifelineColor: colors.muted,
groupColor: colors.secondary,
requestColor: colors.primary,
responseColor: colors.primary,
noteColor: colors.warning,
noteBackgroundColor: colors.background,
})
case "state":
return new StateDiagramRenderable(ctx, {
content: token.text,
stateColor: colors.primary,
compositeColor: colors.muted,
transitionColor: colors.secondary,
labelColor: colors.text,
noteBorderColor: colors.warning,
noteTextColor: colors.warning,
noteConnectorColor: colors.muted,
activeStateColor: colors.accent,
activeTransitionColor: colors.accent,
startColor: colors.muted,
endColor: colors.muted,
choiceColor: colors.primary,
})
}
})()
diagram.width = "100%"
diagram.height = diagram.renderedHeight
diagram.marginTop = 1
diagram.selectable = false
let drag: { x: number; y: number } | undefined
diagram.onMouseDown = (event: MouseEvent) => {
if (event.button !== 0) return
drag = { x: event.x, y: event.y }
event.preventDefault()
event.stopPropagation()
}
diagram.onMouseDrag = (event: MouseEvent) => {
if (!drag) return
const dx = event.x - drag.x
drag = { x: event.x, y: event.y }
if (dx) diagram.scrollX -= dx
event.preventDefault()
event.stopPropagation()
}
diagram.onMouseDragEnd = () => {
drag = undefined
}
diagram.onMouseUp = (event: MouseEvent) => {
if (event.button !== 0) return
drag = undefined
event.preventDefault()
event.stopPropagation()
}
return diagram
} catch (error) {
if (error instanceof MermaidSyntaxError) return undefined
throw error
}
},
})!
}
-23
View File
@@ -1,23 +0,0 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMermaidMarkdownRenderer } from "./markdown.js"
export default Plugin.define({
id: "opencode.merman",
setup(context) {
context.markdown.registerCodeBlockRenderer(
"mermaid",
createMermaidMarkdownRenderer(context.renderer, () => ({
compact: true,
colors: {
text: context.theme.markdown.text,
primary: context.theme.text.default,
secondary: context.theme.text.subdued,
muted: context.theme.border.default,
accent: context.theme.text.action.primary.focused,
warning: context.theme.text.default,
background: context.theme.background.default,
},
})),
)
},
})
@@ -1,896 +0,0 @@
import { describe, expect, test } from "bun:test"
import { parseColor } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { expectDiagram } from "../test/diagram.js"
import { renderSequenceDiagram, renderSequenceDiagramAnsi } from "./diagram.js"
import { parseMermaidSequenceDiagram } from "./parser.js"
import { SequenceDiagramRenderable } from "./renderable.js"
describe("SequenceDiagram", () => {
test("parses Mermaid sequenceDiagram participants and messages", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
participant B as Browser
participant S as Server
B->>S: GET /
S-->>B: 401 WWW-Auth
`)
expect(diagram.participants).toEqual([
{ id: "B", label: "Browser" },
{ id: "S", label: "Server" },
])
expect(diagram.messages).toEqual([
{ from: "B", to: "S", label: "GET /", style: "solid" },
{ from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" },
])
expect(diagram.steps).toEqual([
{ type: "message", message: { from: "B", to: "S", label: "GET /", style: "solid" } },
{ type: "message", message: { from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" } },
])
})
test("renders a terminal sequence diagram", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant B as Browser
participant S as Server
B->>S: GET /
S-->>B: 401 WWW-Auth
`)
expectDiagram(output).toEqualDiagram(`
Browser Server
GET /
401 WWW-Auth
`)
})
test("renders a compact terminal sequence diagram without participant boxes", () => {
const output = renderSequenceDiagram(
`
sequenceDiagram
participant Leaf as leaf tool
participant Location as LocationMutation
participant File as FileMutation
Leaf->>Location: resolve(path)
Location-->>Leaf: Plan(target, authority anchor)
Leaf->>File: commit(plan)
File->>Location: revalidate(plan)
Location-->>File: same target or reject
`,
{ compact: true },
)
expectDiagram(output).toEqualDiagram(`
leaf tool LocationMutation FileMutation
resolve(path)
Plan(target, authority anchor)
commit(plan)
revalidate(plan)
same target or reject
`)
})
test("keeps structured sequence steps visible in compact mode", () => {
const output = renderSequenceDiagram(
`
sequenceDiagram
participant Worker
participant Store
Note over Worker,Store: transaction
alt accepted
Worker->>Worker: prepare
Worker->>Store: commit
end
`,
{ compact: true },
)
expectDiagram(output).toContainInOrder("Worker", "Store", "transaction", "alt: accepted", "prepare", "commit")
})
test("keeps compact labels above arrows when they do not fit inline", () => {
const output = renderSequenceDiagram(
"sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: this label is deliberately much too long to fit between endpoints despite intermediate spacing",
{ compact: true },
)
const lines = output.split("\n")
expect(lines.findIndex((line) => line.includes("deliberately"))).toBeLessThan(
lines.findIndex((line) => line.includes("▶")),
)
})
test("normalizes invalid participant gaps for text and live rendering", async () => {
const content = "sequenceDiagram\n A->>B: hello"
expect(renderSequenceDiagram(content, { minParticipantGap: Number.NaN })).toContain("hello")
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
try {
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
content,
minParticipantGap: Number.NaN,
})
expect(diagram.minParticipantGap).toBe(18)
diagram.minParticipantGap = 0
expect(diagram.minParticipantGap).toBe(1)
diagram.minParticipantGap = 3.9
expect(diagram.minParticipantGap).toBe(3)
} finally {
testRenderer.renderer.destroy()
}
})
test("connects participant headers to lifelines", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Browser
participant Server
`)
const lines = output.split("\n")
const browserCenter = lines[1]!.indexOf("w")
const serverCenter = lines[1]!.indexOf("v")
expect(lines[2]?.[browserCenter]).toBe("┬")
expect(lines[3]?.[browserCenter]).toBe("│")
expect(lines[2]?.[serverCenter]).toBe("┬")
expect(lines[3]?.[serverCenter]).toBe("│")
})
test("renders notes and long cross-participant messages in order", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Browser
participant Server
participant Store as Ticket store
Note over Browser,Server: native browser Basic prompt
Browser->>Server: POST connect-token
Server->>Store: issue { ptyID, scope }
`)
expectDiagram(output).toContainInOrder(
"native browser Basic prompt",
"POST connect-token",
"issue { ptyID, scope }",
)
})
test("renders notes to the left and right of a participant", () => {
const output = renderSequenceDiagram(`sequenceDiagram
participant OpenTUI
Note right of OpenTUI: child is not renderable
Note left of OpenTUI: remove failed`)
expect(output).toContain("child is not renderable")
expect(output).toContain("remove failed")
})
test("preserves the parsed shape of notes over participants", () => {
const diagram = parseMermaidSequenceDiagram(`sequenceDiagram
participant A
Note over A: hello`)
expect(diagram.steps).toContainEqual({ type: "note", note: { over: ["A"], label: "hello" } })
})
test("parses activation shorthand and control blocks", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
Browser->>+Server: request
alt accepted
Server-->>-Browser: response
else rejected
activate Server
Server-->>Browser: error
deactivate Server
end
`)
expect(diagram.steps).toEqual([
{
type: "message",
message: {
from: "Browser",
to: "Server",
label: "request",
style: "solid",
activate: "Server",
},
},
{ type: "fragment", fragment: { kind: "alt", label: "accepted" } },
{
type: "message",
message: {
from: "Server",
to: "Browser",
label: "response",
style: "dashed",
deactivate: "Server",
},
},
{ type: "fragment", fragment: { kind: "else", label: "rejected" } },
{ type: "activation", activation: { participant: "Server", active: true } },
{
type: "message",
message: { from: "Server", to: "Browser", label: "error", style: "dashed" },
},
{ type: "activation", activation: { participant: "Server", active: false } },
{ type: "fragment", fragment: { kind: "end", label: "alt" } },
])
})
test("parses activation syntax without rendering activation bars", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>+Server: request
Server-->>-Browser: response
`)
expect(output).not.toContain("┃")
expect(output).toContain("request")
expect(output).toContain("response")
})
test("parses Mermaid arrow head variants", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
A->B: open solid
B-->A: open dashed
A-xB: failed solid
B--xA: failed dashed
A-)B: async solid
B--)A: async dashed
`)
expect(diagram.messages).toEqual([
{ from: "A", to: "B", label: "open solid", style: "solid", head: "open" },
{ from: "B", to: "A", label: "open dashed", style: "dashed", head: "open" },
{ from: "A", to: "B", label: "failed solid", style: "solid", head: "cross" },
{ from: "B", to: "A", label: "failed dashed", style: "dashed", head: "cross" },
{ from: "A", to: "B", label: "async solid", style: "solid", head: "async" },
{ from: "B", to: "A", label: "async dashed", style: "dashed", head: "async" },
])
})
test("renders Mermaid arrow head variants", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
A->B: open solid
B-->A: open dashed
A-xB: failed solid
B--xA: failed dashed
A-)B: async solid
B--)A: async dashed
`)
expect(output).toMatchInlineSnapshot(`
"
A B
open solid
>
open dashed
<
failed solid
failed dashed
async solid
)
async dashed
(
"
`)
})
test("renders boxed alt else regions", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
alt accepted
Browser->>Server: ok
else rejected
Server-->>Browser: no
end
`)
expectDiagram(output).toContainInOrder("╭─ alt: accepted", "ok", "├─ else: rejected", "no", "╰")
expect(output).not.toContain("end alt")
})
test("expands a fragment frame for a longer else label", () => {
const output = renderSequenceDiagram(`sequenceDiagram
A->>B: start
alt ok
A->>B: yes
else validation failed with a substantially longer explanation
B-->>A: no
end`)
const lines = output.split("\n")
const elseRow = lines.find((line) => line.includes("validation failed"))!
const endRow = [...lines].reverse().find((line) => line.includes("╰"))!
expect(elseRow.lastIndexOf("┤")).toBe(endRow.lastIndexOf("╯"))
})
test("preserves combined graphemes in participant names", () => {
const output = renderSequenceDiagram(`sequenceDiagram
participant A as Cafe\u0301
participant B
A->>B: hi`)
expect(output).toContain("Cafe\u0301")
})
test("renders fragment boxes with lifeline overhang", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant A
participant B
alt ok
A->>B: yes
end
`)
const lines = output.split("\n")
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
expect(fragmentStart).toBeLessThan(participantCenter)
})
test("supports configurable fragment border styles", () => {
const output = renderSequenceDiagram(
`
sequenceDiagram
participant A
participant B
alt ok
A->>B: yes
else no
B-->>A: no
end
`,
{ fragmentBorderStyle: "double" },
)
expect(output).toContain("╔")
expect(output).toContain("╠")
expect(output).toContain("╚")
expect(output).toContain("═")
expect(output).toContain("║")
})
test("parses and renders autonumbered messages", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
autonumber
Browser->>API: request
API-->>Browser: response
`)
const output = renderSequenceDiagram(`
sequenceDiagram
autonumber
Browser->>API: request
API-->>Browser: response
`)
expect(diagram.messages.map((message) => message.number)).toEqual([1, 2])
expect(output).toContain("1. request")
expect(output).toContain("2. response")
})
test("supports autonumber start and increment", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
autonumber 10 5
Browser->>API: first
API-->>Browser: second
`)
const output = renderSequenceDiagram(`
sequenceDiagram
autonumber 10 5
Browser->>API: first
API-->>Browser: second
`)
expect(diagram.messages.map((message) => message.number)).toEqual([10, 15])
expect(output).toContain("10. first")
expect(output).toContain("15. second")
})
test("parses and renders loop regions", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
loop retry up to 3x
Browser->>API: GET /users/42
API-->>Browser: 503
end
`)
const output = renderSequenceDiagram(`
sequenceDiagram
loop retry up to 3x
Browser->>API: GET /users/42
API-->>Browser: 503
end
`)
expect(diagram.steps[0]).toEqual({
type: "fragment",
fragment: { kind: "loop", label: "retry up to 3x" },
})
expect(output).toContain("╭─ ↻ loop: retry up to 3x")
expect(output).not.toContain("end loop")
expect(output.indexOf("loop: retry up to 3x")).toBeLessThan(output.indexOf("GET /users/42"))
})
test("parses Mermaid box participant groups", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
participant Browser
box Backend
participant API
participant Cache
end
box Purple Storage Layer
participant DB
end
box "Purple Literal Label"
participant Worker
end
Browser->>API: request
`)
expect(diagram.groups).toEqual([
{ label: "Backend", participantIds: ["API", "Cache"] },
{ label: "Storage Layer", participantIds: ["DB"] },
{ label: "Purple Literal Label", participantIds: ["Worker"] },
])
expect(diagram.steps).toEqual([
{
type: "message",
message: { from: "Browser", to: "API", label: "request", style: "solid" },
},
])
})
test("adds implicit participants inside box groups", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
participant API
box Backend
API->>DB: query
end
`)
expect(diagram.groups).toEqual([{ label: "Backend", participantIds: ["API", "DB"] }])
expect(diagram.steps).toEqual([
{ type: "message", message: { from: "API", to: "DB", label: "query", style: "solid" } },
])
})
test("does not clip long non-adjacent messages or notes", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant A
participant B
participant C
A->>C: this message needs room past the final participant
Note over A,C: this note also needs full horizontal room
`)
expect(output).toContain("this message needs room past the final participant")
expect(output).toContain("this note also needs full horizontal room")
})
test("keeps long content inside participant groups and fragment frames", () => {
const group = renderSequenceDiagram(`sequenceDiagram
box Services
participant A
participant B
participant C
A->>C: this message text runs far outside of the group container boundary
end`)
const fragment = renderSequenceDiagram(`sequenceDiagram
participant A
participant B
participant C
alt lookup
A->>C: this non adjacent message is deliberately much wider than the frame
end`)
const groupMessageRow = group.split("\n").find((line) => line.includes("this message text"))!
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
})
test("keeps long notes inside groups and nested fragment frames intact", () => {
const groupedNote = renderSequenceDiagram(`sequenceDiagram
box Services
participant A
participant B
participant C
Note over A,C: this note text runs far outside of the group container boundary
end`)
const fragmentNote = renderSequenceDiagram(`sequenceDiagram
participant A
participant B
participant C
alt lookup
Note over A,C: this non adjacent note is deliberately much wider than the frame
end`)
const nested = renderSequenceDiagram(`sequenceDiagram
participant A
participant B
alt outer
loop inner heading wider than outer frame and participant span
A->>B: x
end
end`)
expect(groupedNote).toContain("this note text runs far outside of the group container boundary")
expect(fragmentNote).toContain("this non adjacent note is deliberately much wider than the frame")
expect(nested).toContain("span ─╮│")
expect(nested).toContain("──────╯│")
})
test("does not draw external participants inside groups expanded by self messages", () => {
const output = renderSequenceDiagram(`sequenceDiagram
box G
participant A
end
participant B as External
A->>A: this self-loop extends underneath the external participant header`)
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
const lines = output.split("\n")
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
})
test("renders full-height participant group boxes", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Browser
box Backend
participant API
participant Cache
participant DB
end
Browser->>API: GET /users/42
API->>Cache: get user:42
`)
expect(output).toMatchInlineSnapshot(`
" Backend
Browser API Cache DB
GET /users/42
get user:42
"
`)
})
test("lets message lines pass through group borders without intersections", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Browser
box Backend
participant API
end
Browser->>API: GET /users/42
`)
const arrowLine = output.split("\n").find((line) => line.includes("▶"))!
expect(arrowLine).toContain("───────────────▶")
expect(arrowLine).not.toContain("┼")
})
test("renders self messages as loopback arrows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Service
Service->>Service: Check Permissions
`)
expect(output).toMatchInlineSnapshot(`
"
Service
Check Permissions
"
`)
})
test("places two spacer rows above note badges and one below", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: one
Note over Browser,Server: phase
Browser->>Server: two
`)
const lines = output.split("\n")
const noteRow = lines.findIndex((line) => line.includes("phase"))
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
expect(noteRow).toBeGreaterThan(0)
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
expect(lines[noteRow - 2]?.trim()).toBe("│ │")
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
expect(nextMessageRow).toBe(noteRow + 2)
})
test("renders br-delimited message labels across multiple rows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
`)
expect(output).toContain("POST connect-token")
expect(output).toContain("· Basic (cached by browser)")
expect(output).toContain("· X-OpenCode-Ticket: 1")
expect(output.indexOf("· X-OpenCode-Ticket: 1")).toBeLessThan(output.indexOf("├"))
})
test("colors request and response messages differently", async () => {
const requestColor = parseColor("#38BDF8")
const responseColor = parseColor("#F59E0B")
const testRenderer = await createTestRenderer({ width: 60, height: 16 })
try {
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
content: `sequenceDiagram
Browser->>Server: request
Server-->>Browser: response`,
requestColor,
responseColor,
})
testRenderer.renderer.root.add(diagram)
await testRenderer.renderOnce()
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
const requestSpan = spans.find((span) => span.text.includes("request"))
const responseSpan = spans.find((span) => span.text.includes("response"))
expect(requestSpan?.fg.equals(requestColor)).toBe(true)
expect(responseSpan?.fg.equals(responseColor)).toBe(true)
} finally {
testRenderer.renderer.destroy()
}
})
test("repaints sequence colors after mounting without changing diagram text", async () => {
const initialColor = parseColor("#38BDF8")
const updatedColor = parseColor("#A78BFA")
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
try {
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
content: "sequenceDiagram\n Browser->>Server: request",
requestColor: initialColor,
})
testRenderer.renderer.root.add(diagram)
await testRenderer.renderOnce()
const before = testRenderer.captureCharFrame()
diagram.batchUpdate(() => {
diagram.requestColor = updatedColor
diagram.lifelineColor = updatedColor
})
await testRenderer.renderOnce()
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
expect(testRenderer.captureCharFrame()).toBe(before)
expect(spans.find((span) => span.text.includes("request"))?.fg.equals(updatedColor)).toBe(true)
} finally {
testRenderer.renderer.destroy()
}
})
test("tweens arrow departure colors away from lifelines over five cells", async () => {
const lifelineColor = parseColor("#94A3B8")
const requestColor = parseColor("#38BDF8")
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
try {
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
content: `sequenceDiagram
Browser->>Server: request`,
lifelineColor,
requestColor,
})
testRenderer.renderer.root.add(diagram)
await testRenderer.renderOnce()
const arrowLine = testRenderer
.captureSpans()
.lines.find((line) => line.spans.some((span) => span.text.includes("▶")))
const departureSpan = arrowLine?.spans.find((span) => span.text.includes("├"))
expect(departureSpan).toBeDefined()
expect(departureSpan?.fg.equals(lifelineColor)).toBe(false)
expect(departureSpan?.fg.equals(requestColor)).toBe(false)
} finally {
testRenderer.renderer.destroy()
}
})
test("tweens self-message departure colors away from lifelines", async () => {
const lifelineColor = parseColor("#94A3B8")
const requestColor = parseColor("#38BDF8")
const testRenderer = await createTestRenderer({ width: 60, height: 12 })
try {
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
content: `sequenceDiagram
Service->>Service: validate`,
lifelineColor,
requestColor,
})
testRenderer.renderer.root.add(diagram)
await testRenderer.renderOnce()
const arrowLine = testRenderer
.captureSpans()
.lines.find((line) => line.spans.some((span) => span.text.includes("├")))
const departureSpan = arrowLine?.spans.find((span) => span.text.includes("├"))
expect(departureSpan).toBeDefined()
expect(departureSpan?.fg.equals(lifelineColor)).toBe(false)
expect(departureSpan?.fg.equals(requestColor)).toBe(false)
} finally {
testRenderer.renderer.destroy()
}
})
test("colors headers, header rules, and note badges separately", async () => {
const participantColor = parseColor("#E5E7EB")
const lifelineColor = parseColor("#64748B")
const noteColor = parseColor("#A78BFA")
const noteBackgroundColor = parseColor("#312E81")
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
try {
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
content: `sequenceDiagram
participant Browser
participant Server
Note over Browser,Server: native browser Basic prompt`,
participantColor,
lifelineColor,
noteColor,
noteBackgroundColor,
})
testRenderer.renderer.root.add(diagram)
await testRenderer.renderOnce()
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
const participantSpan = spans.find((span) => span.text.includes("Browser"))
const headerRuleSpan = spans.find((span) => span.text.includes("┬"))
const noteSpan = spans.find((span) => span.text.includes("native browser Basic prompt"))
expect(participantSpan?.fg.equals(participantColor)).toBe(true)
expect(headerRuleSpan?.fg.equals(lifelineColor)).toBe(true)
expect(noteSpan?.fg.equals(noteColor)).toBe(true)
expect(noteSpan?.bg.equals(noteBackgroundColor)).toBe(true)
} finally {
testRenderer.renderer.destroy()
}
})
test("colors group boxes separately from fragments", async () => {
const groupColor = parseColor("#8BA394")
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
try {
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
content: `sequenceDiagram
box Backend
participant API
end
alt ok
API->>API: validate
end`,
groupColor,
})
testRenderer.renderer.root.add(diagram)
await testRenderer.renderOnce()
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
const groupSpan = spans.find((span) => span.text.includes("Backend"))
const fragmentSpan = spans.find((span) => span.text.includes("alt: ok"))
expect(groupSpan?.fg.equals(groupColor)).toBe(true)
expect(fragmentSpan?.fg.equals(groupColor)).toBe(false)
} finally {
testRenderer.renderer.destroy()
}
})
test("adds a background to fragment labels", async () => {
const noteBackgroundColor = parseColor("#24382F")
const testRenderer = await createTestRenderer({ width: 70, height: 16 })
try {
const diagram = new SequenceDiagramRenderable(testRenderer.renderer, {
content: `sequenceDiagram
alt ok
Browser->>Server: request
else no
Server-->>Browser: response
end`,
noteBackgroundColor,
})
testRenderer.renderer.root.add(diagram)
await testRenderer.renderOnce()
const spans = testRenderer.captureSpans().lines.flatMap((line) => line.spans)
const altSpan = spans.find((span) => span.text.includes("alt: ok"))
const elseSpan = spans.find((span) => span.text.includes("else: no"))
expect(altSpan?.bg.equals(noteBackgroundColor)).toBe(true)
expect(elseSpan?.bg.equals(noteBackgroundColor)).toBe(true)
} finally {
testRenderer.renderer.destroy()
}
})
test("prints ANSI colors for terminal dumps", () => {
const output = renderSequenceDiagramAnsi(`
sequenceDiagram
Browser->>Server: request
Server-->>Browser: response
`)
expect(output).toContain("\x1b[38;2;134;225;200m")
expect(output).toContain("\x1b[38;2;230;177;126m")
expect(output).toContain("\x1b[38;2;115;153;138m")
expect(output).toContain("\x1b[38;2;130;211;188m")
expect(output).toContain("\x1b[38;2;131;145;126m")
expect(output).toContain("\x1b[38;2;210;171;126m")
expect(output).toContain("request")
expect(output).toContain("response")
expect(output).toContain("◀")
expect(output).toContain("┤")
})
})
-12
View File
@@ -1,12 +0,0 @@
import { drawSequenceDiagramGrid } from "./drawing.js"
import { parseMermaidSequenceDiagram } from "./parser.js"
import { renderSequenceGridAnsi, renderSequenceGridText } from "./render-grid.js"
import type { SequenceDiagramAnsiOptions, SequenceDiagramRenderOptions } from "./types.js"
export function renderSequenceDiagram(content: string, options: SequenceDiagramRenderOptions = {}): string {
return renderSequenceGridText(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options))
}
export function renderSequenceDiagramAnsi(content: string, options: SequenceDiagramAnsiOptions = {}): string {
return renderSequenceGridAnsi(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options), options.theme)
}

Some files were not shown because too many files have changed in this diff Show More