Compare commits

..

6 Commits

Author SHA1 Message Date
Kit Langton a93c9135d4 fix(util): create log directory before opening logger 2026-08-10 19:42:41 -04:00
Kit Langton 9d34a927d6 refactor(util): simplify global directory acquisition 2026-08-10 19:42:41 -04:00
Kit Langton 6345810764 fix(util): no filesystem side effects at global module load 2026-08-10 19:42:41 -04:00
Kit Langton 2580f880a8 feat(merman): refine sequence diagram styling (#41617) 2026-08-10 19:12:46 -04:00
Kit Langton 9d34029cd9 fix(core): runtime-neutral legacy credential import (#41607) 2026-08-10 18:35:52 -04:00
opencode-agent[bot] 33296e7959 test(app): make offset observer scheduling deterministic (#41602)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-10 17:24:05 -05:00
23 changed files with 412 additions and 283 deletions
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { type Virtualizer } from "@tanstack/solid-virtual"
import { Window } from "happy-dom"
import { Node, Window } from "happy-dom"
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
test("matches only the scroll element or an ancestor containing it", () => {
@@ -18,6 +18,7 @@ test("matches only the scroll element or an ancestor containing it", () => {
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const route = targetWindow.document.createElement("section")
const viewport = targetWindow.document.createElement("div")
const unrelated = targetWindow.document.createElement("div")
@@ -40,24 +41,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
targetWindow.document.body.append(unrelated)
unrelated.remove()
await frames(2, targetWindow)
expect(calls).toEqual([])
try {
mutations.append(targetWindow.document.body, unrelated)
mutations.remove(unrelated)
expect(calls).toEqual([])
route.remove()
targetWindow.document.body.append(route)
await waitFor(() => calls.length === 1, targetWindow)
expect(calls).toEqual([[0, false]])
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
await frames(2, targetWindow)
expect(calls).toEqual([[0, false]])
route.remove()
targetWindow.document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3, targetWindow)
expect(calls).toEqual([[0, false]])
cleanup?.()
await targetWindow.happyDOM.close()
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
await frames(2, targetWindow)
expect(calls).toEqual([[0, false]])
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
}
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
@@ -204,7 +205,33 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
}
}
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
const deadline = targetWindow.performance.now() + 1_000
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
function controlledMutations(targetWindow: Window) {
let emit: (record: MutationRecord) => void = () => {
throw new Error("Mutation observer is not active")
}
class ControlledMutationObserver {
constructor(callback: MutationCallback) {
emit = (record) => callback([record], this as unknown as MutationObserver)
}
observe() {}
disconnect() {}
takeRecords() {
return []
}
}
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
return {
append(parent: Node, node: Node) {
parent.appendChild(node)
emit(record(parent, [node], []))
},
remove(node: Node) {
const parent = node.parentNode
if (!parent) throw new Error("Mutation target has no parent")
parent.removeChild(node)
emit(record(parent, [], [node]))
},
}
}
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
@@ -41,9 +42,9 @@ export default migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () {
const file = Bun.file(filepath)
if (!(yield* Effect.promise(() => file.exists()))) return
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
if (content === undefined) return
const input = Option.getOrUndefined(decodeJson(content))
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
}
@@ -164,6 +164,20 @@ describe("DatabaseMigration", () => {
expect(await Bun.file(source).text()).toBe(content)
})
test("skips legacy credential import when the source file is absent", async () => {
await using tmp = await tmpdir()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
yield* db.transaction((tx) => importLegacyCredentials(tx, path.join(tmp.path, "missing-auth.json")))
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
}),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
+5 -7
View File
@@ -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)}`,
+9 -4
View File
@@ -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,
+9
View File
@@ -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)
})
})
+6
View File
@@ -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),
}
}
+2
View File
@@ -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,
}),
})),
+106 -69
View File
@@ -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)
})
+14 -38
View File
@@ -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)
}
}
+1
View File
@@ -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)
})
})
+10 -7
View File
@@ -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,
+16 -3
View File
@@ -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
+2
View File
@@ -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"
-13
View File
@@ -928,19 +928,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
},
onAdmissionError: renderPromptError,
onCompact: async () => {
await state.switching?.catch(() => {})
if (state.model)
await state.sdk.session.switchModel(
{
sessionID: state.sessionID,
model: {
providerID: state.model.providerID,
id: state.model.modelID,
variant: state.activeVariant,
},
},
formRequestOptions(state.location),
)
await state.sdk.session.compact({ sessionID: state.sessionID }, formRequestOptions(state.location))
},
settle: async () => {
-86
View File
@@ -164,92 +164,6 @@ describe("run interactive runtime", () => {
await task
})
test("switches to the active model and variant before compacting", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const ui = createFooterApiFixture()
const api = ui.api
let lifecycle!: LifecycleInput
const calls: string[] = []
const model = catalogModel({
id: "selected",
providerID: "test",
name: "Selected Model",
variants: ["high"],
})
stubCatalogLists(sdk, {
providers: [catalogProvider("test", "Test Provider")],
models: [model],
})
const switched = spyOn(sdk.session, "switchModel").mockImplementation((input) => {
calls.push("switch")
expect(input).toEqual({
sessionID: "ses_root",
model: { providerID: "test", id: "selected", variant: "high" },
})
return ok(undefined)
})
const compacted = spyOn(sdk.session, "compact").mockImplementation(() => {
calls.push("compact")
api.close()
return ok({}) as never
})
const task = runInteractiveDeferredMode(
{
host: host(),
sdk,
directory: "/tmp",
target: async () => ({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
resume: false,
}),
agent: "build",
model: undefined,
variant: undefined,
files: [],
},
{
createRuntimeLifecycle: async (input) => {
lifecycle = input
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
streamTransport: Promise.resolve({
createSessionTransport: async () => ({
runPromptTurn: async () => {},
admitPromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
replayOnResize: async () => false,
close: async () => {},
}),
formatUnknownError: (error: unknown) => String(error),
}),
},
)
await ui.promptReady
await lifecycle.onModelSelect?.({ providerID: "test", modelID: "selected" })
await lifecycle.onVariantSelect?.("high")
expect(ui.submit("/compact")).toBe(true)
await task
expect(switched).toHaveBeenCalledTimes(1)
expect(compacted).toHaveBeenCalledTimes(1)
expect(calls).toEqual(["switch", "compact"])
})
test("routes form responses to their owners with global location and local settlement", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const api = footer()
+24 -20
View File
@@ -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"
+6 -2
View File
@@ -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"))
+49
View File
@@ -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 })
})
})
+75
View File
@@ -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}`)
}),
})