mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 19:49:48 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a93c9135d4 | |||
| 9d34a927d6 | |||
| 6345810764 | |||
| 2580f880a8 |
@@ -41,7 +41,6 @@ export type SessionMessageAgentSelected = {
|
||||
time: { created: number }
|
||||
type: "agent-switched"
|
||||
agent: string
|
||||
previous?: string
|
||||
}
|
||||
|
||||
export type PromptBase64 = string
|
||||
@@ -2536,7 +2535,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -2788,7 +2786,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -3040,7 +3037,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
@@ -60,19 +59,15 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.created": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const previous = yield* adapter.getAgent()
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
})
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.model.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "../agent"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
@@ -231,17 +230,6 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getAgent() {
|
||||
return db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
|
||||
)
|
||||
},
|
||||
getModel() {
|
||||
return db
|
||||
.select({ model: SessionTable.model })
|
||||
@@ -410,15 +398,12 @@ const layer = Layer.effectDiscard(
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* bus.project(SessionEvent.ModelSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -5,12 +5,10 @@ import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("global paths", () => {
|
||||
test("tmp path is the canonical system temp directory", async () => {
|
||||
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
|
||||
expect(Global.make().tmp).toBe(Global.Path.tmp)
|
||||
})
|
||||
|
||||
test("tmp path is created on module load", async () => {
|
||||
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
|
||||
test("tmp path is canonical and created on first access", async () => {
|
||||
const tmp = Global.Path.tmp
|
||||
expect(tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
|
||||
expect(Global.make().tmp).toBe(tmp)
|
||||
expect((await fs.stat(tmp)).isDirectory()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -15,6 +16,7 @@ import { testEffect } from "./lib/effect"
|
||||
import { readInitial, readUpdate, state } from "./lib/instructions"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const testConfig = path.join(os.tmpdir(), "opencode-instruction-discovery-test")
|
||||
|
||||
const instructionLayer = (input: {
|
||||
config: string
|
||||
@@ -147,7 +149,7 @@ describe("InstructionDiscovery", () => {
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
config: testConfig,
|
||||
filesystemLayer: failingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -183,7 +185,7 @@ describe("InstructionDiscovery", () => {
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
config: testConfig,
|
||||
filesystemLayer: racingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -222,7 +224,7 @@ describe("InstructionDiscovery", () => {
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
config: testConfig,
|
||||
filesystemLayer: observingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -250,7 +252,7 @@ describe("InstructionDiscovery", () => {
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
config: testConfig,
|
||||
project: false,
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
@@ -277,7 +279,7 @@ describe("InstructionDiscovery", () => {
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
config: testConfig,
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import os from "os"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -16,6 +17,7 @@ const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
||||
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
||||
const sessionID = SessionSchema.ID.make("ses_builtin_test")
|
||||
const temporary = os.tmpdir()
|
||||
const localDate = (time: number) => new Date(time).toDateString()
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -29,7 +31,7 @@ const locationLayer = Layer.succeed(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(InstructionBuiltIns.node, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
|
||||
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -49,7 +51,7 @@ describe("InstructionBuiltIns", () => {
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
|
||||
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
|
||||
@@ -648,7 +648,7 @@ describe("Session.create", () => {
|
||||
it.effect("switches the selected agent through the durable Session event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location, agent: Agent.ID.make("build") })
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
|
||||
|
||||
@@ -656,9 +656,6 @@ describe("Session.create", () => {
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -358,7 +358,6 @@ describe("SessionProjector", () => {
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
agent: "plan",
|
||||
model: previousModel,
|
||||
})
|
||||
.run()
|
||||
@@ -460,10 +459,6 @@ describe("SessionProjector", () => {
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
|
||||
agent: build,
|
||||
previous: "plan",
|
||||
})
|
||||
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
command: "pwd",
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface MermaidMarkdownRendererOptions {
|
||||
muted?: ColorInput
|
||||
warning?: ColorInput
|
||||
background?: ColorInput
|
||||
request?: ColorInput
|
||||
response?: ColorInput
|
||||
note?: ColorInput
|
||||
noteBackground?: ColorInput
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,13 +134,14 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
resolveSequenceStyleColors({
|
||||
participant: color(colors.primary),
|
||||
lifeline: color(colors.muted),
|
||||
lifelineEnd: color(colors.background),
|
||||
group: color(colors.secondary),
|
||||
request: color(colors.primary),
|
||||
response: color(colors.primary),
|
||||
request: color(colors.request ?? colors.primary),
|
||||
response: color(colors.response ?? colors.primary),
|
||||
fragment: color(colors.secondary),
|
||||
fragmentLabelBg: color(colors.background),
|
||||
note: color(colors.warning),
|
||||
noteBg: color(colors.background),
|
||||
note: color(colors.note ?? colors.warning),
|
||||
noteBg: color(colors.noteBackground ?? colors.background),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { blendColor } from "./core/color/style.js"
|
||||
import { createOpenCodeDiagramPalette } from "./palette.js"
|
||||
|
||||
type Rgb = readonly [number, number, number]
|
||||
@@ -31,11 +32,15 @@ describe("OpenCode diagram palette", () => {
|
||||
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
|
||||
const primary = rgb(text)
|
||||
const info = RGBA.fromInts(40, 120, 220)
|
||||
const success = RGBA.fromInts(80, 180, 120)
|
||||
const warning = RGBA.fromInts(220, 160, 80)
|
||||
const background = RGBA.fromInts(10, 20, 30)
|
||||
const palette = createOpenCodeDiagramPalette({
|
||||
text: primary,
|
||||
subdued: rgb(subdued),
|
||||
info,
|
||||
success,
|
||||
warning,
|
||||
background,
|
||||
})
|
||||
|
||||
@@ -45,5 +50,9 @@ describe("OpenCode diagram palette", () => {
|
||||
expect(palette.muted.equals(rgb(muted))).toBe(true)
|
||||
expect(palette.warning).toBe(info)
|
||||
expect(palette.background).toBe(background)
|
||||
expect(palette.request).toBe(success)
|
||||
expect(palette.response).toBe(warning)
|
||||
expect(palette.note).toBe(primary)
|
||||
expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface OpenCodeDiagramPaletteInput {
|
||||
readonly text: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly info: RGBA
|
||||
readonly success: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly background: RGBA
|
||||
}
|
||||
|
||||
@@ -16,5 +18,9 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
|
||||
muted: blendColor(input.text, input.subdued, 0.7),
|
||||
warning: input.info,
|
||||
background: input.background,
|
||||
request: input.success,
|
||||
response: input.warning,
|
||||
note: input.text,
|
||||
noteBackground: blendColor(input.background, input.subdued, 0.25),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export default Plugin.define({
|
||||
text: context.theme.text.default,
|
||||
subdued: context.theme.text.subdued,
|
||||
info: context.theme.text.feedback.info.default,
|
||||
success: context.theme.text.feedback.success.default,
|
||||
warning: context.theme.text.feedback.warning.default,
|
||||
background: context.theme.background.default,
|
||||
}),
|
||||
})),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderSequenceDiagram } from "./diagram.js"
|
||||
import { drawSequenceDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidSequenceDiagram } from "./parser.js"
|
||||
import { resolveSequenceStyleColors } from "./style.js"
|
||||
|
||||
describe("SequenceDiagram", () => {
|
||||
test("parses Mermaid sequenceDiagram participants and messages", () => {
|
||||
@@ -51,16 +53,19 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭─────────╮ ╭────────╮
|
||||
│ Browser │ │ Server │
|
||||
╰────┬────╯ ╰────┬───╯
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
Browser Server
|
||||
───┬─── ───┬──
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -93,6 +98,10 @@ sequenceDiagram
|
||||
│ │ │
|
||||
│ ├─ same target or reject ──►
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -140,13 +149,13 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
const lines = output.split("\n")
|
||||
const browserCenter = lines[1]!.indexOf("w")
|
||||
const serverCenter = lines[1]!.indexOf("v")
|
||||
const browserCenter = lines[0]!.indexOf("w")
|
||||
const serverCenter = lines[0]!.indexOf("v")
|
||||
|
||||
expect(lines[2]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[3]?.[browserCenter]).toBe("│")
|
||||
expect(lines[2]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[3]?.[serverCenter]).toBe("│")
|
||||
expect(lines[1]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[2]?.[browserCenter]).toBe("│")
|
||||
expect(lines[1]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[2]?.[serverCenter]).toBe("│")
|
||||
})
|
||||
|
||||
test("ramps participant frames into neutral lifelines", () => {
|
||||
@@ -162,6 +171,27 @@ sequenceDiagram
|
||||
expect(new Set(rampStyles)).toEqual(new Set(["lifelineRamp1", "lifelineRamp2", "lifelineRamp3"]))
|
||||
})
|
||||
|
||||
test("fades the bottom of participant lifelines", () => {
|
||||
const grid = drawSequenceDiagramGrid(
|
||||
parseMermaidSequenceDiagram(
|
||||
"sequenceDiagram\n participant Browser\n participant Server\n Browser->>Server: request",
|
||||
),
|
||||
)
|
||||
const fadeStyles = grid.rows
|
||||
.flatMap((row) => row.map((cell) => cell.style))
|
||||
.filter((style) => style?.startsWith("lifelineFade"))
|
||||
|
||||
expect(new Set(fadeStyles)).toEqual(
|
||||
new Set(["lifelineFade1", "lifelineFade2", "lifelineFade3", "lifelineFade4", "lifelineFade5"]),
|
||||
)
|
||||
|
||||
const lifeline = RGBA.fromInts(100, 120, 110)
|
||||
const background = RGBA.fromInts(10, 20, 15)
|
||||
const colors = resolveSequenceStyleColors({ lifeline, lifelineEnd: background })
|
||||
expect(colors.lifelineFade1.equals(lifeline)).toBe(true)
|
||||
expect(colors.lifelineFade5.equals(background)).toBe(true)
|
||||
})
|
||||
|
||||
test("renders notes and long cross-participant messages in order", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
@@ -277,8 +307,8 @@ sequenceDiagram
|
||||
A->>B: hello`)
|
||||
|
||||
expect(output).not.toContain("<br")
|
||||
expect(output).toContain("│ First line │")
|
||||
expect(output).toContain("│ Second line │")
|
||||
expect(output).toContain("First line")
|
||||
expect(output).toContain("Second line")
|
||||
})
|
||||
|
||||
test("parses Mermaid arrow head variants", () => {
|
||||
@@ -314,28 +344,31 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭───╮ ╭───╮
|
||||
│ A │ │ B │
|
||||
╰─┬─╯ ╰─┬─╯
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
" A B
|
||||
─┬─ ─┬─
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -387,7 +420,7 @@ sequenceDiagram
|
||||
end
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
|
||||
const participantCenter = lines.find((line) => line.includes(" A"))!.indexOf("A")
|
||||
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
|
||||
|
||||
expect(fragmentStart).toBeLessThan(participantCenter)
|
||||
@@ -557,7 +590,7 @@ sequenceDiagram
|
||||
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(2)
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
|
||||
})
|
||||
|
||||
test("keeps long notes inside groups and nested fragment frames intact", () => {
|
||||
@@ -600,7 +633,7 @@ sequenceDiagram
|
||||
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("╭")
|
||||
const externalHeaderLeft = lines[externalLabelRow + 1]!.lastIndexOf("─")
|
||||
|
||||
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
|
||||
})
|
||||
@@ -655,18 +688,21 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Backend ──────────────────────────────────╮
|
||||
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
|
||||
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
|
||||
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
╰────────────────────────────────────────────╯"
|
||||
" ╭─ Backend ───────────────────────────────╮
|
||||
Browser │ API Cache DB │
|
||||
───┬─── │ ─┬─ ──┬── ─┬─ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
╰─────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -706,18 +742,21 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─────────╮
|
||||
│ Service │
|
||||
╰────┬────╯
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│"
|
||||
"Service
|
||||
───┬───
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│"
|
||||
`)
|
||||
})
|
||||
|
||||
test("frames notes in their reserved rows", () => {
|
||||
test("renders note badges in their reserved rows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: one
|
||||
@@ -729,11 +768,9 @@ sequenceDiagram
|
||||
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
|
||||
|
||||
expect(noteRow).toBeGreaterThan(0)
|
||||
expect(lines[noteRow - 1]).toContain("╭")
|
||||
expect(lines[noteRow - 1]).toContain("╮")
|
||||
expect(lines[noteRow]).toContain("│ phase │")
|
||||
expect(lines[noteRow + 1]).toContain("╰")
|
||||
expect(lines[noteRow + 1]).toContain("╯")
|
||||
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow]).toContain(" phase ")
|
||||
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
|
||||
expect(nextMessageRow).toBe(noteRow + 2)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BorderChars, type BorderStyle } from "@opentui/core"
|
||||
import { DiagramCanvas } from "../core/canvas.js"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import { DEFAULT_FRAGMENT_BORDER_STYLE } from "./options.js"
|
||||
import { DEFAULT_FRAGMENT_BORDER_STYLE, SEQUENCE_LIFELINE_FADE_STEPS } from "./options.js"
|
||||
import {
|
||||
createSequencePlacementPlan,
|
||||
type SequenceGroupPlacement,
|
||||
@@ -191,29 +191,9 @@ function renderSelfMessage(
|
||||
}
|
||||
|
||||
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
|
||||
const width = Math.max(...placement.textLines.map(diagramTextWidth))
|
||||
const left = placement.textX
|
||||
const right = left + width - 1
|
||||
const top = placement.textY - 1
|
||||
const bottom = placement.textY + placement.textLines.length
|
||||
|
||||
for (let x = left + 1; x < right; x++) {
|
||||
setCell(grid, x, top, SEQUENCE_BORDER.horizontal, "note")
|
||||
setCell(grid, x, bottom, SEQUENCE_BORDER.horizontal, "note")
|
||||
}
|
||||
for (let y = top + 1; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
setCell(grid, left, top, SEQUENCE_BORDER.topLeft, "note")
|
||||
setCell(grid, right, top, SEQUENCE_BORDER.topRight, "note")
|
||||
setCell(grid, left, bottom, SEQUENCE_BORDER.bottomLeft, "note")
|
||||
setCell(grid, right, bottom, SEQUENCE_BORDER.bottomRight, "note")
|
||||
placement.textLines.forEach((line, index) => setText(grid, left, placement.textY + index, line, "noteBadge"))
|
||||
for (let y = placement.textY; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
placement.textLines.forEach((line, index) =>
|
||||
setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
|
||||
)
|
||||
}
|
||||
|
||||
export function drawSequenceDiagramGrid(
|
||||
@@ -236,28 +216,24 @@ export function drawSequenceDiagramGrid(
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
} else {
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderTopY + index, line, "participant"),
|
||||
)
|
||||
for (let x = headerLeftX; x <= headerRightX; x++) {
|
||||
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
}
|
||||
|
||||
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
|
||||
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
|
||||
for (let y = participantHeaderY; y < participantRuleY; y++) {
|
||||
setCell(grid, headerLeftX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
setCell(grid, headerRightX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
}
|
||||
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
|
||||
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
|
||||
}
|
||||
|
||||
for (let y = lifelineStartY; y <= lifelineEndY; y++) {
|
||||
const distance = y - lifelineStartY
|
||||
const style = !options.compact && distance < 3 ? (`lifelineRamp${distance + 1}` as SequenceCellStyle) : "lifeline"
|
||||
const fadeDistance = y - (lifelineEndY - SEQUENCE_LIFELINE_FADE_STEPS.length + 1)
|
||||
const style =
|
||||
fadeDistance >= 0
|
||||
? (`lifelineFade${fadeDistance + 1}` as SequenceCellStyle)
|
||||
: !options.compact && distance < 3
|
||||
? (`lifelineRamp${distance + 1}` as SequenceCellStyle)
|
||||
: "lifeline"
|
||||
setCell(grid, center, y, SEQUENCE_BORDER.vertical, style)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BorderStyle } from "@opentui/core"
|
||||
|
||||
export const DEFAULT_MIN_PARTICIPANT_GAP = 18
|
||||
export const DEFAULT_FRAGMENT_BORDER_STYLE = "rounded" satisfies BorderStyle
|
||||
export const SEQUENCE_LIFELINE_FADE_STEPS = [1, 2, 3, 4, 5] as const
|
||||
|
||||
export function normalizeSequenceMinParticipantGap(value: number | undefined): number {
|
||||
return value === undefined || !Number.isFinite(value) ? DEFAULT_MIN_PARTICIPANT_GAP : Math.max(1, Math.floor(value))
|
||||
|
||||
@@ -206,7 +206,7 @@ ${Array.from(
|
||||
expect(explicit.activations).toEqual(shorthand.activations)
|
||||
})
|
||||
|
||||
test("centers message label blocks over their arrow span", () => {
|
||||
test("left-aligns message label blocks inside their arrow span", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
@@ -214,8 +214,6 @@ ${Array.from(
|
||||
A->>B: short<br/>a much longer line`),
|
||||
)
|
||||
const message = plan.steps.find((step) => step.type === "message")!
|
||||
const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
|
||||
|
||||
expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
|
||||
expect(message.labelX).toBe(message.leftX + 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import { normalizeSequenceMinParticipantGap } from "./options.js"
|
||||
import { normalizeSequenceMinParticipantGap, SEQUENCE_LIFELINE_FADE_STEPS } from "./options.js"
|
||||
import type {
|
||||
SequenceDiagram,
|
||||
SequenceDiagramRenderOptions,
|
||||
@@ -142,7 +142,7 @@ function messageLabelText(message: SequenceMessage): string {
|
||||
|
||||
function participantHeaderWidth(label: string, compact: boolean): number {
|
||||
const width = labelLinesWidth(mermaidLabelLines(label))
|
||||
return compact ? width : Math.max(5, width + 4)
|
||||
return compact ? width : Math.max(3, width)
|
||||
}
|
||||
|
||||
function fragmentLabelText(fragment: SequenceFragment): string {
|
||||
@@ -247,7 +247,7 @@ function getStepContentBounds(
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
const labelWidth = messageWidth(step.message)
|
||||
const labelLeftX = Math.floor((leftX + rightX - labelWidth) / 2)
|
||||
const labelLeftX = leftX + 2
|
||||
return { leftX: Math.min(leftX, labelLeftX), rightX: Math.max(rightX, labelLeftX + labelWidth - 1) }
|
||||
}
|
||||
if (step.type !== "note") return undefined
|
||||
@@ -525,13 +525,16 @@ export function createSequencePlacementPlan(
|
||||
...diagram.participants.map((participant) => mermaidLabelLines(participant.label).length),
|
||||
)
|
||||
const participantHeaderTopY = hasGroups ? 1 : 0
|
||||
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
|
||||
const participantHeaderY = participantHeaderTopY
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
|
||||
const lifelineStartY = participantRuleY + 1
|
||||
const stepStartY = lifelineStartY + 1
|
||||
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
|
||||
const baseHeight =
|
||||
stepStartY + diagram.steps.reduce((total, step) => total + getStepHeight(step, centers, indexes, compact), 0)
|
||||
stepStartY +
|
||||
diagram.steps.reduce((total, step) => total + getStepHeight(step, centers, indexes, compact), 0) +
|
||||
SEQUENCE_LIFELINE_FADE_STEPS.length -
|
||||
1
|
||||
const height = hasGroups ? Math.max(5, baseHeight + 1) : Math.max(3, baseHeight)
|
||||
const lifelineEndY = hasGroups ? height - 2 : height - 1
|
||||
const participants = diagram.participants.map((participant, index) => {
|
||||
@@ -650,7 +653,7 @@ export function createSequencePlacementPlan(
|
||||
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
|
||||
const arrowY = inlineLabel ? stepY : stepY + labelLines.length
|
||||
const renderedLabelWidth = inlineLabel ? visualLength(inlineLabel) : labelLinesWidth(labelLines)
|
||||
const labelX = Math.floor((leftX + rightX - renderedLabelWidth) / 2)
|
||||
const labelX = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
|
||||
steps.push({
|
||||
type: "message",
|
||||
message: step.message,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
blendColor,
|
||||
createColorRampTheme,
|
||||
DIAGRAM_FADE_STEPS,
|
||||
numberedStyleKeys,
|
||||
rgba,
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
import type { FadeStyle, LifelineRampStyle, SequenceCellStyle } from "./types.js"
|
||||
import { SEQUENCE_LIFELINE_FADE_STEPS } from "./options.js"
|
||||
import type { FadeStyle, LifelineFadeStyle, LifelineRampStyle, SequenceCellStyle } from "./types.js"
|
||||
|
||||
export interface SequenceStyleColors {
|
||||
participant?: RGBA
|
||||
lifeline?: RGBA
|
||||
lifelineEnd?: RGBA
|
||||
group?: RGBA
|
||||
request?: RGBA
|
||||
response?: RGBA
|
||||
@@ -30,6 +33,7 @@ const LIFELINE_RAMP_STYLES = [
|
||||
const DEFAULT_THEME_RGB = {
|
||||
participant: [228, 239, 232],
|
||||
lifeline: [111, 138, 126],
|
||||
lifelineEnd: [15, 23, 19],
|
||||
group: [76, 99, 89],
|
||||
request: [134, 225, 200],
|
||||
response: [230, 177, 126],
|
||||
@@ -41,14 +45,22 @@ const DEFAULT_THEME_RGB = {
|
||||
|
||||
export function resolveSequenceStyleColors(
|
||||
colors: SequenceStyleColors = {},
|
||||
): Required<SequenceStyleColors> & Record<FadeStyle | LifelineRampStyle, RGBA> {
|
||||
): Required<SequenceStyleColors> & Record<FadeStyle | LifelineFadeStyle | LifelineRampStyle, RGBA> {
|
||||
const participant = colors.participant ?? rgba(DEFAULT_THEME_RGB.participant)
|
||||
const lifeline = colors.lifeline ?? rgba(DEFAULT_THEME_RGB.lifeline)
|
||||
const request = colors.request ?? rgba(DEFAULT_THEME_RGB.request)
|
||||
const response = colors.response ?? rgba(DEFAULT_THEME_RGB.response)
|
||||
const lifelineEnd = colors.lifelineEnd ?? rgba(DEFAULT_THEME_RGB.lifelineEnd)
|
||||
const lifelineFade = Object.fromEntries(
|
||||
SEQUENCE_LIFELINE_FADE_STEPS.map((step, index) => [
|
||||
`lifelineFade${step}`,
|
||||
blendColor(lifeline, lifelineEnd, index / (SEQUENCE_LIFELINE_FADE_STEPS.length - 1)),
|
||||
]),
|
||||
) as Record<LifelineFadeStyle, RGBA>
|
||||
return {
|
||||
participant,
|
||||
lifeline,
|
||||
lifelineEnd,
|
||||
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
|
||||
request,
|
||||
response,
|
||||
@@ -59,12 +71,13 @@ export function resolveSequenceStyleColors(
|
||||
...createColorRampTheme(numberedStyleKeys("requestFade", SEQUENCE_FADE_STEPS), lifeline, request),
|
||||
...createColorRampTheme(numberedStyleKeys("responseFade", SEQUENCE_FADE_STEPS), lifeline, response),
|
||||
...createColorRampTheme(LIFELINE_RAMP_STYLES, participant, lifeline),
|
||||
...lifelineFade,
|
||||
}
|
||||
}
|
||||
|
||||
export function sequenceStyleColor(
|
||||
style: SequenceCellStyle | undefined,
|
||||
colors: Required<SequenceStyleColors> & Record<FadeStyle | LifelineRampStyle, RGBA>,
|
||||
colors: Required<SequenceStyleColors> & Record<FadeStyle | LifelineFadeStyle | LifelineRampStyle, RGBA>,
|
||||
): RGBA | undefined {
|
||||
if (style === "noteBadge") return colors.note
|
||||
if (style === "fragmentLabel") return colors.fragment
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface SequenceDiagramRenderOptions {
|
||||
export type MessageStyle = "request" | "response"
|
||||
export type FadeStyle = `${MessageStyle}Fade${1 | 2 | 3 | 4 | 5}`
|
||||
export type LifelineRampStyle = `lifelineRamp${1 | 2 | 3}`
|
||||
export type LifelineFadeStyle = `lifelineFade${1 | 2 | 3 | 4 | 5}`
|
||||
export type SequenceCellStyle =
|
||||
| "participant"
|
||||
| "lifeline"
|
||||
@@ -68,6 +69,7 @@ export type SequenceCellStyle =
|
||||
| MessageStyle
|
||||
| FadeStyle
|
||||
| LifelineRampStyle
|
||||
| LifelineFadeStyle
|
||||
| "fragment"
|
||||
| "fragmentLabel"
|
||||
| "note"
|
||||
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
@@ -42,7 +42,6 @@ export const AgentSelected = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("agent-switched"),
|
||||
agent: Agent.ID,
|
||||
previous: Agent.ID.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.AgentSelected" })
|
||||
|
||||
export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
|
||||
|
||||
+24
-20
@@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import fs from "fs"
|
||||
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||
import os from "os"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
@@ -12,8 +12,7 @@ const cache = path.join(xdgCache!, app)
|
||||
const config = path.join(xdgConfig!, app)
|
||||
const state = path.join(xdgState!, app)
|
||||
const tmp = path.join(os.tmpdir(), app)
|
||||
|
||||
await fs.mkdir(tmp, { recursive: true })
|
||||
const resolvedTmp: { value?: string } = {}
|
||||
|
||||
const paths = {
|
||||
get home() {
|
||||
@@ -26,22 +25,18 @@ const paths = {
|
||||
cache,
|
||||
config,
|
||||
state,
|
||||
tmp: await fs.realpath(tmp),
|
||||
get tmp() {
|
||||
if (resolvedTmp.value) return resolvedTmp.value
|
||||
fs.mkdirSync(tmp, { recursive: true })
|
||||
resolvedTmp.value = fs.realpathSync(tmp)
|
||||
return resolvedTmp.value
|
||||
},
|
||||
}
|
||||
|
||||
export const Path = paths
|
||||
|
||||
Flock.setGlobal({ state })
|
||||
|
||||
await Promise.all([
|
||||
fs.mkdir(Path.data, { recursive: true }),
|
||||
fs.mkdir(Path.config, { recursive: true }),
|
||||
fs.mkdir(Path.state, { recursive: true }),
|
||||
fs.mkdir(Path.log, { recursive: true }),
|
||||
fs.mkdir(Path.bin, { recursive: true }),
|
||||
fs.mkdir(Path.repos, { recursive: true }),
|
||||
])
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
|
||||
|
||||
export interface Interface {
|
||||
@@ -63,7 +58,7 @@ export function make(input: Partial<Interface> = {}): Interface {
|
||||
cache: Path.cache,
|
||||
config: Path.config,
|
||||
state: Path.state,
|
||||
tmp: Path.tmp,
|
||||
tmp: input.tmp ?? Path.tmp,
|
||||
bin: Path.bin,
|
||||
log: Path.log,
|
||||
repos: Path.repos,
|
||||
@@ -71,17 +66,26 @@ export function make(input: Partial<Interface> = {}): Interface {
|
||||
}
|
||||
}
|
||||
|
||||
const acquire = (input: Partial<Interface>) =>
|
||||
Effect.gen(function* () {
|
||||
const service = Service.of(make(input))
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
[service.data, service.config, service.state, service.log, service.bin, service.repos, service.tmp].map(
|
||||
(directory) => fs.promises.mkdir(directory, { recursive: true }),
|
||||
),
|
||||
),
|
||||
)
|
||||
return service
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.sync(() => Service.of(make({ config: process.env.OPENCODE_CONFIG_DIR ?? Path.config }))),
|
||||
Effect.suspend(() => acquire({ config: process.env.OPENCODE_CONFIG_DIR ?? Path.config })),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
export const layerWith = (input: Partial<Interface>) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.sync(() => Service.of(make(input))),
|
||||
)
|
||||
export const layerWith = (input: Partial<Interface>) => Layer.effect(Service, acquire(input))
|
||||
|
||||
export * as Global from "./global.js"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Formatter, Logger, type LogLevel } from "effect"
|
||||
import { Effect, FileSystem, Formatter, Logger, type LogLevel } from "effect"
|
||||
import path from "path"
|
||||
import { Global } from "../global.js"
|
||||
import { runID } from "./shared.js"
|
||||
@@ -53,7 +53,11 @@ export function file(local = true, channel = "local") {
|
||||
|
||||
export function fileLogger(target = file(), id: string = runID) {
|
||||
// Do not set batchWindow to 0; it causes high idle CPU usage.
|
||||
return Logger.toFile(formatter(id), target, { flag: "a" })
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* fs.makeDirectory(path.dirname(target), { recursive: true })
|
||||
return yield* Logger.toFile(formatter(id), target, { flag: "a" })
|
||||
})
|
||||
}
|
||||
|
||||
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Global } from "../src/global.js"
|
||||
|
||||
describe("global", () => {
|
||||
test("importing the module does not create directories", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-import-"))
|
||||
const directories = ["data", "cache", "config", "state", "tmp"].map((directory) => path.join(root, directory))
|
||||
const module = pathToFileURL(path.join(import.meta.dir, "../src/global.ts")).href
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [process.execPath, "-e", `await import(${JSON.stringify(module)})`],
|
||||
env: {
|
||||
...process.env,
|
||||
XDG_DATA_HOME: directories[0],
|
||||
XDG_CACHE_HOME: directories[1],
|
||||
XDG_CONFIG_HOME: directories[2],
|
||||
XDG_STATE_HOME: directories[3],
|
||||
TMPDIR: directories[4],
|
||||
},
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0)
|
||||
directories.forEach((directory) => expect(fs.existsSync(path.join(directory, "opencode"))).toBe(false))
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("building the layer creates service directories", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-layer-"))
|
||||
const directories = {
|
||||
data: path.join(root, "data"),
|
||||
config: path.join(root, "config"),
|
||||
state: path.join(root, "state"),
|
||||
log: path.join(root, "log"),
|
||||
bin: path.join(root, "bin"),
|
||||
repos: path.join(root, "repos"),
|
||||
tmp: path.join(root, "tmp"),
|
||||
}
|
||||
|
||||
await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith(directories))))
|
||||
|
||||
Object.values(directories).forEach((directory) => expect(fs.statSync(directory).isDirectory()).toBe(true))
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { defineScript, Effect, Llm } from "opencode-drive"
|
||||
|
||||
const theme = Bun.env.DRIVE_THEME ?? "opencode"
|
||||
const output = Bun.env.DRIVE_SCREENSHOT ?? `artifacts/mermaid-${theme}.png`
|
||||
const animate = Bun.env.DRIVE_ANIMATE === "1"
|
||||
const cycleThemes = Bun.env.DRIVE_CYCLE_THEMES === "1"
|
||||
|
||||
const response = `\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
participant T as Ticket store
|
||||
participant P as PTY
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
Note over B,S: native browser Basic prompt
|
||||
B->>S: GET / · Basic
|
||||
S-->>B: 200 web UI
|
||||
Note over B,S: user opens terminal
|
||||
B->>S: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
|
||||
S->>T: issue { ptyID, … }
|
||||
S-->>B: { ticket }
|
||||
B->>S: WS …?ticket=…<br/>Upgrade: websocket
|
||||
S->>T: consume(token,scope)
|
||||
T-->>S: ok, delete
|
||||
S->>P: attach
|
||||
P-->>B: WS frames
|
||||
\`\`\``
|
||||
|
||||
export default defineScript({
|
||||
config: {
|
||||
autoupdate: false,
|
||||
},
|
||||
tuiConfig: {
|
||||
theme: {
|
||||
name: theme,
|
||||
mode: "dark",
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
viewport: { cols: 180, rows: 64 },
|
||||
},
|
||||
run: ({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.submit("Show the connection flow as a Mermaid sequence diagram")
|
||||
yield* llm.send(
|
||||
Llm.text(response, animate ? { delay: 80, chunkSize: 20 } : { delay: 0, chunkSize: response.length }),
|
||||
)
|
||||
yield* ui.waitFor("WS frames", { timeout: 10_000 })
|
||||
if (cycleThemes) {
|
||||
yield* Effect.sleep(800)
|
||||
yield* Effect.forEach(
|
||||
["everforest", "synthwave84", "matrix", "opencode"],
|
||||
(next) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.press("x", { ctrl: true })
|
||||
yield* ui.press("t")
|
||||
yield* ui.waitFor("Themes")
|
||||
yield* ui.type(next)
|
||||
yield* Effect.sleep(700)
|
||||
yield* ui.enter()
|
||||
yield* Effect.sleep(1_200)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
const screenshot = yield* ui.screenshot(`mermaid-${theme}`)
|
||||
yield* Effect.promise(async () => {
|
||||
await mkdir("artifacts", { recursive: true })
|
||||
await Bun.write(output, Bun.file(screenshot))
|
||||
})
|
||||
yield* Effect.log(`Saved ${output}`)
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user