Compare commits

..
Author SHA1 Message Date
Kit Langton 7c2e2e0c62 fix(core): add conservative shell parse fallback 2026-08-12 19:22:19 -04:00
19 changed files with 15 additions and 658 deletions
+5 -33
View File
@@ -10,7 +10,7 @@ import {
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceStopResponse } from "./generated/types.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -130,15 +130,7 @@ async function read(file?: string) {
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
if (text === undefined) return undefined
try {
const value: unknown = JSON.parse(text)
if (typeof value !== "object" || value === null) return undefined
if (!("url" in value) || typeof value.url !== "string") return undefined
if (!("pid" in value) || !Number.isInteger(value.pid) || typeof value.pid !== "number" || value.pid <= 0)
return undefined
if ("id" in value && value.id !== undefined && typeof value.id !== "string") return undefined
if ("version" in value && value.version !== undefined && typeof value.version !== "string") return undefined
if ("password" in value && value.password !== undefined && typeof value.password !== "string") return undefined
return value as Info
return JSON.parse(text) as Info
} catch {
return undefined
}
@@ -171,7 +163,7 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
})
.then(async (response) => ({
response,
body: (await response.json()) as unknown,
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
}))
.then(
(value) => ({ value }),
@@ -180,18 +172,7 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
const body = result.value.body
if (
typeof body === "object" &&
body !== null &&
"healthy" in body &&
body.healthy === true &&
"version" in body &&
typeof body.version === "string" &&
"pid" in body &&
typeof body.pid === "number" &&
Number.isInteger(body.pid) &&
body.pid > 0
) {
if (body !== undefined && "version" in body && "pid" in body) {
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && body.version !== info.version) return { service: undefined, timedOut: false }
return {
@@ -205,16 +186,7 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
timedOut: false,
}
}
if (
!allowLegacy ||
typeof body !== "object" ||
body === null ||
!("healthy" in body) ||
body.healthy !== true ||
"version" in body ||
"pid" in body
)
return { service: undefined, timedOut: false }
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
return {
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
timedOut: false,
@@ -38,50 +38,6 @@ test("discovers a compatible registered service", async () => {
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
})
test("rejects malformed registrations without probing or signaling", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const malformed = [
null,
[],
{},
{ url: "http://127.0.0.1:1" },
{ url: "http://127.0.0.1:1", pid: 0 },
{ url: "http://127.0.0.1:1", pid: -1 },
{ url: "http://127.0.0.1:1", pid: 1.5 },
{ url: "http://127.0.0.1:1", pid: "1" },
{ url: "http://127.0.0.1:1", pid: 1, id: 1 },
]
for (const value of malformed) {
await Bun.write(registration, JSON.stringify(value))
expect(await Service.discover({ file: registration })).toBeUndefined()
}
})
test("rejects primitive and partial modern health responses", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const bodies = [
null,
1,
"healthy",
[],
{},
{ healthy: false, version: "test", pid: process.pid },
{ healthy: true, version: null, pid: process.pid },
{ healthy: true, version: "test", pid: "1" },
{ healthy: true, version: "test" },
{ healthy: true, pid: process.pid },
]
for (const body of bodies) {
using server = Bun.serve({ port: 0, fetch: () => Response.json(body) })
await Bun.write(registration, JSON.stringify({ url: server.url.toString(), pid: process.pid }))
expect(await Service.discover({ file: registration })).toBeUndefined()
}
})
test("ensures a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+9 -2
View File
@@ -1,6 +1,6 @@
export * as ShellParse from "./parse.js"
import { Effect } from "effect"
import { Effect, Exit } from "effect"
import { fileURLToPath } from "url"
import os from "os"
import path from "path"
@@ -153,8 +153,15 @@ const ARITY: Record<string, number> = {
}
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const loaded = yield* Effect.promise(load).pipe(Effect.exit)
// Workerd has no filesystem-backed tree-sitter assets. Preserve execution
// with one conservative permission resource instead of disabling shell.
if (Exit.isFailure(loaded)) {
const tokens = command.trim().split(/\s+/)
return { commands: [{ resource: command, save: `${prefix(tokens).join(" ")} *` }], directories: [] }
}
const parsers = loaded.value
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"
import wrangler from "../wrangler.jsonc"
import { assetPath } from "../worker"
describe("catalog worker", () => {
@@ -13,8 +12,4 @@ describe("catalog worker", () => {
expect(assetPath("/lab/catalog/catalog.json")).toBe("/catalog.json")
expect(assetPath("/lab/catalog/captures/opencode/home.frame.json")).toBe("/captures/opencode/home.frame.json")
})
test("leaves HTML routing to the worker", () => {
expect(wrangler.assets.html_handling).toBe("none")
})
})
-1
View File
@@ -8,7 +8,6 @@
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"html_handling": "none",
},
"routes": [
{
-2
View File
@@ -2,12 +2,10 @@ import type { MermaidDiagramKind } from "./diagnostics.js"
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
import { isMermaidStateDiagram } from "./state/parser.js"
import { isMermaidTimelineDiagram } from "./timeline/parser.js"
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
if (isMermaidFlowchartDiagram(content)) return "flowchart"
if (isMermaidSequenceDiagram(content)) return "sequence"
if (isMermaidStateDiagram(content)) return "state"
if (isMermaidTimelineDiagram(content)) return "timeline"
return undefined
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline"
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
/** An otherwise valid diagram contains syntax that this renderer does not support. */
export class MermaidSyntaxError extends Error {
-23
View File
@@ -25,10 +25,6 @@ import { drawStateDiagramGrid } from "./state/drawing.js"
import { parseMermaidStateDiagram } from "./state/parser.js"
import { renderStateGridStyledText } from "./state/render-grid.js"
import { resolveStateStyleColors } from "./state/style.js"
import { drawTimelineDiagramGrid } from "./timeline/drawing.js"
import { parseMermaidTimelineDiagram } from "./timeline/parser.js"
import { renderTimelineGridStyledText } from "./timeline/render-grid.js"
import { resolveTimelineStyleColors } from "./timeline/style.js"
type DiagramKind = NonNullable<ReturnType<typeof detectMermaidDiagram>>
@@ -184,25 +180,6 @@ function prepareDiagram(
height: size.height,
}
}
case "timeline": {
const grid = drawTimelineDiagramGrid(parseMermaidTimelineDiagram(source))
const size = grid.getTextSize({ trimBottom: true })
return {
kind,
source,
text: renderTimelineGridStyledText(
grid,
resolveTimelineStyleColors({
title: color(colors.text),
section: color(colors.secondary),
period: color(colors.warning),
spine: color(colors.muted),
event: color(colors.primary),
}),
),
height: size.height,
}
}
}
}
@@ -3,7 +3,6 @@ import { MermaidSyntaxError } from "../diagnostics.js"
import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
import { parseMermaidSequenceDiagram } from "../sequence/parser.js"
import { parseMermaidStateDiagram } from "../state/parser.js"
import { renderTimelineDiagram } from "../timeline/diagram.js"
import { renderSequenceDiagram } from "../sequence/diagram.js"
describe("parser diagnostics", () => {
@@ -105,12 +104,6 @@ describe("parser diagnostics", () => {
).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"')
})
test("reports malformed timeline continuations with timeline diagnostics", () => {
expect(() => renderTimelineDiagram("timeline\n : orphan event")).toThrow(
'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"',
)
})
test("does not attach else through an unclosed nested sequence block", () => {
expect(() =>
parseMermaidSequenceDiagram(`sequenceDiagram
-28
View File
@@ -333,31 +333,3 @@ stateDiagram-v2
expect(frame).toContain("Idle")
expect(frame).not.toContain("stateDiagram-v2")
})
test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-timeline",
content: `\`\`\`mermaid
timeline
title Product history
section Foundation
2024 : Prototype
: First release
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)
const frame = captureCharFrame()
expect(frame).toContain("Product history")
expect(frame).toContain("Foundation")
expect(frame).toContain("First release")
expect(frame).not.toContain("timeline")
})
@@ -1,188 +0,0 @@
import { describe, expect, test } from "bun:test"
import { renderTimelineDiagram } from "./diagram.js"
import { drawTimelineDiagramGrid } from "./drawing.js"
import { parseMermaidTimelineDiagram } from "./parser.js"
import { renderTimelineGridText } from "./render-grid.js"
import { resolveTimelineStyleColors } from "./style.js"
describe("TimelineDiagram", () => {
test("detects and parses titles, sections, periods, inline events, and continuations", () => {
const diagram = parseMermaidTimelineDiagram(`
%% product history
timeline LR
title Product &amp;<br/>Platform
section Foundation
2024 : Prototype : First release
: Public beta
section Growth
2025 : "Scale: &#x2265; 10k"
`)
expect(diagram.direction).toBe("LR")
expect(diagram.title).toBe("Product &<br/>Platform")
expect(diagram.sections).toEqual([{ label: "Foundation" }, { label: "Growth" }])
expect(diagram.periods).toEqual([
{ period: "2024", events: ["Prototype", "First release", "Public beta"] },
{ period: "2025", events: ["Scale: ≥ 10k"] },
])
expect(diagram.entries.map((entry) => entry.type)).toEqual(["section", "period", "section", "period"])
})
test("renders a vertical spine with title, section, periods, events, entities, and line breaks", () => {
const output = renderTimelineDiagram(`timeline
title Product &amp;<br/>Platform
section Foundation<br/>phase
2024 : Prototype<br/>ready : First release
: Scale &#x2265; 10k`)
expect(output).toBe(
[
" Product &",
" Platform",
"",
"Foundation ───┐",
" phase │",
" │",
" 2024 ───● Prototype",
" │ ready",
" │ First release",
" │ Scale ≥ 10k",
" │",
].join("\n"),
)
})
test.each(["timeline", "timeline TD", "timeline LR"])("uses the vertical terminal layout for %s", (header) => {
const output = renderTimelineDiagram(`${header}\n 2024 : One\n 2025 : Two`)
const lines = output.split("\n")
expect(lines.findIndex((line) => line.includes("2024"))).toBeLessThan(
lines.findIndex((line) => line.includes("2025")),
)
expect(output).toContain("│")
expect(output).toContain("●")
})
test("preserves Mermaid direction semantics while using vertical terminal layout", () => {
expect(parseMermaidTimelineDiagram("timeline\n 2024 : One").direction).toBe("LR")
expect(parseMermaidTimelineDiagram("timeline TD\n 2024 : One").direction).toBe("TD")
})
test("keeps ordinary colons in event text", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
2024 : https://example.com : event:detail : next event`)
expect(diagram.periods[0]?.events).toEqual(["https://example.com", "event:detail", "next event"])
})
test("does not treat apostrophes in event prose as quotes", () => {
const diagram = parseMermaidTimelineDiagram("timeline\n 2024 : Kit's launch : Public beta")
expect(diagram.periods[0]?.events).toEqual(["Kit's launch", "Public beta"])
})
test("supports standalone periods followed by continuation events", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
2024
: First release
: Public beta`)
expect(diagram.periods).toEqual([{ period: "2024", events: ["First release", "Public beta"] }])
})
test("ignores timeline comments and accessibility directives", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
# product history
accTitle: Product timeline
accDescr Product release history
2024 : Prototype %% internal note`)
expect(diagram.periods).toEqual([{ period: "2024", events: ["Prototype"] }])
})
test("ignores multiline accessibility descriptions", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
accDescr {
Product milestones by year.
Includes launch and growth.
}
2024 : Ship`)
expect(diagram.periods).toEqual([{ period: "2024", events: ["Ship"] }])
})
test("rejects a continuation without a period with source diagnostics", () => {
expect(() => parseMermaidTimelineDiagram("timeline\n : orphan event")).toThrow(
'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"',
)
})
test("rejects unsupported and empty syntax", () => {
expect(() => parseMermaidTimelineDiagram("timeline\n section")).toThrow("Timeline section cannot be empty")
expect(() => parseMermaidTimelineDiagram("timeline\n 2024 :")).toThrow("Timeline event cannot be empty")
expect(() => parseMermaidTimelineDiagram("timeline\n : unsupported")).toThrow("requires a preceding period")
})
test("draws semantic styles for every timeline role", () => {
const grid = drawTimelineDiagramGrid(
parseMermaidTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"),
)
const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean)))
expect(styles).toEqual(
new Set([
"title",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
"period",
"periodFade1",
"periodFade2",
"periodFade3",
"event",
]),
)
expect(Object.keys(resolveTimelineStyleColors()).sort()).toEqual([
"event",
"period",
"periodFade1",
"periodFade2",
"periodFade3",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
"title",
])
expect(renderTimelineGridText(grid)).toBe(
renderTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"),
)
})
test("uses section starts and joins with ordered color ramps", () => {
const grid = drawTimelineDiagramGrid(
parseMermaidTimelineDiagram("timeline\n section Morning\n 09:00 : Start\n section Midday\n 12:00 : Continue"),
)
const text = renderTimelineGridText(grid)
expect(text).toContain("Morning ───┐")
expect(text).toContain("Midday ───┤")
expect(grid.rows[0]?.map((cell) => cell.style).filter(Boolean)).toEqual([
"section",
"section",
"section",
"section",
"section",
"section",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
])
})
})
-8
View File
@@ -1,8 +0,0 @@
import { drawTimelineDiagramGrid } from "./drawing.js"
import { parseMermaidTimelineDiagram } from "./parser.js"
import { renderTimelineGridText } from "./render-grid.js"
import type { TimelineDiagramRenderOptions } from "./types.js"
export function renderTimelineDiagram(content: string, options: TimelineDiagramRenderOptions = {}): string {
return renderTimelineGridText(drawTimelineDiagramGrid(parseMermaidTimelineDiagram(content), options))
}
-109
View File
@@ -1,109 +0,0 @@
import { DiagramCanvas } from "../core/canvas.js"
import { splitDiagramLines } from "../core/text-lines.js"
import { diagramTextWidth } from "../core/text.js"
import type { TimelineGrid } from "./render-grid.js"
import { TIMELINE_PERIOD_FADE_STYLES, TIMELINE_SECTION_FADE_STYLES } from "./style.js"
import type { TimelineCellStyle, TimelineDiagram, TimelineDiagramRenderOptions, TimelinePeriod } from "./types.js"
interface PeriodLayout {
period: TimelinePeriod
periodLines: string[]
eventLines: string[][]
height: number
}
const JOIN_WIDTH = TIMELINE_SECTION_FADE_STYLES.length
const SPINE_OFFSET = JOIN_WIDTH + 1
const EVENT_OFFSET = 3
export function drawTimelineDiagramGrid(
diagram: TimelineDiagram,
_options: TimelineDiagramRenderOptions = {},
): TimelineGrid {
const periodLayouts = new Map<TimelinePeriod, PeriodLayout>()
let leftWidth = 0
let rightWidth = 0
let bodyHeight = 0
for (const entry of diagram.entries) {
if (entry.type === "section") {
const lines = splitDiagramLines(entry.section.label)
bodyHeight += lines.length + 1
for (const line of lines) leftWidth = Math.max(leftWidth, diagramTextWidth(line))
continue
}
const periodLines = splitDiagramLines(entry.period.period)
const eventLines = entry.period.events.map(splitDiagramLines)
const eventHeight = eventLines.reduce((height, lines) => height + lines.length, 0)
const height = Math.max(periodLines.length, eventHeight)
periodLayouts.set(entry.period, { period: entry.period, periodLines, eventLines, height })
for (const line of periodLines) leftWidth = Math.max(leftWidth, diagramTextWidth(line))
for (const lines of eventLines) {
for (const line of lines) rightWidth = Math.max(rightWidth, diagramTextWidth(line))
}
bodyHeight += height + 1
}
const titleLines = diagram.title ? splitDiagramLines(diagram.title) : []
const bodyWidth = diagram.entries.length === 0 ? 0 : leftWidth + SPINE_OFFSET + EVENT_OFFSET + rightWidth + 1
let titleWidth = 0
for (const line of titleLines) titleWidth = Math.max(titleWidth, diagramTextWidth(line))
const width = Math.max(bodyWidth, titleWidth)
const titleHeight = titleLines.length === 0 ? 0 : titleLines.length + (diagram.entries.length === 0 ? 0 : 1)
if (width === 0) return new DiagramCanvas(0, 0)
const grid: TimelineGrid = new DiagramCanvas(width, titleHeight + bodyHeight)
titleLines.forEach((line, index) =>
setText(grid, Math.floor((width - diagramTextWidth(line)) / 2), index, line, "title"),
)
if (diagram.entries.length === 0) return grid
const spineX = leftWidth + SPINE_OFFSET
let y = titleHeight
let railStarted = false
for (const entry of diagram.entries) {
if (entry.type === "section") {
const lines = splitDiagramLines(entry.section.label)
lines.forEach((line, index) => {
setText(grid, leftWidth - diagramTextWidth(line), y + index, line, "section")
if (index > 0) setCell(grid, spineX, y + index, "│", "spine")
})
drawJoin(grid, leftWidth, y, TIMELINE_SECTION_FADE_STYLES)
setCell(grid, spineX, y, railStarted ? "┤" : "┐", "spine")
setCell(grid, spineX, y + lines.length, "│", "spine")
railStarted = true
y += lines.length + 1
continue
}
const layout = periodLayouts.get(entry.period)!
for (let row = 0; row < layout.height + 1; row++) setCell(grid, spineX, y + row, "│", "spine")
railStarted = true
setCell(grid, spineX, y, "●", "spine")
layout.periodLines.forEach((line, index) => {
const lineWidth = diagramTextWidth(line)
setText(grid, leftWidth - lineWidth, y + index, line, "period")
})
drawJoin(grid, leftWidth, y, TIMELINE_PERIOD_FADE_STYLES)
let eventY = y
for (const lines of layout.eventLines) {
lines.forEach((line, index) => setText(grid, spineX + EVENT_OFFSET, eventY + index, line, "event"))
eventY += lines.length
}
y += layout.height + 1
}
return grid
}
function drawJoin(grid: TimelineGrid, x: number, y: number, styles: readonly TimelineCellStyle[]): void {
styles.forEach((style, index) => setCell(grid, x + index + 1, y, "─", style))
}
function setCell(grid: TimelineGrid, x: number, y: number, char: string, style: TimelineCellStyle): void {
grid.setCell(x, y, char, style)
}
function setText(grid: TimelineGrid, x: number, y: number, text: string, style: TimelineCellStyle): void {
grid.setText(x, y, text, style)
}
-119
View File
@@ -1,119 +0,0 @@
import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import type { TimelineDiagram, TimelineDirection, TimelineEntry, TimelinePeriod, TimelineSection } from "./types.js"
const HEADER_RE = /^timeline(?:\s+(TD|LR))?$/i
const TITLE_RE = /^title(?:\s+(.+))?$/i
const SECTION_RE = /^section(?:\s+(.+))?$/i
const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i
export function isMermaidTimelineDiagram(content: string): boolean {
return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
}
export function parseMermaidTimelineDiagram(content: string): TimelineDiagram {
const sections: TimelineSection[] = []
const periods: TimelinePeriod[] = []
const entries: TimelineEntry[] = []
let direction: TimelineDirection = "LR"
let title: string | undefined
let currentPeriod: TimelinePeriod | undefined
let inAccessibilityDescription = false
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = stripTimelineComment(source.text)
if (inAccessibilityDescription) {
if (line === "}") inAccessibilityDescription = false
continue
}
if (/^accDescr\s*\{$/i.test(line)) {
inAccessibilityDescription = true
continue
}
if (!line || line.startsWith("#") || ACCESSIBILITY_RE.test(line)) continue
const header = line.match(HEADER_RE)
if (header) {
direction = (header[1]?.toUpperCase() as TimelineDirection | undefined) ?? "LR"
continue
}
const titleMatch = line.match(TITLE_RE)
if (titleMatch) {
if (!titleMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline title cannot be empty")
title = stripMermaidQuotes(titleMatch[1])
continue
}
const sectionMatch = line.match(SECTION_RE)
if (sectionMatch) {
if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline section cannot be empty")
const section = { label: stripMermaidQuotes(sectionMatch[1]) }
sections.push(section)
entries.push({ type: "section", section })
currentPeriod = undefined
continue
}
if (line.startsWith(":")) {
if (!currentPeriod) {
throw syntaxError(source.lineNumber, line, "Timeline continuation requires a preceding period")
}
currentPeriod.events.push(...parseEvents(line.slice(1), source.lineNumber, line))
continue
}
const fields = splitEventFields(line)
const periodLabel = stripMermaidQuotes(fields.shift()!)
if (!periodLabel) throw syntaxError(source.lineNumber, line, "Timeline period cannot be empty")
const period = {
period: periodLabel,
events: fields.length === 0 ? [] : parseEventFields(fields, source.lineNumber, line),
}
periods.push(period)
entries.push({ type: "period", period })
currentPeriod = period
}
return { direction, ...(title === undefined ? {} : { title }), sections, periods, entries }
}
function parseEvents(value: string, lineNumber: number, sourceLine: string): string[] {
return parseEventFields(splitEventFields(value), lineNumber, sourceLine)
}
function parseEventFields(fields: string[], lineNumber: number, sourceLine: string): string[] {
const events = fields.map(stripMermaidQuotes)
if (events.length === 0 || events.some((event) => event.length === 0)) {
throw syntaxError(lineNumber, sourceLine, "Timeline event cannot be empty")
}
return events
}
function splitEventFields(value: string): string[] {
const fields: string[] = []
let quote: '"' | "'" | undefined
let start = 0
for (let index = 0; index < value.length; index++) {
const char = value[index]
if (char === '"' || char === "'") {
if (quote === char) quote = undefined
else if (quote === undefined && value.slice(start, index).trim() === "") quote = char
continue
}
const next = value[index + 1]
if (char !== ":" || quote !== undefined || (next !== undefined && !/\s/.test(next))) continue
fields.push(value.slice(start, index))
start = index + 1
}
fields.push(value.slice(start))
return fields
}
function stripTimelineComment(value: string): string {
const comment = value.indexOf("%%")
return (comment < 0 ? value : value.slice(0, comment)).trim()
}
function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError {
return new MermaidSyntaxError("timeline", lineNumber, sourceLine, reason)
}
@@ -1,17 +0,0 @@
import type { StyledText } from "@opentui/core"
import type { DiagramCanvas } from "../core/canvas.js"
import { renderDiagramGridStyledText } from "../core/render-grid.js"
import type { TimelineStyleColors } from "./style.js"
import type { TimelineCellStyle } from "./types.js"
export type TimelineGrid = DiagramCanvas<TimelineCellStyle>
export function renderTimelineGridText(grid: TimelineGrid): string {
return grid.toString({ trimBottom: true })
}
export function renderTimelineGridStyledText(grid: TimelineGrid, colors: TimelineStyleColors): StyledText {
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
trimBottom: true,
})
}
-36
View File
@@ -1,36 +0,0 @@
import { RGBA } from "@opentui/core"
import { blendColor, numberedStyleKeys, rgba, type DiagramRgb } from "../core/color/style.js"
import type { TimelineBaseCellStyle, TimelineCellStyle } from "./types.js"
const DEFAULT_THEME_RGB = {
title: [228, 239, 232],
section: [154, 184, 169],
period: [230, 177, 126],
spine: [111, 138, 126],
event: [134, 225, 200],
} as const satisfies Record<TimelineBaseCellStyle, DiagramRgb>
export type TimelineStyleColors = Required<Record<TimelineCellStyle, RGBA>>
export const TIMELINE_SECTION_FADE_STYLES = numberedStyleKeys("sectionFade", [1, 2, 3] as const)
export const TIMELINE_PERIOD_FADE_STYLES = numberedStyleKeys("periodFade", [1, 2, 3] as const)
export function resolveTimelineStyleColors(
colors: Partial<Record<TimelineBaseCellStyle, RGBA | undefined>> = {},
): TimelineStyleColors {
const section = colors.section ?? rgba(DEFAULT_THEME_RGB.section)
const period = colors.period ?? rgba(DEFAULT_THEME_RGB.period)
const spine = colors.spine ?? rgba(DEFAULT_THEME_RGB.spine)
return {
title: colors.title ?? rgba(DEFAULT_THEME_RGB.title),
section,
period,
spine,
event: colors.event ?? rgba(DEFAULT_THEME_RGB.event),
sectionFade1: blendColor(section, spine, 0.5),
sectionFade2: blendColor(section, spine, 0.67),
sectionFade3: blendColor(section, spine, 0.83),
periodFade1: blendColor(period, spine, 0.5),
periodFade2: blendColor(period, spine, 0.67),
periodFade3: blendColor(period, spine, 0.83),
}
}
-31
View File
@@ -1,31 +0,0 @@
export type TimelineDirection = "TD" | "LR"
export interface TimelineSection {
label: string
}
export interface TimelinePeriod {
period: string
events: string[]
}
export type TimelineEntry = { type: "section"; section: TimelineSection } | { type: "period"; period: TimelinePeriod }
export interface TimelineDiagram {
direction: TimelineDirection
title?: string
sections: TimelineSection[]
periods: TimelinePeriod[]
entries: TimelineEntry[]
}
export interface TimelineDiagramRenderOptions {
/** Parsed for Mermaid compatibility. Timeline diagrams always use a vertical terminal layout. */
direction?: TimelineDirection
}
export type TimelineBaseCellStyle = "title" | "section" | "period" | "spine" | "event"
export type TimelineFadeStep = 1 | 2 | 3
export type TimelineSectionFadeStyle = `sectionFade${TimelineFadeStep}`
export type TimelinePeriodFadeStyle = `periodFade${TimelineFadeStep}`
export type TimelineCellStyle = TimelineBaseCellStyle | TimelineSectionFadeStyle | TimelinePeriodFadeStyle
-1
View File
@@ -7,7 +7,6 @@ export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Config } from "@opencode-ai/schema/config"
export { Credential } from "@opencode-ai/schema/credential"
export { Event } from "@opencode-ai/schema/event"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location"
@@ -4,7 +4,6 @@ import { SessionInbox as CoreSessionInbox } from "@opencode-ai/core/session/inbo
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Agent } from "@opencode-ai/schema/agent"
import { Config } from "@opencode-ai/schema/config"
import { Event } from "@opencode-ai/schema/event"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
@@ -27,7 +26,6 @@ const CoreSession = await import("@opencode-ai/core/session")
test("re-exports canonical contracts directly from Schema", () => {
expect(SDK.Agent).toBe(Agent)
expect(SDK.Config).toBe(Config)
expect(SDK.Event).toBe(Event)
expect(SDK.Model).toBe(Model)
expect(SDK.WebSearch).toBe(WebSearch)
expect(SDK.Session).toBe(Session)
@@ -39,7 +37,6 @@ test("re-exports canonical contracts directly from Schema", () => {
"Command",
"Config",
"Credential",
"Event",
"FileSystem",
"Integration",
"Location",