From 57b0d1085de54cd392938f6830f85c428a885f2a Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 17:17:13 -0400 Subject: [PATCH 1/2] feat(jetbrains): render all Mermaid diagram types in chat Extend the in-process Kotlin Mermaid engine to cover every diagram type Type can detect, not just flowcharts and sequence diagrams: class, state, ER, gantt, pie, user journey, quadrant, requirement, git graph, C4 (all five headers), mindmap, timeline, sankey, XY chart, block, packet, kanban, architecture, radar, and treemap. Each type gets its own parser + layout file under mermaid/, sharing a new Sheet (mark collection/normalization), Layered (deterministic graph layout for the class/state/er/requirement/C4 family), Scopes (composite/boundary edge re-anchoring), Axis (chart ticks), and Lex (bracket/quote-aware tokenizing) so the per-type files stay small. The Art model gains a Sector mark for pie wedges plus optional tone/soft fields for categorical chart colors, and Head gains UML/ER arrow variants (Triangle, Diamond, Crow, Bar, ...). Existing marks keep their prior toString() output when the new fields are unset, so flowchart/sequence snapshots are unchanged. Unsupported types (zenuml, venn, ...) keep the existing soft "unsupported" note over the source rather than an error. --- .changeset/jetbrains-mermaid-all-diagrams.md | 5 + .../ai/kilocode/client/ui/diagram/Art.kt | 61 ++- .../ai/kilocode/client/ui/diagram/Palette.kt | 7 + .../client/ui/diagram/ScenePainter.kt | 114 +++++- .../ai/kilocode/client/ui/diagram/Type.kt | 33 +- .../client/ui/diagram/mermaid/Arch.kt | 258 +++++++++++++ .../client/ui/diagram/mermaid/Axis.kt | 39 ++ .../client/ui/diagram/mermaid/BlockDg.kt | 139 +++++++ .../client/ui/diagram/mermaid/C4Dg.kt | 212 +++++++++++ .../client/ui/diagram/mermaid/ClassDg.kt | 246 +++++++++++++ .../client/ui/diagram/mermaid/ErDg.kt | 165 +++++++++ .../client/ui/diagram/mermaid/Gantt.kt | 171 +++++++++ .../client/ui/diagram/mermaid/GitDg.kt | 131 +++++++ .../client/ui/diagram/mermaid/Journey.kt | 111 ++++++ .../client/ui/diagram/mermaid/Kanban.kt | 90 +++++ .../client/ui/diagram/mermaid/Layered.kt | 139 +++++++ .../kilocode/client/ui/diagram/mermaid/Lex.kt | 60 +++ .../client/ui/diagram/mermaid/Mermaid.kt | 33 +- .../client/ui/diagram/mermaid/Mindmap.kt | 139 +++++++ .../client/ui/diagram/mermaid/Packet.kt | 79 ++++ .../kilocode/client/ui/diagram/mermaid/Pie.kt | 103 ++++++ .../client/ui/diagram/mermaid/Quadrant.kt | 108 ++++++ .../client/ui/diagram/mermaid/Radar.kt | 175 +++++++++ .../client/ui/diagram/mermaid/ReqDg.kt | 148 ++++++++ .../client/ui/diagram/mermaid/Sankey.kt | 153 ++++++++ .../client/ui/diagram/mermaid/Scopes.kt | 51 +++ .../client/ui/diagram/mermaid/SeqLayout.kt | 2 + .../client/ui/diagram/mermaid/Sheet.kt | 135 +++++++ .../client/ui/diagram/mermaid/StateDg.kt | 223 +++++++++++ .../client/ui/diagram/mermaid/Timeline.kt | 87 +++++ .../client/ui/diagram/mermaid/Treemap.kt | 110 ++++++ .../client/ui/diagram/mermaid/XyChart.kt | 145 ++++++++ .../client/ui/diagram/ui/DiagramTheme.kt | 14 + .../kilocode/client/ui/diagram/CancelTest.kt | 14 + .../client/ui/diagram/ConformanceTest.kt | 47 ++- .../client/ui/diagram/DiagramAsserts.kt | 1 + .../kilocode/client/ui/diagram/ErrorTest.kt | 2 +- .../client/ui/diagram/InvariantTest.kt | 2 +- .../client/ui/diagram/ScenePainterTest.kt | 35 ++ .../client/ui/diagram/SerializeTest.kt | 3 + .../ai/kilocode/client/ui/diagram/TypeTest.kt | 18 + .../client/ui/diagram/mermaid/EnginesTest.kt | 347 ++++++++++++++++++ .../client/ui/md/MdViewDiagramTest.kt | 6 +- .../resources/diagram/architecture-basic.mmd | 9 + .../test/resources/diagram/block-basic.mmd | 7 + .../src/test/resources/diagram/c4-basic.mmd | 7 + .../test/resources/diagram/class-basic.mmd | 20 + .../src/test/resources/diagram/er-basic.mmd | 13 + .../test/resources/diagram/gantt-basic.mmd | 9 + .../src/test/resources/diagram/git-basic.mmd | 8 + .../test/resources/diagram/journey-basic.mmd | 8 + .../test/resources/diagram/kanban-basic.mmd | 8 + .../test/resources/diagram/mindmap-basic.mmd | 10 + .../test/resources/diagram/packet-basic.mmd | 14 + .../src/test/resources/diagram/pie-basic.mmd | 6 + .../test/resources/diagram/quadrant-basic.mmd | 11 + .../test/resources/diagram/radar-basic.mmd | 8 + .../resources/diagram/requirement-basic.mmd | 11 + .../test/resources/diagram/sankey-basic.mmd | 6 + .../test/resources/diagram/state-basic.mmd | 11 + .../test/resources/diagram/timeline-basic.mmd | 6 + .../test/resources/diagram/treemap-basic.mmd | 8 + .../test/resources/diagram/xychart-basic.mmd | 6 + 63 files changed, 4300 insertions(+), 47 deletions(-) create mode 100644 .changeset/jetbrains-mermaid-all-diagrams.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Arch.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Axis.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/BlockDg.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/C4Dg.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ClassDg.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ErDg.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Gantt.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/GitDg.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Journey.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Kanban.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Layered.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Lex.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Mindmap.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Packet.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Pie.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Quadrant.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Radar.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ReqDg.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sankey.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Scopes.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sheet.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/StateDg.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Timeline.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Treemap.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/XyChart.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/mermaid/EnginesTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/architecture-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/block-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/c4-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/class-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/er-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/gantt-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/git-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/journey-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/kanban-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/mindmap-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/packet-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/pie-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/quadrant-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/radar-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/requirement-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/sankey-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/state-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/timeline-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/treemap-basic.mmd create mode 100644 packages/kilo-jetbrains/frontend/src/test/resources/diagram/xychart-basic.mmd diff --git a/.changeset/jetbrains-mermaid-all-diagrams.md b/.changeset/jetbrains-mermaid-all-diagrams.md new file mode 100644 index 00000000000..d1e22fde54c --- /dev/null +++ b/.changeset/jetbrains-mermaid-all-diagrams.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Render all Mermaid diagram types natively in JetBrains chat: class, state, ER, gantt, pie, user journey, quadrant, requirement, git graph, C4, mindmap, timeline, sankey, XY chart, block, packet, kanban, architecture, radar, and treemap now join flowcharts and sequence diagrams. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Art.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Art.kt index 37d422b85aa..4b2d9eb67f5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Art.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Art.kt @@ -21,25 +21,61 @@ internal data class Size(val w: Double, val h: Double) { internal enum class Role { Surface, Border, Text, Muted, Accent, Note, Cluster, Line } -internal enum class Head { None, Arrow, Open, Cross, Dot } +internal enum class Head { None, Arrow, Open, Cross, Dot, Triangle, Diamond, DiamondFilled, Crow, Bar, CircleOpen } internal enum class Anchor { TopLeft, Top, TopRight, Left, Center, Right, BottomLeft, Bottom, BottomRight } @Serializable internal sealed interface Mark { @Serializable - data class Box(val rect: Rect, val arc: Double, val fill: Role?, val line: Role?, val dash: Boolean = false) : Mark { - override fun toString() = "box $rect arc=${fmt(arc)} fill=${fill.name()} line=${line.name()} dash=$dash" + data class Box( + val rect: Rect, + val arc: Double, + val fill: Role?, + val line: Role?, + val dash: Boolean = false, + val tone: Int? = null, + val soft: Boolean = false, + ) : Mark { + override fun toString() = "box $rect arc=${fmt(arc)} fill=${fill.name()} line=${line.name()} dash=$dash${paint(tone, soft)}" } @Serializable - data class Oval(val rect: Rect, val fill: Role?, val line: Role?) : Mark { - override fun toString() = "oval $rect fill=${fill.name()} line=${line.name()}" + data class Oval( + val rect: Rect, + val fill: Role?, + val line: Role?, + val tone: Int? = null, + val soft: Boolean = false, + ) : Mark { + override fun toString() = "oval $rect fill=${fill.name()} line=${line.name()}${paint(tone, soft)}" } @Serializable - data class Poly(val points: List, val fill: Role?, val line: Role?) : Mark { - override fun toString() = "poly ${points.joinToString(" ")} fill=${fill.name()} line=${line.name()}" + data class Poly( + val points: List, + val fill: Role?, + val line: Role?, + val tone: Int? = null, + val soft: Boolean = false, + ) : Mark { + override fun toString() = "poly ${points.joinToString(" ")} fill=${fill.name()} line=${line.name()}${paint(tone, soft)}" + } + + /** A filled circle wedge. [start] and [sweep] are degrees in AWT arc space: 0° at 3 o'clock, counterclockwise positive. */ + @Serializable + data class Sector( + val at: Pt, + val r: Double, + val start: Double, + val sweep: Double, + val fill: Role?, + val line: Role?, + val tone: Int? = null, + val soft: Boolean = false, + ) : Mark { + override fun toString() = + "sector at=$at r=${fmt(r)} start=${fmt(start)} sweep=${fmt(sweep)} fill=${fill.name()} line=${line.name()}${paint(tone, soft)}" } @Serializable @@ -50,8 +86,11 @@ internal sealed interface Mark { val thick: Boolean = false, val head: Head = Head.None, val tail: Head = Head.None, + val tone: Int? = null, + val soft: Boolean = false, ) : Mark { - override fun toString() = "edge ${points.joinToString(" ")} role=$role dash=$dash thick=$thick head=$head tail=$tail" + override fun toString() = + "edge ${points.joinToString(" ")} role=$role dash=$dash thick=$thick head=$head tail=$tail${paint(tone, soft)}" } @Serializable @@ -81,6 +120,12 @@ internal data class Scene(@SerialName("diagram") val type: Type, val marks: List private fun Role?.name() = this?.name ?: "-" +/** Tone/soft only appear when set so existing snapshots stay byte-stable. */ +private fun paint(tone: Int?, soft: Boolean) = buildString { + if (tone != null) append(" tone=$tone") + if (soft) append(" soft") +} + internal fun fmt(value: Double): String = value.roundToInt().toString() private fun quote(value: String) = buildString { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Palette.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Palette.kt index 9bef0f4dac9..c53736d6f32 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Palette.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Palette.kt @@ -14,6 +14,8 @@ internal data class Palette( val line: Color, val font: Font, val bold: Font, + /** Categorical series colors for charts; falls back to [accent] when empty. */ + val tones: List = emptyList(), ) { fun color(role: Role): Color = when (role) { Role.Surface -> surface @@ -25,4 +27,9 @@ internal data class Palette( Role.Cluster -> cluster Role.Line -> line } + + fun tone(idx: Int): Color { + if (tones.isEmpty()) return accent + return tones[Math.floorMod(idx, tones.size)] + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ScenePainter.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ScenePainter.kt index 8f7e484c9a9..19490542f8e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ScenePainter.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ScenePainter.kt @@ -1,8 +1,11 @@ package ai.kilocode.client.ui.diagram import java.awt.BasicStroke +import java.awt.Color import java.awt.Graphics2D import java.awt.RenderingHints +import java.awt.Shape +import java.awt.geom.Arc2D import java.awt.geom.Ellipse2D import java.awt.geom.Line2D import java.awt.geom.Path2D @@ -20,6 +23,7 @@ internal object ScenePainter : Painter { private const val HEAD = 10.0 private const val DOT = 4.0 private const val CROSS = 5.0 + private const val SOFT = 96 override fun accepts(art: Art) = art is Scene @@ -41,17 +45,26 @@ internal object ScenePainter : Painter { is Mark.Box -> box(g, mark, palette) is Mark.Oval -> oval(g, mark, palette) is Mark.Poly -> poly(g, mark, palette) + is Mark.Sector -> sector(g, mark, palette) is Mark.Edge -> edge(g, mark, palette) is Mark.Text -> text(g, mark, palette) is Mark.Group -> mark.marks.forEach { draw(g, it, palette) } } } + /** A [tone] wins over the role fill; [soft] applies a fixed translucency for stacked chart fills. */ + private fun fill(palette: Palette, role: Role?, tone: Int?, soft: Boolean): Color? { + val base = if (tone != null) palette.tone(tone) else role?.let(palette::color) + if (base == null) return null + if (!soft) return base + return Color(base.red, base.green, base.blue, SOFT) + } + private fun box(g: Graphics2D, mark: Mark.Box, palette: Palette) { val rect = mark.rect val shape = RoundRectangle2D.Double(rect.x, rect.y, rect.w, rect.h, mark.arc, mark.arc) - mark.fill?.let { - g.color = palette.color(it) + fill(palette, mark.fill, mark.tone, mark.soft)?.let { + g.color = it g.fill(shape) } mark.line?.let { @@ -64,8 +77,8 @@ internal object ScenePainter : Painter { private fun oval(g: Graphics2D, mark: Mark.Oval, palette: Palette) { val rect = mark.rect val shape = Ellipse2D.Double(rect.x, rect.y, rect.w, rect.h) - mark.fill?.let { - g.color = palette.color(it) + fill(palette, mark.fill, mark.tone, mark.soft)?.let { + g.color = it g.fill(shape) } mark.line?.let { @@ -77,8 +90,29 @@ internal object ScenePainter : Painter { private fun poly(g: Graphics2D, mark: Mark.Poly, palette: Palette) { val shape = path(mark.points, true) - mark.fill?.let { + fill(palette, mark.fill, mark.tone, mark.soft)?.let { + g.color = it + g.fill(shape) + } + mark.line?.let { g.color = palette.color(it) + g.stroke = stroke() + g.draw(shape) + } + } + + private fun sector(g: Graphics2D, mark: Mark.Sector, palette: Palette) { + val shape = Arc2D.Double( + mark.at.x - mark.r, + mark.at.y - mark.r, + mark.r * 2, + mark.r * 2, + mark.start, + mark.sweep, + Arc2D.PIE, + ) + fill(palette, mark.fill, mark.tone, mark.soft)?.let { + g.color = it g.fill(shape) } mark.line?.let { @@ -90,11 +124,11 @@ internal object ScenePainter : Painter { private fun edge(g: Graphics2D, mark: Mark.Edge, palette: Palette) { if (mark.points.size < 2) return - g.color = palette.color(mark.role) + g.color = fill(palette, mark.role, mark.tone, mark.soft) ?: palette.color(mark.role) g.stroke = stroke(mark.dash, mark.thick) g.draw(path(mark.points, false)) - head(g, mark.points[mark.points.lastIndex - 1], mark.points.last(), mark.head) - head(g, mark.points[1], mark.points.first(), mark.tail) + head(g, palette, mark.points[mark.points.lastIndex - 1], mark.points.last(), mark.head) + head(g, palette, mark.points[1], mark.points.first(), mark.tail) } private fun text(g: Graphics2D, mark: Mark.Text, palette: Palette) { @@ -116,21 +150,33 @@ internal object ScenePainter : Painter { g.drawString(mark.text, x.toFloat(), y.toFloat()) } - private fun head(g: Graphics2D, from: Pt, to: Pt, head: Head) { + private fun head(g: Graphics2D, palette: Palette, from: Pt, to: Pt, head: Head) { if (head == Head.None) return val angle = atan2(to.y - from.y, to.x - from.x) when (head) { - Head.Arrow -> { - val p = arrow(to, angle) - g.fill(p) - } + Head.Arrow -> g.fill(arrow(to, angle)) Head.Open -> g.draw(arrow(to, angle)) Head.Cross -> cross(g, to, angle) Head.Dot -> g.fill(Ellipse2D.Double(to.x - DOT, to.y - DOT, DOT * 2, DOT * 2)) + Head.Triangle -> hollow(g, palette, triangle(to, angle)) + Head.Diamond -> hollow(g, palette, diamond(to, angle)) + Head.DiamondFilled -> g.fill(diamond(to, angle)) + Head.Crow -> crow(g, to, angle) + Head.Bar -> bar(g, to, angle) + Head.CircleOpen -> hollow(g, palette, Ellipse2D.Double(to.x - DOT, to.y - DOT, DOT * 2, DOT * 2)) Head.None -> Unit } } + /** UML-style hollow heads: surface fill so the line underneath does not show through, then outline. */ + private fun hollow(g: Graphics2D, palette: Palette, shape: Shape) { + val color = g.color + g.color = palette.surface + g.fill(shape) + g.color = color + g.draw(shape) + } + private fun arrow(to: Pt, angle: Double): Path2D { val left = point(to, angle + PI * 0.82, HEAD) val right = point(to, angle - PI * 0.82, HEAD) @@ -142,6 +188,48 @@ internal object ScenePainter : Painter { } } + private fun triangle(to: Pt, angle: Double): Path2D { + val left = point(to, angle + PI * 0.86, HEAD * 1.4) + val right = point(to, angle - PI * 0.86, HEAD * 1.4) + return Path2D.Double().apply { + moveTo(to.x, to.y) + lineTo(left.x, left.y) + lineTo(right.x, right.y) + closePath() + } + } + + private fun diamond(to: Pt, angle: Double): Path2D { + val mid = point(to, angle + PI, HEAD) + val back = point(to, angle + PI, HEAD * 2) + val left = point(mid, angle + PI / 2, HEAD / 2) + val right = point(mid, angle - PI / 2, HEAD / 2) + return Path2D.Double().apply { + moveTo(to.x, to.y) + lineTo(left.x, left.y) + lineTo(back.x, back.y) + lineTo(right.x, right.y) + closePath() + } + } + + /** Crow's foot: three prongs spreading back from the endpoint toward the line. */ + private fun crow(g: Graphics2D, to: Pt, angle: Double) { + val root = point(to, angle + PI, HEAD) + val left = point(to, angle + PI / 2, HEAD / 2) + val right = point(to, angle - PI / 2, HEAD / 2) + g.draw(Line2D.Double(root.x, root.y, left.x, left.y)) + g.draw(Line2D.Double(root.x, root.y, right.x, right.y)) + g.draw(Line2D.Double(root.x, root.y, to.x, to.y)) + } + + private fun bar(g: Graphics2D, to: Pt, angle: Double) { + val mid = point(to, angle + PI, HEAD / 2) + val left = point(mid, angle + PI / 2, HEAD / 2) + val right = point(mid, angle - PI / 2, HEAD / 2) + g.draw(Line2D.Double(left.x, left.y, right.x, right.y)) + } + private fun cross(g: Graphics2D, to: Pt, angle: Double) { val a = point(to, angle + PI / 4.0, CROSS) val b = point(to, angle + PI + PI / 4.0, CROSS) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Type.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Type.kt index 22652deb265..dc6bc7b1103 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Type.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/Type.kt @@ -13,6 +13,21 @@ internal enum class Type { Er, Gantt, Pie, + Journey, + Quadrant, + Requirement, + Git, + C4, + Mindmap, + Timeline, + Sankey, + XyChart, + Block, + Packet, + Kanban, + Architecture, + Radar, + Treemap, Unknown; companion object { @@ -24,7 +39,8 @@ internal enum class Type { fun of(clean: Clean): Type { val head = clean.lines.firstOrNull { it.text.isNotBlank() }?.text?.trim() ?: return Unknown - val token = head.takeWhile { !it.isWhitespace() }.lowercase() + // `gitGraph LR:` and `gitGraph:` keep a trailing colon on the keyword itself. + val token = head.takeWhile { !it.isWhitespace() }.trimEnd(':').lowercase() return when (token) { "graph", "flowchart" -> Flowchart "sequencediagram" -> Sequence @@ -33,6 +49,21 @@ internal enum class Type { "erdiagram" -> Er "gantt" -> Gantt "pie" -> Pie + "journey" -> Journey + "quadrantchart" -> Quadrant + "requirementdiagram" -> Requirement + "gitgraph" -> Git + "c4context", "c4container", "c4component", "c4dynamic", "c4deployment" -> C4 + "mindmap" -> Mindmap + "timeline" -> Timeline + "sankey-beta", "sankey" -> Sankey + "xychart-beta", "xychart" -> XyChart + "block-beta", "block" -> Block + "packet-beta", "packet" -> Packet + "kanban" -> Kanban + "architecture-beta", "architecture" -> Architecture + "radar-beta", "radar" -> Radar + "treemap-beta", "treemap" -> Treemap else -> Unknown } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Arch.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Arch.kt new file mode 100644 index 00000000000..ddaad9f11c1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Arch.kt @@ -0,0 +1,258 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Head +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * Architecture engine. Services land on an integer grid: the first service anchors at the origin and + * every edge's side pair (`db:L -- R:server`) pulls its unplaced peer next to a placed one. Icons are + * simple glyphs drawn from existing primitives. + */ +internal class Arch(private val measure: Measure, private val spec: Spec) { + private val services = linkedMapOf() + private val groups = linkedMapOf() + private val edges = mutableListOf() + + suspend fun draw(clean: Clean): Out { + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + val token = text.substringBefore(' ').lowercase() + if (token == "architecture-beta" || token == "architecture") continue + } + val err = stmt(text) + if (err != null) return Out.Err(Fault.Syntax, err, line.at) + if (services.size > spec.limits.nodes) return Out.Err(Fault.Limit, "architecture exceeds ${spec.limits.nodes} services") + if (edges.size > spec.limits.edges) return Out.Err(Fault.Limit, "architecture exceeds ${spec.limits.edges} edges") + } + if (services.isEmpty()) return Out.Err(Fault.Syntax, "architecture has no services", 1) + return Out.Ok(marks()) + } + + private fun stmt(text: String): String? { + val token = text.substringBefore(' ').lowercase() + val rest = text.substringAfter(' ', "").trim() + when (token) { + "group" -> { + val part = part(rest) ?: return "malformed group" + groups[part.id] = Group(part.id, part.label, member(rest)) + return null + } + "service" -> { + val part = part(rest) ?: return "malformed service" + services[part.id] = Service(part.id, part.label, part.icon, member(rest)) + return null + } + "junction" -> { + val id = rest.substringBefore(' ').trim() + if (id.isEmpty()) return "junction needs an id" + services[id] = Service(id, "", "", member(rest)) + return null + } + "accdescr", "acctitle" -> return null + else -> { + val match = EDGE.find(text) ?: return null + if (!services.containsKey(match.groupValues[1]) || !services.containsKey(match.groupValues[6])) { + return "edge references an unknown service" + } + edges.add( + Edge( + match.groupValues[1], + match.groupValues[6], + side(match.groupValues[2]), + side(match.groupValues[5]), + into = match.groupValues[4].isNotEmpty(), + back = match.groupValues[3].isNotEmpty(), + ), + ) + return null + } + } + } + + /** `id(icon)[Label]` — icon and label both optional. */ + private fun part(text: String): Part? { + val head = text.substringBefore(" in ").trim() + val icon = Regex("""^(\w+)\(([\w-]+)\)""").find(head) + val id = icon?.groupValues?.get(1) ?: Regex("""^(\w+)""").find(head)?.groupValues?.get(1) ?: return null + val label = Regex("""\[(.*)]""").find(head)?.groupValues?.get(1)?.let { Source.unquote(it) } ?: id + return Part(id, icon?.groupValues?.get(2).orEmpty(), label) + } + + private fun member(text: String): String { + val at = text.indexOf(" in ") + if (at < 0) return "" + return text.substring(at + 4).trim().substringBefore(' ') + } + + private fun side(text: String) = when (text.uppercase()) { + "L" -> Side.Left + "R" -> Side.Right + "T" -> Side.Top + else -> Side.Bottom + } + + private suspend fun marks(): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val spots = linkedMapOf>() + val taken = mutableSetOf>() + + fun place(id: String, spot: Pair) { + var at = spot + while (at in taken) at = at.first + 1 to at.second + spots[id] = at + taken.add(at) + } + + place(services.keys.first(), 0 to 0) + var pass = 0 + while (pass++ < services.size) { + var moved = false + for (edge in edges) { + val from = spots[edge.from] + val to = spots[edge.to] + if (from != null && to == null) { + place(edge.to, from.shift(edge.fromSide)) + moved = true + } + if (from == null && to != null) { + place(edge.from, to.shift(edge.toSide)) + moved = true + } + } + if (!moved) break + } + coroutineContext.ensureActive() + for (id in services.keys) { + if (spots.containsKey(id)) continue + place(id, (taken.maxOfOrNull { it.first } ?: 0) + 1 to 0) + } + + val cellW = services.values.maxOf { maxOf(sheet.width(it.label), high * 3) } + sheet.gap * 2 + val cellH = high * 4 + sheet.gap * 2 + val rects = linkedMapOf() + for ((id, spot) in spots) { + val service = services.getValue(id) + val junction = service.label.isEmpty() && service.icon.isEmpty() + val wide = if (junction) pad else maxOf(sheet.width(service.label) + pad * 2, high * 3) + val tall = if (junction) pad else high * 3.5 + rects[id] = Rect( + spot.first * cellW + (cellW - wide) / 2, + spot.second * cellH + (cellH - tall) / 2, + wide, + tall, + ) + } + for (edge in edges) { + val from = anchor(rects.getValue(edge.from), edge.fromSide) + val to = anchor(rects.getValue(edge.to), edge.toSide) + sheet.add( + Mark.Edge( + listOf(from, to), + Role.Line, + head = if (edge.into) Head.Arrow else Head.None, + tail = if (edge.back) Head.Arrow else Head.None, + ), + ) + } + for ((id, rect) in rects) { + val service = services.getValue(id) + if (service.label.isEmpty() && service.icon.isEmpty()) { + sheet.add(Mark.Oval(rect, Role.Border, null)) + continue + } + sheet.add(Mark.Box(rect, spec.metrics.arc, Role.Surface, Role.Border)) + icon(sheet, service.icon, Rect(rect.x + rect.w / 2 - high, rect.y + pad / 2, high * 2, high * 2)) + sheet.texts(listOf(service.label), rect.x + rect.w / 2, rect.y + rect.h - high - pad / 2, Role.Text) + } + for (group in groups.values) { + val members = rects.filterKeys { services.getValue(it).group == group.id }.values + if (members.isEmpty()) continue + val x = members.minOf { it.x } - sheet.gap + val y = members.minOf { it.y } - sheet.gap - high + val w = members.maxOf { it.x + it.w } + sheet.gap - x + val h = members.maxOf { it.y + it.h } + sheet.gap - y + sheet.add(Mark.Box(Rect(x, y, w, h), spec.metrics.arc, null, Role.Cluster, dash = true)) + sheet.add(Mark.Text(group.label, Pt(x + pad, y + pad + high / 2), Anchor.Left, Role.Muted, bold = true)) + } + return sheet.scene(Type.Architecture) + } + + /** A small icon glyph built from primitives; unknown icons fall back to a plain box. */ + private fun icon(sheet: Sheet, name: String, rect: Rect) { + when (name.lowercase()) { + "database", "db" -> { + sheet.add(Mark.Box(Rect(rect.x, rect.y + rect.h * 0.2, rect.w, rect.h * 0.6), 0.0, null, Role.Muted)) + sheet.add(Mark.Oval(Rect(rect.x, rect.y, rect.w, rect.h * 0.4), null, Role.Muted)) + sheet.add(Mark.Oval(Rect(rect.x, rect.y + rect.h * 0.6, rect.w, rect.h * 0.4), null, Role.Muted)) + } + "disk", "storage" -> { + sheet.add(Mark.Box(rect, 2.0, null, Role.Muted)) + sheet.add(Mark.Oval(Rect(rect.x + rect.w * 0.3, rect.y + rect.h * 0.3, rect.w * 0.4, rect.h * 0.4), null, Role.Muted)) + } + "cloud", "internet" -> { + sheet.add(Mark.Oval(Rect(rect.x, rect.y + rect.h * 0.3, rect.w * 0.6, rect.h * 0.6), null, Role.Muted)) + sheet.add(Mark.Oval(Rect(rect.x + rect.w * 0.4, rect.y + rect.h * 0.1, rect.w * 0.6, rect.h * 0.7), null, Role.Muted)) + } + "server" -> { + sheet.add(Mark.Box(Rect(rect.x, rect.y, rect.w, rect.h * 0.45), 2.0, null, Role.Muted)) + sheet.add(Mark.Box(Rect(rect.x, rect.y + rect.h * 0.55, rect.w, rect.h * 0.45), 2.0, null, Role.Muted)) + } + else -> if (name.isNotEmpty()) sheet.add(Mark.Box(rect, 2.0, null, Role.Muted)) + } + } + + private fun Pair.shift(side: Side) = when (side) { + Side.Left -> first - 1 to second + Side.Right -> first + 1 to second + Side.Top -> first to second - 1 + Side.Bottom -> first to second + 1 + } + + private fun anchor(rect: Rect, side: Side) = when (side) { + Side.Left -> Pt(rect.x, rect.y + rect.h / 2) + Side.Right -> Pt(rect.x + rect.w, rect.y + rect.h / 2) + Side.Top -> Pt(rect.x + rect.w / 2, rect.y) + Side.Bottom -> Pt(rect.x + rect.w / 2, rect.y + rect.h) + } + + private enum class Side { Left, Right, Top, Bottom } + + private data class Part(val id: String, val icon: String, val label: String) + + private data class Service(val id: String, val label: String, val icon: String, val group: String) + + private data class Group(val id: String, val label: String, val parent: String) + + private data class Edge( + val from: String, + val to: String, + val fromSide: Side, + val toSide: Side, + val into: Boolean, + val back: Boolean, + ) + + private companion object { + /** `db:L -- R:server`, optional `<`/`>` arrows on either side of the rails. */ + val EDGE = Regex("""^(\w+):([LRTB])\s*(<)?--(>)?\s*([LRTB]):(\w+)$""") + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Axis.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Axis.kt new file mode 100644 index 00000000000..7e1a428bcff --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Axis.kt @@ -0,0 +1,39 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow + +/** Nice-number axis ticks shared by the chart engines. */ +internal object Axis { + /** Tick positions covering `[min, max]` at a 1/2/5 step; always at least two ticks. */ + fun ticks(min: Double, max: Double, want: Int = 5): List { + if (max <= min) return listOf(min, min + 1) + val raw = (max - min) / want.coerceAtLeast(1) + val mag = 10.0.pow(floor(log10(raw))) + val norm = raw / mag + val step = mag * when { + norm <= 1.0 -> 1.0 + norm <= 2.0 -> 2.0 + norm <= 5.0 -> 5.0 + else -> 10.0 + } + val out = mutableListOf() + var tick = floor(min / step) * step + val last = ceil(max / step) * step + while (tick <= last + step / 2) { + out.add(tick) + tick += step + } + return out + } + + /** Formats a tick without a trailing `.0` and without float noise. */ + fun label(value: Double): String { + val whole = Math.round(value) + if (abs(value - whole) < 1e-9) return whole.toString() + return "%.2f".format(value).trimEnd('0').trimEnd('.') + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/BlockDg.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/BlockDg.kt new file mode 100644 index 00000000000..257e16a1dd5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/BlockDg.kt @@ -0,0 +1,139 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Head +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * Block diagram engine: cells flow row-major into `columns N` slots; `space` and `space:N` skip + * slots; `A --> B` lines connect placed blocks. + */ +internal class BlockDg(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + var columns = 0 + val cells = mutableListOf() + val links = mutableListOf() + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + val token = text.substringBefore(' ').lowercase() + if (token == "block-beta" || token == "block") continue + } + val token = text.substringBefore(' ').lowercase() + if (token == "columns") { + columns = Lex.num(text.substringAfter(' ', ""))?.toInt() + ?: return Out.Err(Fault.Syntax, "columns needs a number", line.at) + continue + } + if (token == "accdescr" || token == "acctitle" || token == "classdef" || token == "class" || token == "style") continue + val arrow = text.indexOf("-->") + if (arrow > 0) { + val from = text.substring(0, arrow).trim() + val to = text.substring(arrow + 3).trim() + if (from.isEmpty() || to.isEmpty()) return Out.Err(Fault.Syntax, "block link needs both ends", line.at) + links.add(Link(from, to)) + if (links.size > spec.limits.edges) return Out.Err(Fault.Limit, "block diagram exceeds ${spec.limits.edges} links") + continue + } + for (tok in Lex.tokens(text)) { + val lower = tok.text.lowercase() + if (lower == "space") { + cells.add(null) + continue + } + if (lower.startsWith("space:")) { + val skip = lower.substringAfter(':').toIntOrNull() ?: 1 + repeat(skip.coerceIn(1, BITSLOTS)) { cells.add(null) } + continue + } + cells.add(cell(tok.text)) + if (cells.size > spec.limits.nodes) return Out.Err(Fault.Limit, "block diagram exceeds ${spec.limits.nodes} blocks") + } + } + val named = cells.filterNotNull() + if (named.isEmpty()) return Out.Err(Fault.Syntax, "block diagram has no blocks", 1) + return Out.Ok(marks(if (columns > 0) columns else cells.size.coerceAtLeast(1), cells, links)) + } + + private fun cell(text: String): Cell { + for (wrap in WRAPS) { + val open = text.indexOf(wrap.open) + if (open <= 0 || !text.endsWith(wrap.close)) continue + if (text.length < open + wrap.open.length + wrap.close.length) continue + val id = text.substring(0, open) + val label = Source.unquote(text.substring(open + wrap.open.length, text.length - wrap.close.length)) + return Cell(id, label, wrap.kind) + } + return Cell(text, text, KIND_RECT) + } + + private fun marks(columns: Int, cells: List, links: List): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val named = cells.filterNotNull() + val wide = named.maxOf { sheet.width(it.label) } + pad * 4 + val tall = high + pad * 2 + val rects = linkedMapOf() + cells.forEachIndexed { idx, cell -> + if (cell == null) return@forEachIndexed + val col = idx % columns + val row = idx / columns + rects[cell.id] = Rect(col * (wide + sheet.gap), row * (tall + sheet.gap), wide, tall) + } + for (link in links) { + val from = rects[link.from] ?: continue + val to = rects[link.to] ?: continue + val ends = joint(from, to) + sheet.add(Mark.Edge(listOf(ends.first, ends.second), Role.Line, head = Head.Arrow)) + } + for (cell in named) { + val rect = rects.getValue(cell.id) + when (cell.kind) { + KIND_CIRCLE -> sheet.add(Mark.Oval(rect, Role.Surface, Role.Border)) + KIND_CYL -> sheet.add(Mark.Box(rect, spec.metrics.arc * 3, Role.Surface, Role.Border)) + KIND_ROUND -> sheet.add(Mark.Box(rect, spec.metrics.arc * 2, Role.Surface, Role.Border)) + KIND_STADIUM -> sheet.add(Mark.Box(rect, rect.h / 2, Role.Surface, Role.Border)) + else -> sheet.add(Mark.Box(rect, 0.0, Role.Surface, Role.Border)) + } + sheet.label(listOf(cell.label), rect, Role.Text) + } + return sheet.scene(Type.Block) + } + + private data class Cell(val id: String, val label: String, val kind: Int) + + private data class Link(val from: String, val to: String) + + private data class Wrap(val open: String, val close: String, val kind: Int) + + private companion object { + const val KIND_RECT = 0 + const val KIND_ROUND = 1 + const val KIND_STADIUM = 2 + const val KIND_CYL = 3 + const val KIND_CIRCLE = 4 + const val BITSLOTS = 64 + + val WRAPS = listOf( + Wrap("[(", ")]", KIND_CYL), + Wrap("([", "])", KIND_STADIUM), + Wrap("((", "))", KIND_CIRCLE), + Wrap("[", "]", KIND_RECT), + Wrap("(", ")", KIND_ROUND), + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/C4Dg.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/C4Dg.kt new file mode 100644 index 00000000000..5735d1ecd56 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/C4Dg.kt @@ -0,0 +1,212 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Head +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Size +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * C4 engine covering all five headers with one boxes-and-relations renderer. Styling and layout + * statements (`UpdateElementStyle`, `LAYOUT_*`, ...) are parsed and discarded. + */ +internal class C4Dg(private val measure: Measure, private val spec: Spec) { + private val scopes = Scopes() + private val cells = linkedMapOf() + private val bounds = linkedMapOf() + private val members = linkedMapOf>() + private val rels = linkedMapOf>() + private val stack = ArrayDeque() + private var title = "" + + suspend fun draw(clean: Clean): Out { + members[Scopes.ROOT] = mutableListOf() + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.substringBefore(' ').trimEnd(':').lowercase().startsWith("c4")) continue + } + val err = stmt(text) + if (err != null) return Out.Err(Fault.Syntax, err, line.at) + if (cells.size > spec.limits.nodes) return Out.Err(Fault.Limit, "C4 diagram exceeds ${spec.limits.nodes} elements") + if (rels.values.sumOf { it.size } > spec.limits.edges) { + return Out.Err(Fault.Limit, "C4 diagram exceeds ${spec.limits.edges} relations") + } + } + if (stack.isNotEmpty()) return Out.Err(Fault.Syntax, "boundary is missing a closing brace", clean.lines.lastOrNull()?.at ?: 1) + if (cells.isEmpty()) return Out.Err(Fault.Syntax, "C4 diagram has no elements", 1) + val sheet = Sheet(measure, spec) + var top = 0.0 + if (title.isNotEmpty()) top = sheet.texts(listOf(title), 0.0, 0.0, Role.Text, bold = true) + sheet.pad + val part = scope(Scopes.ROOT, sheet) + for (mark in part.marks) sheet.add(moved(mark, 0.0, top)) + return Out.Ok(sheet.scene(Type.C4)) + } + + private fun stmt(text: String): String? { + if (text == "}") { + if (stack.isEmpty()) return "unexpected closing brace" + stack.removeLast() + return null + } + val word = text.substringBefore('(').substringBefore(' ').trim() + val lower = word.lowercase() + if (lower == "title") { + title = text.substringAfter(' ', "").trim() + return null + } + val body = Lex.call(text.removeSuffix("{").trim()) + if (lower.endsWith("_boundary") || lower == "boundary") { + if (body == null) return "malformed boundary statement" + val args = Lex.args(body).map { Source.unquote(it) } + val id = args.firstOrNull().orEmpty() + if (id.isEmpty()) return "boundary needs an id" + val here = stack.lastOrNull() ?: Scopes.ROOT + bounds[id] = args.getOrNull(1) ?: id + members.getValue(here).add(id) + members.getOrPut(id) { mutableListOf() } + scopes.open(id, here) + stack.addLast(id) + return null + } + if (lower.startsWith("rel") || lower.startsWith("birel")) { + if (body == null) return "malformed relation statement" + val args = Lex.args(body).map { Source.unquote(it) } + if (args.size < 2) return "relation needs two elements" + val back = lower == "rel_back" + val from = if (back) args[1] else args[0] + val to = if (back) args[0] else args[1] + if (!scopes.has(from) || !scopes.has(to)) return null + val hop = scopes.resolve(from, to) + rels.getOrPut(hop.scope) { mutableListOf() } + .add(CRel(hop.from, hop.to, args.getOrNull(2).orEmpty(), args.getOrNull(3).orEmpty(), both = lower.startsWith("birel"))) + return null + } + val kind = KINDS[lower] ?: return null + if (body == null) return "malformed $word statement" + val args = Lex.args(body).map { Source.unquote(it) } + val id = args.firstOrNull().orEmpty() + if (id.isEmpty()) return "$word needs an alias" + val here = stack.lastOrNull() ?: Scopes.ROOT + val label = args.getOrNull(1) ?: id + val tech = if (kind.tech) args.getOrNull(2).orEmpty() else "" + val descr = (if (kind.tech) args.getOrNull(3) else args.getOrNull(2)).orEmpty() + cells[id] = Cell(id, label, tech, descr, kind) + scopes.claim(id, here) + members.getValue(here).add(id) + return null + } + + private suspend fun scope(id: String, sheet: Sheet): Part { + coroutineContext.ensureActive() + val pad = sheet.pad + val high = sheet.high + val parts = linkedMapOf() + val sizes = linkedMapOf() + val texts = linkedMapOf>() + for (node in members.getValue(id)) { + val cell = cells[node] + if (cell == null) { + val inner = scope(node, sheet) + val label = bounds.getValue(node) + val wide = maxOf(inner.size.w + pad * 2, sheet.width(label, bold = true) + pad * 2) + parts[node] = inner + sizes[node] = Size(wide, inner.size.h + high + pad * 3) + continue + } + val room = maxOf(sheet.width(cell.label, bold = true), high * 12) + val lines = mutableListOf(Line(cell.label, Role.Text, true)) + if (cell.tech.isNotEmpty()) lines.add(Line("[${cell.tech}]", Role.Muted, false)) + for (row in sheet.wrap(cell.descr, room)) lines.add(Line(row, Role.Muted, false)) + texts[node] = lines.mapIndexed { idx, line -> + Mark.Text(line.text, Pt(0.0, high * (idx + 0.5)), Anchor.Center, line.role, line.bold) + } + val wide = lines.maxOf { sheet.width(it.text, it.bold) } + pad * 2 + val head = if (cell.kind.person) high else 0.0 + sizes[node] = Size(wide, head + high * lines.size + pad * 2) + } + val plan = Layered(spec).run(sizes, rels[id].orEmpty().map { Rail(it.from, it.to) }) + val marks = mutableListOf() + for (rel in rels[id].orEmpty()) { + val ends = joint(plan.rects.getValue(rel.from), plan.rects.getValue(rel.to)) + marks.add(Mark.Edge(listOf(ends.first, ends.second), Role.Line, dash = true, head = Head.Arrow, tail = if (rel.both) Head.Arrow else Head.None)) + val mid = Pt((ends.first.x + ends.second.x) / 2, (ends.first.y + ends.second.y) / 2) + val lines = listOfNotNull(rel.label.ifEmpty { null }, rel.tech.ifEmpty { null }?.let { "[$it]" }) + lines.forEachIndexed { idx, text -> + marks.add(Mark.Text(text, Pt(mid.x, mid.y - high * (lines.size - idx - 0.5) - 2), Anchor.Center, Role.Muted)) + } + } + for (node in members.getValue(id)) { + val rect = plan.rects.getValue(node) + val cell = cells[node] + if (cell == null) { + marks.add(Mark.Box(rect, spec.metrics.arc, null, Role.Cluster, dash = true)) + marks.add(Mark.Text(bounds.getValue(node), Pt(rect.x + pad, rect.y + pad + high * 0.5), Anchor.Left, Role.Muted, true)) + val inner = parts.getValue(node) + val dx = rect.x + (rect.w - inner.size.w) / 2 + for (mark in inner.marks) marks.add(moved(mark, dx, rect.y + high + pad * 2)) + continue + } + val head = if (cell.kind.person) high else 0.0 + val body = Rect(rect.x, rect.y + head, rect.w, rect.h - head) + if (cell.kind.person) { + // The head touches the body edge exactly; overlapping surfaces would break layout invariants. + marks.add(Mark.Oval(Rect(rect.x + rect.w / 2 - high / 2, rect.y, high, high), Role.Surface, Role.Border)) + } + marks.add(Mark.Box(body, spec.metrics.arc * (if (cell.kind.round) 3 else 1), Role.Surface, Role.Border, dash = cell.kind.ext)) + for (text in texts.getValue(node)) { + marks.add(text.copy(at = Pt(body.x + body.w / 2, body.y + pad + text.at.y))) + } + } + return Part(marks, plan.size) + } + + private data class Part(val marks: List, val size: Size) + + private data class Cell(val id: String, val label: String, val tech: String, val descr: String, val kind: Kind) + + private data class CRel(val from: String, val to: String, val label: String, val tech: String, val both: Boolean) + + private data class Line(val text: String, val role: Role, val bold: Boolean) + + /** [tech] marks kinds whose third argument is a technology rather than a description. */ + private data class Kind(val person: Boolean = false, val ext: Boolean = false, val tech: Boolean = false, val round: Boolean = false) + + private companion object { + val KINDS = buildMap { + put("person", Kind(person = true)) + put("person_ext", Kind(person = true, ext = true)) + put("system", Kind()) + put("system_ext", Kind(ext = true)) + put("systemdb", Kind(round = true)) + put("systemdb_ext", Kind(round = true, ext = true)) + put("systemqueue", Kind(round = true)) + put("systemqueue_ext", Kind(round = true, ext = true)) + for (base in listOf("container", "component")) { + put(base, Kind(tech = true)) + put("${base}_ext", Kind(tech = true, ext = true)) + put("${base}db", Kind(tech = true, round = true)) + put("${base}db_ext", Kind(tech = true, round = true, ext = true)) + put("${base}queue", Kind(tech = true, round = true)) + put("${base}queue_ext", Kind(tech = true, round = true, ext = true)) + } + put("node", Kind(tech = true)) + put("node_l", Kind(tech = true)) + put("node_r", Kind(tech = true)) + put("deployment_node", Kind(tech = true)) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ClassDg.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ClassDg.kt new file mode 100644 index 00000000000..e8815c6a770 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ClassDg.kt @@ -0,0 +1,246 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Head +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Size +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * Class diagram engine: `class X { members }` compartment boxes plus UML relations. Unknown + * statements are skipped, matching [Flow] and [Seq]. + */ +internal class ClassDg(private val measure: Measure, private val spec: Spec) { + private val classes = linkedMapOf() + private val rels = mutableListOf() + private var block: String? = null + + suspend fun draw(clean: Clean): Out { + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + val token = text.substringBefore(' ').lowercase() + if (token == "classdiagram" || token == "classdiagram-v2") continue + } + val err = stmt(text) + if (err != null) return Out.Err(Fault.Syntax, err, line.at) + if (classes.size > spec.limits.nodes) return Out.Err(Fault.Limit, "class diagram exceeds ${spec.limits.nodes} classes") + if (rels.size > spec.limits.edges) return Out.Err(Fault.Limit, "class diagram exceeds ${spec.limits.edges} relations") + } + if (block != null) return Out.Err(Fault.Syntax, "class block is missing a closing brace", clean.lines.lastOrNull()?.at ?: 1) + if (classes.isEmpty()) return Out.Err(Fault.Syntax, "class diagram has no classes", 1) + return Out.Ok(marks()) + } + + private fun stmt(text: String): String? { + val open = block + if (open != null) { + if (text == "}") { + block = null + return null + } + member(open, text.removeSuffix("}").trim()) + if (text.endsWith("}")) block = null + return null + } + val token = text.substringBefore(' ').lowercase() + if (token in SKIP) return null + if (token == "class") return define(text) + relation(text)?.let { + rels.add(it) + return null + } + // `Name : +member` assigns one member outside a block. + val colon = text.indexOf(':') + if (colon > 0 && !text.startsWith("<<")) { + val id = text.substring(0, colon).trim() + if (id.isNotEmpty() && id.none { it.isWhitespace() }) { + member(claim(id).id, text.substring(colon + 1).trim()) + } + } + return null + } + + private fun define(text: String): String? { + val rest = text.substringAfter(' ', "").trim() + if (rest.isEmpty()) return "class needs a name" + val body = rest.removeSuffix("{").trim() + val tag = Lex.tagged(body) ?: return "malformed class name $body" + val cls = claim(tag.first) + if (tag.second != tag.first) classes[cls.id] = cls.copy(label = tag.second) + if (rest.endsWith("{")) block = tag.first + return null + } + + private fun member(id: String, text: String) { + if (text.isEmpty()) return + val cls = claim(id) + if (text.startsWith("<<") && text.endsWith(">>")) { + classes[id] = cls.copy(note = text.removeSurrounding("<<", ">>").trim()) + return + } + if (text.contains('(')) { + cls.ops.add(text) + return + } + cls.attrs.add(text) + } + + private fun claim(id: String): Cls = classes.getOrPut(id) { Cls(id, id, null, mutableListOf(), mutableListOf()) } + + private fun relation(text: String): CRel? { + val tokens = Lex.tokens(text) + val op = tokens.withIndex().firstOrNull { REL.matches(it.value.text) } ?: return null + val idx = op.index + if (idx == 0 || idx == tokens.lastIndex) return null + val match = REL.find(op.value.text) ?: return null + val fromCard = if (idx >= 2 && tokens[idx - 1].text.startsWith("\"")) Source.unquote(tokens[idx - 1].text) else "" + val from = tokens[0].text + val next = tokens[idx + 1].text + val toCard = if (next.startsWith("\"")) Source.unquote(next) else "" + val toTok = if (toCard.isEmpty()) tokens[idx + 1] else tokens.getOrNull(idx + 2) ?: return null + val to = toTok.text.substringBefore(':') + if (to.isEmpty()) return null + val colon = text.indexOf(':', toTok.at) + val label = if (colon < 0) emptyList() else Source.label(text.substring(colon + 1)) + claim(from) + claim(to) + return CRel( + from, + to, + tail = mark(match.groupValues[1]), + head = mark(match.groupValues[3]), + dashed = match.groupValues[2].startsWith("."), + fromCard = fromCard, + toCard = toCard, + label = label, + ) + } + + private suspend fun marks(): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val sizes = linkedMapOf() + for (cls in classes.values) { + coroutineContext.ensureActive() + val wide = maxOf( + sheet.width(cls.label, bold = true), + cls.note?.let { sheet.width("«$it»") } ?: 0.0, + sheet.widest(cls.attrs), + sheet.widest(cls.ops), + ) + pad * 2 + val title = high * (if (cls.note == null) 1 else 2) + pad * 2 + val attrs = if (cls.attrs.isEmpty() && cls.ops.isEmpty()) 0.0 else high * cls.attrs.size + pad + val ops = if (cls.attrs.isEmpty() && cls.ops.isEmpty()) 0.0 else high * cls.ops.size + pad + sizes[cls.id] = Size(wide, title + attrs + ops) + } + val rails = rels.map { rail(it) } + val plan = Layered(spec).run(sizes, rails) + + for (rel in rels) { + coroutineContext.ensureActive() + val ends = joint(plan.rects.getValue(rel.from), plan.rects.getValue(rel.to)) + sheet.add(Mark.Edge(listOf(ends.first, ends.second), Role.Line, dash = rel.dashed, head = rel.head, tail = rel.tail)) + cards(sheet, rel, ends.first, ends.second) + if (rel.label.isNotEmpty()) { + val mid = Pt((ends.first.x + ends.second.x) / 2, (ends.first.y + ends.second.y) / 2) + sheet.texts(rel.label, mid.x, mid.y - high * rel.label.size - 2, Role.Muted) + } + } + for (cls in classes.values) { + coroutineContext.ensureActive() + box(sheet, cls, plan.rects.getValue(cls.id)) + } + return sheet.scene(Type.Class) + } + + /** For ranking, the parent side of a triangle or diamond goes on top. */ + private fun rail(rel: CRel): Rail { + if (rel.head == Head.Triangle || rel.head == Head.Diamond || rel.head == Head.DiamondFilled) { + return Rail(rel.to, rel.from) + } + return Rail(rel.from, rel.to) + } + + private fun cards(sheet: Sheet, rel: CRel, from: Pt, to: Pt) { + if (rel.fromCard.isNotEmpty()) card(sheet, rel.fromCard, from, to) + if (rel.toCard.isNotEmpty()) card(sheet, rel.toCard, to, from) + } + + private fun card(sheet: Sheet, text: String, near: Pt, far: Pt) { + val at = Pt(near.x + (far.x - near.x) * 0.18, near.y + (far.y - near.y) * 0.18) + sheet.add(Mark.Text(text, Pt(at.x + sheet.pad, at.y), Anchor.Left, Role.Muted)) + } + + private fun box(sheet: Sheet, cls: Cls, rect: Rect) { + val high = sheet.high + val pad = sheet.pad + sheet.add(Mark.Box(rect, spec.metrics.arc, Role.Surface, Role.Border)) + var top = rect.y + pad + cls.note?.let { top += sheet.texts(listOf("«$it»"), rect.x + rect.w / 2, top, Role.Muted) } + top += sheet.texts(listOf(cls.label), rect.x + rect.w / 2, top, Role.Text, bold = true) + top += pad + if (cls.attrs.isEmpty() && cls.ops.isEmpty()) return + sheet.add(Mark.Edge(listOf(Pt(rect.x, top), Pt(rect.x + rect.w, top)), Role.Border)) + top += pad / 2 + for (attr in cls.attrs) { + sheet.add(Mark.Text(attr, Pt(rect.x + pad, top + high * 0.5), Anchor.Left, Role.Text)) + top += high + } + top += pad / 2 + sheet.add(Mark.Edge(listOf(Pt(rect.x, top), Pt(rect.x + rect.w, top)), Role.Border)) + top += pad / 2 + for (op in cls.ops) { + sheet.add(Mark.Text(op, Pt(rect.x + pad, top + high * 0.5), Anchor.Left, Role.Text)) + top += high + } + } + + private data class Cls( + val id: String, + val label: String, + val note: String?, + val attrs: MutableList, + val ops: MutableList, + ) + + private data class CRel( + val from: String, + val to: String, + val tail: Head, + val head: Head, + val dashed: Boolean, + val fromCard: String, + val toCard: String, + val label: List, + ) + + private companion object { + val SKIP = setOf("direction", "note", "style", "classdef", "cssclass", "click", "callback", "link", "namespace", "accdescr", "acctitle") + + val REL = Regex("""^(<\||o|\*|<)?(-{2,}|\.{2,})(\|>|o|\*|>)?$""") + + fun mark(glyph: String) = when (glyph) { + "<|", "|>" -> Head.Triangle + "o" -> Head.Diamond + "*" -> Head.DiamondFilled + "<", ">" -> Head.Arrow + else -> Head.None + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ErDg.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ErDg.kt new file mode 100644 index 00000000000..d59b77b1065 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ErDg.kt @@ -0,0 +1,165 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Head +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Size +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * Entity relationship engine: crow's-foot relations plus entity attribute tables. Cardinality glyph + * pairs collapse to one head each: many beats optional beats exactly-one. + */ +internal class ErDg(private val measure: Measure, private val spec: Spec) { + private val entities = linkedMapOf>() + private val rels = mutableListOf() + private var block: String? = null + + suspend fun draw(clean: Clean): Out { + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.substringBefore(' ').lowercase() == "erdiagram") continue + } + val err = stmt(text) + if (err != null) return Out.Err(Fault.Syntax, err, line.at) + if (entities.size > spec.limits.nodes) return Out.Err(Fault.Limit, "er diagram exceeds ${spec.limits.nodes} entities") + if (rels.size > spec.limits.edges) return Out.Err(Fault.Limit, "er diagram exceeds ${spec.limits.edges} relations") + } + if (block != null) return Out.Err(Fault.Syntax, "entity block is missing a closing brace", clean.lines.lastOrNull()?.at ?: 1) + if (entities.isEmpty()) return Out.Err(Fault.Syntax, "er diagram has no entities", 1) + return marks() + } + + private fun stmt(text: String): String? { + val open = block + if (open != null) { + if (text == "}") { + block = null + return null + } + attr(open, text.removeSuffix("}").trim()) + if (text.endsWith("}")) block = null + return null + } + val match = REL.find(text) + if (match != null) { + val from = Source.unquote(match.groupValues[1]) + val to = Source.unquote(match.groupValues[5]) + entities.getOrPut(from) { mutableListOf() } + entities.getOrPut(to) { mutableListOf() } + rels.add( + ERel( + from, + to, + tail = head(match.groupValues[2]), + head = head(match.groupValues[4]), + dashed = match.groupValues[3].startsWith("."), + label = Source.unquote(match.groupValues[6].trim()), + ), + ) + return null + } + if (text.endsWith("{")) { + val id = Source.unquote(text.removeSuffix("{").trim()) + if (id.isEmpty()) return "entity needs a name" + entities.getOrPut(id) { mutableListOf() } + block = id + return null + } + return null + } + + /** `type name PK,FK "comment"` rows; keys and the comment are optional. */ + private fun attr(entity: String, text: String) { + if (text.isEmpty()) return + val tokens = Lex.tokens(text) + if (tokens.size < 2) return + val keys = tokens.drop(2).map { it.text }.filter { !it.startsWith("\"") }.joinToString(" ") + entities.getValue(entity).add(Attr(tokens[0].text, tokens[1].text, keys)) + } + + private suspend fun marks(): Out.Ok { + val sheet = Sheet(measure, spec) + val pad = sheet.pad + val high = sheet.high + val sizes = linkedMapOf() + for (entry in entities) { + coroutineContext.ensureActive() + val rows = entry.value + val typeW = rows.maxOfOrNull { sheet.width(it.type) } ?: 0.0 + val nameW = rows.maxOfOrNull { sheet.width(name(it)) } ?: 0.0 + val wide = maxOf(sheet.width(entry.key, bold = true) + pad * 2, typeW + nameW + pad * 3) + sizes[entry.key] = Size(wide, high + pad * 2 + rows.size * (high + pad)) + } + val plan = Layered(spec).run(sizes, rels.map { Rail(it.from, it.to) }) + for (rel in rels) { + coroutineContext.ensureActive() + val ends = joint(plan.rects.getValue(rel.from), plan.rects.getValue(rel.to)) + sheet.add(Mark.Edge(listOf(ends.first, ends.second), Role.Line, dash = rel.dashed, head = rel.head, tail = rel.tail)) + if (rel.label.isNotEmpty()) { + val mid = Pt((ends.first.x + ends.second.x) / 2, (ends.first.y + ends.second.y) / 2) + sheet.texts(listOf(rel.label), mid.x, mid.y - high, Role.Muted) + } + } + for (entry in entities) { + coroutineContext.ensureActive() + table(sheet, entry.key, entry.value, plan.rects.getValue(entry.key)) + } + return Out.Ok(sheet.scene(Type.Er)) + } + + private fun table(sheet: Sheet, id: String, rows: List, rect: Rect) { + val pad = sheet.pad + val high = sheet.high + sheet.add(Mark.Box(rect, 0.0, Role.Surface, Role.Border)) + sheet.texts(listOf(id), rect.x + rect.w / 2, rect.y + pad, Role.Text, bold = true) + var top = rect.y + high + pad * 2 + val typeW = rows.maxOfOrNull { sheet.width(it.type) } ?: 0.0 + for (row in rows) { + sheet.add(Mark.Edge(listOf(Pt(rect.x, top), Pt(rect.x + rect.w, top)), Role.Border)) + val mid = top + (high + pad) / 2 + sheet.add(Mark.Text(row.type, Pt(rect.x + pad, mid), Anchor.Left, Role.Muted)) + sheet.add(Mark.Text(name(row), Pt(rect.x + pad * 2 + typeW, mid), Anchor.Left, Role.Text)) + top += high + pad + } + } + + private fun name(row: Attr) = if (row.keys.isEmpty()) row.name else "${row.name} ${row.keys}" + + private data class Attr(val type: String, val name: String, val keys: String) + + private data class ERel( + val from: String, + val to: String, + val tail: Head, + val head: Head, + val dashed: Boolean, + val label: String, + ) + + private companion object { + /** `A ||--o{ B : label` — the crow's-foot glyphs contain braces, so tokenizers cannot split this. */ + val REL = Regex("""^("[^"]*"|\S+)\s+([|o{}]{1,2})(-{2}|\.{2})([|o{}]{1,2})\s+("[^"]*"|\S+)\s*(?::\s*(.+))?$""") + + /** `}o` and friends collapse to the strongest glyph on that side. */ + fun head(glyph: String) = when { + glyph.contains('{') || glyph.contains('}') -> Head.Crow + glyph.contains('o') -> Head.CircleOpen + else -> Head.Bar + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Gantt.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Gantt.kt new file mode 100644 index 00000000000..8d858b66b02 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Gantt.kt @@ -0,0 +1,171 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * Gantt engine: sections as lanes, ISO dates, `after id` chaining, `Nd`/`Nw`/`Nh` durations, and + * milestone diamonds. Date math is pinned to ISO/[Locale.ROOT] so layout is deterministic. + */ +internal class Gantt(private val measure: Measure, private val spec: Spec) { + private val tasks = mutableListOf() + private val ends = linkedMapOf() + private var title = "" + private var section = "" + + suspend fun draw(clean: Clean): Out { + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.lowercase() == "gantt") continue + } + val token = text.substringBefore(' ').lowercase() + if (token in SKIP) continue + if (token == "title") { + title = text.substringAfter(' ', "").trim() + continue + } + if (token == "section") { + section = text.substringAfter(' ', "").trim() + continue + } + val colon = text.indexOf(':') + if (colon <= 0) continue + val err = task(text.substring(0, colon).trim(), text.substring(colon + 1).trim()) + if (err != null) return Out.Err(Fault.Syntax, err, line.at) + if (tasks.size > spec.limits.nodes) return Out.Err(Fault.Limit, "gantt exceeds ${spec.limits.nodes} tasks") + } + if (tasks.isEmpty()) return Out.Err(Fault.Syntax, "gantt has no tasks", 1) + return Out.Ok(marks()) + } + + private fun task(label: String, body: String): String? { + val cells = ArrayDeque(body.split(',').map { it.trim() }.filter { it.isNotEmpty() }) + if (cells.isEmpty()) return "task needs a start" + var milestone = false + while (cells.isNotEmpty() && cells.first().lowercase() in TAGS) { + if (cells.removeFirst().lowercase() == "milestone") milestone = true + } + // An id cell is anything left in front of the cell that resolves to a start. + val id = if (cells.size >= 2 && date(cells.first()) == null && !cells.first().lowercase().startsWith("after")) { + cells.removeFirst() + } else { + "" + } + if (cells.isEmpty()) return "task needs a start" + val begin = start(cells.removeFirst()) ?: return "task needs a date or `after id`" + val stop = when { + cells.isEmpty() -> begin.plusDays(1) + else -> finish(begin, cells.removeFirst()) ?: return "task needs a duration or end date" + } + if (id.isNotEmpty()) ends[id] = stop + tasks.add(Task(label, section, begin, stop, milestone)) + return null + } + + private fun start(cell: String): LocalDate? { + if (cell.lowercase().startsWith("after")) { + val ids = cell.substringAfter(' ', "").trim().split(' ').filter { it.isNotEmpty() } + val dates = ids.mapNotNull { ends[it] } + return dates.maxOrNull() ?: tasks.lastOrNull()?.stop + } + return date(cell) + } + + private fun finish(begin: LocalDate, cell: String): LocalDate? { + date(cell)?.let { return it } + val match = SPAN.find(cell.lowercase()) ?: return null + val count = match.groupValues[1].toLong() + return when (match.groupValues[2]) { + "w" -> begin.plusWeeks(count) + "h" -> begin.plusDays((count + 23) / 24) + else -> begin.plusDays(count) + } + } + + private fun date(cell: String): LocalDate? { + return runCatching { LocalDate.parse(cell, ISO) }.getOrNull() + } + + private fun marks(): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val open = tasks.minOf { it.begin } + val shut = tasks.maxOf { it.stop } + val days = maxOf(1L, java.time.temporal.ChronoUnit.DAYS.between(open, shut)) + val sections = tasks.map { it.section }.distinct() + val left = (sections.maxOfOrNull { sheet.width(it, bold = true) } ?: 0.0) + pad * 2 + val plot = Rect(left, 0.0, (days * 6.0).coerceIn(high * 28, high * 60), tasks.size * (high + pad * 2)) + val day = plot.w / days + fun x(date: LocalDate) = plot.x + java.time.temporal.ChronoUnit.DAYS.between(open, date) * day + + if (title.isNotEmpty()) { + sheet.texts(listOf(title), plot.x + plot.w / 2, -high * 2 - pad, Role.Text, bold = true) + } + var tick = open + while (!tick.isAfter(shut)) { + val at = x(tick) + sheet.add(Mark.Edge(listOf(Pt(at, plot.y), Pt(at, plot.y + plot.h)), Role.Cluster, dash = true)) + sheet.add(Mark.Text(tick.format(DAY), Pt(at, plot.y + plot.h + pad), Anchor.Top, Role.Muted)) + tick = tick.plusDays(maxOf(1L, days / 6)) + } + tasks.forEachIndexed { idx, task -> + val top = plot.y + idx * (high + pad * 2) + pad + val tone = sections.indexOf(task.section) + if (task.milestone) { + val cx = x(task.begin) + val cy = top + high / 2 + val r = high * 0.6 + sheet.add(Mark.Poly(listOf(Pt(cx, cy - r), Pt(cx + r, cy), Pt(cx, cy + r), Pt(cx - r, cy)), null, Role.Border, tone = tone)) + sheet.add(Mark.Text(task.label, Pt(cx + r + pad, cy), Anchor.Left, Role.Text)) + } else { + val rect = Rect(x(task.begin), top, maxOf(day / 2, x(task.stop) - x(task.begin)), high) + sheet.add(Mark.Box(rect, spec.metrics.arc, null, Role.Border, tone = tone)) + if (sheet.width(task.label) <= rect.w - pad) { + sheet.label(listOf(task.label), rect, Role.Text) + } else { + sheet.add(Mark.Text(task.label, Pt(rect.x + rect.w + pad, rect.y + rect.h / 2), Anchor.Left, Role.Text)) + } + } + } + var lane = 0 + for (name in sections) { + val rows = tasks.count { it.section == name } + val top = plot.y + lane * (high + pad * 2) + if (name.isNotEmpty()) { + sheet.add(Mark.Text(name, Pt(0.0, top + pad + high / 2), Anchor.Left, Role.Muted, bold = true)) + } + lane += rows + } + return sheet.scene(Type.Gantt) + } + + private data class Task(val label: String, val section: String, val begin: LocalDate, val stop: LocalDate, val milestone: Boolean) + + private companion object { + val SKIP = setOf("dateformat", "axisformat", "excludes", "includes", "todaymarker", "tickinterval", "weekday", "accdescr", "acctitle", "inclusiveenddates") + val TAGS = setOf("active", "done", "crit", "milestone") + val SPAN = Regex("""^(\d+)([dwh])$""") + val ISO: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.ROOT) + val DAY: DateTimeFormatter = DateTimeFormatter.ofPattern("MM-dd", Locale.ROOT) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/GitDg.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/GitDg.kt new file mode 100644 index 00000000000..14d49835d3c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/GitDg.kt @@ -0,0 +1,131 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** Git graph engine: branch lanes as rows, commits as tone dots in sequence order, merge links. */ +internal class GitDg(private val measure: Measure, private val spec: Spec) { + private val lanes = linkedMapOf() + private val commits = mutableListOf() + private val heads = linkedMapOf() + private var branch = MAIN + + suspend fun draw(clean: Clean): Out { + lanes[MAIN] = 0 + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.substringBefore(' ').trimEnd(':').lowercase() == "gitgraph") continue + } + val err = stmt(text) + if (err != null) return Out.Err(Fault.Syntax, err, line.at) + if (commits.size > spec.limits.nodes) return Out.Err(Fault.Limit, "git graph exceeds ${spec.limits.nodes} commits") + if (lanes.size > spec.limits.nodes) return Out.Err(Fault.Limit, "git graph exceeds ${spec.limits.nodes} branches") + } + if (commits.isEmpty()) return Out.Err(Fault.Syntax, "git graph has no commits", 1) + return Out.Ok(marks()) + } + + private fun stmt(text: String): String? { + val token = text.substringBefore(' ').lowercase() + val rest = text.substringAfter(' ', "").trim() + when (token) { + "commit" -> { + commit(rest, from = heads[branch]) + return null + } + "branch" -> { + val name = rest.substringBefore(' ').trim() + if (name.isEmpty()) return "branch needs a name" + lanes.getOrPut(name) { lanes.size } + heads[name] = heads[branch] ?: -1 + branch = name + return null + } + "checkout", "switch" -> { + val name = rest.substringBefore(' ').trim() + if (!lanes.containsKey(name)) return "unknown branch $name" + branch = name + return null + } + "merge" -> { + val name = rest.substringBefore(' ').trim() + if (!lanes.containsKey(name)) return "unknown branch $name" + commit(rest.substringAfter(' ', "").trim(), from = heads[branch], other = heads[name], fallback = "merge $name") + return null + } + "accdescr", "acctitle", "%%" -> return null + else -> return null + } + } + + private fun commit(args: String, from: Int?, other: Int? = null, fallback: String = "") { + val id = pick(args, "id") ?: fallback.ifEmpty { "${commits.size}" } + val tag = pick(args, "tag").orEmpty() + commits.add(Commit(id, tag, branch, from, other)) + heads[branch] = commits.lastIndex + } + + /** Pulls `key: "value"` out of a commit argument list. */ + private fun pick(args: String, key: String): String? { + val match = Regex("""$key:\s*("[^"]*"|\S+)""").find(args) ?: return null + return Source.unquote(match.groupValues[1]) + } + + private fun marks(): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val stride = maxOf(high * 3, commits.maxOf { sheet.width(it.id) } / 2 + pad * 2) + val row = high * 3 + val left = lanes.keys.maxOf { sheet.width(it, bold = true) } + pad * 3 + fun at(idx: Int): Pt { + val commit = commits[idx] + return Pt(left + stride * (idx + 1), lanes.getValue(commit.branch) * row + row / 2) + } + lanes.forEach { (name, lane) -> + val y = lane * row + row / 2 + sheet.add(Mark.Text(name, Pt(0.0, y), Anchor.Left, Role.Muted, bold = true)) + } + commits.forEachIndexed { idx, commit -> + for (parent in listOfNotNull(commit.from, commit.other)) { + if (parent < 0) continue + sheet.add(Mark.Edge(listOf(at(parent), at(idx)), Role.Line, tone = lanes.getValue(commits[idx].branch))) + } + } + commits.forEachIndexed { idx, commit -> + val spot = at(idx) + val r = high * 0.55 + sheet.add(Mark.Oval(Rect(spot.x - r, spot.y - r, r * 2, r * 2), null, Role.Border, tone = lanes.getValue(commit.branch))) + sheet.add(Mark.Text(commit.id, Pt(spot.x, spot.y + r + pad), Anchor.Top, Role.Muted)) + if (commit.tag.isNotEmpty()) { + val wide = sheet.width(commit.tag) + pad * 2 + val box = Rect(spot.x - wide / 2, spot.y - r - pad - high, wide, high) + sheet.add(Mark.Box(box, spec.metrics.arc, Role.Note, Role.Border)) + sheet.label(listOf(commit.tag), box, Role.Text) + } + } + return sheet.scene(Type.Git) + } + + private data class Commit(val id: String, val tag: String, val branch: String, val from: Int?, val other: Int?) + + private companion object { + const val MAIN = "main" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Journey.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Journey.kt new file mode 100644 index 00000000000..aee61379a60 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Journey.kt @@ -0,0 +1,111 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * User journey engine. Tasks form columns with a section band above and a five-step score strip: the + * dot sits at the score height and consecutive dots are linked, mirroring mermaid's mood curve. + */ +internal class Journey(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + var title = "" + val tasks = mutableListOf() + var section = "" + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.lowercase() == "journey") continue + } + val token = text.substringBefore(' ').lowercase() + if (token == "title") { + title = text.substringAfter(' ', "").trim() + continue + } + if (token == "section") { + section = text.substringAfter(' ', "").trim() + continue + } + if (token == "accdescr" || token == "acctitle") continue + val parts = text.split(':') + if (parts.size < 2) continue + val score = Lex.num(parts[1]) ?: return Out.Err(Fault.Syntax, "journey score must be a number", line.at) + val actors = parts.getOrNull(2)?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }.orEmpty() + tasks.add(Task(parts[0].trim(), score.coerceIn(1.0, 5.0), actors, section)) + if (tasks.size > spec.limits.nodes) return Out.Err(Fault.Limit, "journey exceeds ${spec.limits.nodes} tasks") + } + if (tasks.isEmpty()) return Out.Err(Fault.Syntax, "journey has no tasks", 1) + return Out.Ok(marks(title, tasks)) + } + + private fun marks(title: String, tasks: List): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val strip = high * 5 + val sections = tasks.map { it.section }.distinct() + val widths = tasks.map { maxOf(sheet.width(it.label), sheet.width(it.actors.joinToString(", ")), high * 4) + sheet.gap } + + var top = 0.0 + if (title.isNotEmpty()) { + val wide = widths.sum() + top = sheet.texts(listOf(title), wide / 2, 0.0, Role.Text, bold = true) + pad + } + val band = top + val lift = band + high + pad * 2 + val dots = mutableListOf() + var x = 0.0 + tasks.forEachIndexed { idx, task -> + val wide = widths[idx] + val cx = x + wide / 2 + val cy = lift + (5.0 - task.score) / 4.0 * (strip - high) + high / 2 + dots.add(Pt(cx, cy)) + x += wide + } + sheet.add(Mark.Edge(dots, Role.Muted, dash = true)) + x = 0.0 + tasks.forEachIndexed { idx, task -> + val wide = widths[idx] + val tone = sections.indexOf(task.section) + val dot = dots[idx] + val r = high * 0.7 + sheet.add(Mark.Oval(Rect(dot.x - r, dot.y - r, r * 2, r * 2), null, Role.Border, tone = tone)) + sheet.add(Mark.Text(Axis.label(task.score), dot, Anchor.Center, Role.Text)) + sheet.texts(listOf(task.label), dot.x, lift + strip + pad, Role.Text) + if (task.actors.isNotEmpty()) { + sheet.texts(listOf(task.actors.joinToString(", ")), dot.x, lift + strip + pad + high, Role.Muted) + } + x += wide + } + var left = 0.0 + for (section in sections) { + if (section.isEmpty()) { + left += tasks.indices.filter { tasks[it].section.isEmpty() }.sumOf { widths[it] } + continue + } + val span = tasks.indices.filter { tasks[it].section == section }.sumOf { widths[it] } + val rect = Rect(left, band, span - pad / 2, high + pad) + sheet.add(Mark.Box(rect, spec.metrics.arc, null, null, tone = sections.indexOf(section), soft = true)) + sheet.label(listOf(section), rect, Role.Text, bold = true) + left += span + } + return sheet.scene(Type.Journey) + } + + private data class Task(val label: String, val score: Double, val actors: List, val section: String) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Kanban.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Kanban.kt new file mode 100644 index 00000000000..e2055e43ab9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Kanban.kt @@ -0,0 +1,90 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * Kanban engine: indentation makes the first level columns and deeper levels cards. Trailing + * `@{ ... }` metadata is parsed and discarded. + */ +internal class Kanban(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + val columns = mutableListOf() + var indent = -1 + var first = true + var count = 0 + for (line in clean.lines) { + coroutineContext.ensureActive() + if (line.text.isBlank()) continue + val depth = line.text.takeWhile { it == ' ' }.length + val text = strip(line.text.trim()) + if (text.isEmpty()) continue + if (first) { + first = false + if (text.lowercase() == "kanban") continue + } + val label = Lex.tagged(text)?.second ?: text + count++ + if (count > spec.limits.nodes) return Out.Err(Fault.Limit, "kanban exceeds ${spec.limits.nodes} items") + if (indent < 0 || depth <= indent) { + indent = if (indent < 0) depth else indent + if (depth <= indent) { + columns.add(Column(label, mutableListOf())) + continue + } + } + val column = columns.lastOrNull() ?: return Out.Err(Fault.Syntax, "card before any column", line.at) + column.cards.add(label) + } + if (columns.isEmpty()) return Out.Err(Fault.Syntax, "kanban has no columns", 1) + return Out.Ok(marks(columns)) + } + + /** Drops one trailing `@{ ... }` metadata block. */ + private fun strip(text: String): String { + val at = text.lastIndexOf("@{") + if (at < 0 || !text.trimEnd().endsWith("}")) return text + return text.substring(0, at).trim() + } + + private fun marks(columns: List): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + var x = 0.0 + val tall = columns.maxOf { it.cards.size } * (high + pad * 3) + high + pad * 3 + columns.forEachIndexed { idx, column -> + val wide = maxOf( + sheet.width(column.label, bold = true), + column.cards.maxOfOrNull { sheet.width(it) } ?: 0.0, + high * 8, + ) + pad * 4 + val frame = Rect(x, 0.0, wide, tall) + sheet.add(Mark.Box(frame, spec.metrics.arc, null, Role.Cluster)) + sheet.add(Mark.Box(Rect(x, 0.0, wide, high + pad * 2), spec.metrics.arc, null, null, tone = idx, soft = true)) + sheet.texts(listOf(column.label), x + wide / 2, pad, Role.Text, bold = true) + var y = high + pad * 3 + for (card in column.cards) { + val rect = Rect(x + pad, y, wide - pad * 2, high + pad * 2) + sheet.add(Mark.Box(rect, spec.metrics.arc, Role.Surface, Role.Border)) + sheet.add(Mark.Text(card, Pt(rect.x + pad, rect.y + rect.h / 2), Anchor.Left, Role.Text)) + y += rect.h + pad + } + x += wide + sheet.gap + } + return sheet.scene(Type.Kanban) + } + + private data class Column(val label: String, val cards: MutableList) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Layered.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Layered.kt new file mode 100644 index 00000000000..b541b265729 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Layered.kt @@ -0,0 +1,139 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Size +import ai.kilocode.client.ui.diagram.Spec +import kotlin.coroutines.coroutineContext +import kotlin.math.abs +import kotlinx.coroutines.ensureActive + +internal data class Rail(val from: String, val to: String) + +internal data class Plan(val rects: Map, val size: Size) + +/** + * Small layered layout shared by the graph-family engines (class, state, er, requirement, C4). + * + * Deliberately simpler than [FlowLayout]: longest-path ranks over cycle-free rails, one barycenter + * sweep in each direction, centered rows. All phases iterate insertion-ordered collections a fixed + * number of times so the result is byte-stable for a given input. + */ +internal class Layered(private val spec: Spec) { + suspend fun run(sizes: Map, rails: List): Plan { + val gap = spec.metrics.gap + val step = spec.metrics.rank + val links = rails.filter { it.from != it.to && sizes.containsKey(it.from) && sizes.containsKey(it.to) } + val ids = sizes.keys.toList() + val keep = drop(ids, links) + val rank = ranks(ids, keep) + coroutineContext.ensureActive() + + val rows = linkedMapOf>() + for (id in ids) rows.getOrPut(rank.getValue(id)) { mutableListOf() }.add(id) + order(rows, keep) + coroutineContext.ensureActive() + + val wide = rows.values.maxOf { row -> row.sumOf { sizes.getValue(it).w } + gap * (row.size - 1) } + val rects = linkedMapOf() + var top = 0.0 + for (row in rows.values) { + val tall = row.maxOf { sizes.getValue(it).h } + var x = (wide - (row.sumOf { sizes.getValue(it).w } + gap * (row.size - 1))) / 2 + for (id in row) { + val size = sizes.getValue(id) + rects[id] = Rect(x, top + (tall - size.h) / 2, size.w, size.h) + x += size.w + gap + } + top += tall + step + } + return Plan(rects, Size(wide, top - step)) + } + + /** Rails surviving a DFS cycle check in declaration order; back edges do not shape ranks. */ + private fun drop(ids: List, links: List): List { + val adj = linkedMapOf>() + for (id in ids) adj[id] = mutableListOf() + links.forEachIndexed { idx, rail -> adj[rail.from]?.add(idx) } + val state = linkedMapOf() + val backs = linkedSetOf() + + fun visit(id: String) { + state[id] = GRAY + for (idx in adj[id] ?: mutableListOf()) { + val to = links[idx].to + when (state[to] ?: WHITE) { + GRAY -> backs.add(idx) + WHITE -> visit(to) + else -> Unit + } + } + state[id] = BLACK + } + + for (id in ids) if ((state[id] ?: WHITE) == WHITE) visit(id) + return links.filterIndexed { idx, _ -> idx !in backs } + } + + /** Longest-path ranks; the rail set is a DAG so passes converge within the node count. */ + private fun ranks(ids: List, links: List): Map { + val rank = linkedMapOf() + for (id in ids) rank[id] = 0 + repeat(ids.size) { + var moved = false + for (link in links) { + val want = rank.getValue(link.from) + 1 + if (rank.getValue(link.to) < want) { + rank[link.to] = want + moved = true + } + } + if (!moved) return rank + } + return rank + } + + /** One barycenter sweep down then up; stable sort keeps declaration order for ties. */ + private fun order(rows: LinkedHashMap>, links: List) { + val keys = rows.keys.sorted() + val at = linkedMapOf() + for (row in rows.values) row.forEachIndexed { idx, id -> at[id] = idx } + for (key in keys.drop(1)) sweep(rows.getValue(key), links, at, up = false) + for (key in keys.dropLast(1).reversed()) sweep(rows.getValue(key), links, at, up = true) + } + + private fun sweep(row: MutableList, links: List, at: MutableMap, up: Boolean) { + val score = linkedMapOf() + row.forEachIndexed { idx, id -> + val peers = links.mapNotNull { + if (up && it.from == id) at[it.to] else if (!up && it.to == id) at[it.from] else null + } + score[id] = if (peers.isEmpty()) idx.toDouble() else peers.average() + } + row.sortWith(compareBy { score.getValue(it) }) + row.forEachIndexed { idx, id -> at[id] = idx } + } + + private companion object { + const val WHITE = 0 + const val GRAY = 1 + const val BLACK = 2 + } +} + +/** + * Border anchor points for a straight connector between two rects: vertical faces when the centers + * are stacked, horizontal faces when they sit side by side. + */ +internal fun joint(a: Rect, b: Rect): Pair { + val ax = a.x + a.w / 2 + val ay = a.y + a.h / 2 + val bx = b.x + b.w / 2 + val by = b.y + b.h / 2 + if (abs(by - ay) >= abs(bx - ax)) { + if (by >= ay) return Pt(ax, a.y + a.h) to Pt(bx, b.y) + return Pt(ax, a.y) to Pt(bx, b.y + b.h) + } + if (bx >= ax) return Pt(a.x + a.w, ay) to Pt(b.x, by) + return Pt(a.x, ay) to Pt(b.x + b.w, by) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Lex.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Lex.kt new file mode 100644 index 00000000000..7a98d0aec19 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Lex.kt @@ -0,0 +1,60 @@ +package ai.kilocode.client.ui.diagram.mermaid + +internal data class Tok(val text: String, val at: Int) + +/** Small lexing helpers shared by the chart parsers. */ +internal object Lex { + /** Splits on commas that sit outside quotes and outside bracket groups; parts are trimmed. */ + fun args(text: String): List { + val mask = Source.opens(text) + val out = mutableListOf() + var start = 0 + for (idx in text.indices) { + if (text[idx] != ',' || !mask[idx]) continue + out.add(text.substring(start, idx).trim()) + start = idx + 1 + } + out.add(text.substring(start).trim()) + return out + } + + /** The body inside the outermost `(...)` of a call-shaped statement, or null when malformed. */ + fun call(text: String): String? { + val open = text.indexOf('(') + if (open < 0 || !text.trimEnd().endsWith(")")) return null + return text.substring(open + 1, text.trimEnd().length - 1) + } + + fun num(text: String): Double? = text.trim().toDoubleOrNull() + + /** Whitespace tokens with their offsets; quoted strings and bracket groups stay glued. */ + fun tokens(text: String): List { + val mask = Source.opens(text) + val out = mutableListOf() + var start = -1 + for (idx in text.indices) { + if (text[idx].isWhitespace() && mask[idx]) { + if (start >= 0) { + out.add(Tok(text.substring(start, idx), start)) + start = -1 + } + continue + } + if (start < 0) start = idx + } + if (start >= 0) out.add(Tok(text.substring(start), start)) + return out + } + + /** `id["Label"]` → id to label; a bare token maps to itself. */ + fun tagged(text: String): Pair? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return null + val open = trimmed.indexOf('[') + if (open < 0) return trimmed to trimmed + if (!trimmed.endsWith("]") || open == 0) return null + val id = trimmed.substring(0, open).trim() + val label = Source.unquote(trimmed.substring(open + 1, trimmed.length - 1).trim()) + return id to label + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Mermaid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Mermaid.kt index bf4c435d429..aefc8ca1078 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Mermaid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Mermaid.kt @@ -10,13 +10,13 @@ import kotlin.coroutines.coroutineContext import kotlinx.coroutines.ensureActive /** - * In-process mermaid engine covering flowcharts and sequence diagrams. + * In-process mermaid engine covering every diagram type [Type] can detect. * * [Measure] is a construction-time capability rather than part of [Spec] so an out-of-process engine * can implement the same interface while doing its own text measurement. */ internal class Mermaid(private val measure: Measure) : Engine { - override fun accepts(type: Type) = type == Type.Flowchart || type == Type.Sequence + override fun accepts(type: Type) = type != Type.Unknown override suspend fun draw(source: String, spec: Spec): Out { if (source.length > spec.limits.chars) { @@ -32,10 +32,31 @@ internal class Mermaid(private val measure: Measure) : Engine { if (clean.lines.any { it.text.length > spec.limits.span }) { return Out.Err(Fault.Limit, "a line exceeds ${spec.limits.span} characters") } - val type = Type.of(clean) - if (!accepts(type)) return Out.Err(Fault.Unsupported, "unsupported diagram type: $type") - if (type == Type.Flowchart) return flow(clean, spec) - return seq(clean, spec) + return when (val type = Type.of(clean)) { + Type.Flowchart -> flow(clean, spec) + Type.Sequence -> seq(clean, spec) + Type.Class -> ClassDg(measure, spec).draw(clean) + Type.State -> StateDg(measure, spec).draw(clean) + Type.Er -> ErDg(measure, spec).draw(clean) + Type.Gantt -> Gantt(measure, spec).draw(clean) + Type.Pie -> Pie(measure, spec).draw(clean) + Type.Journey -> Journey(measure, spec).draw(clean) + Type.Quadrant -> Quadrant(measure, spec).draw(clean) + Type.Requirement -> ReqDg(measure, spec).draw(clean) + Type.Git -> GitDg(measure, spec).draw(clean) + Type.C4 -> C4Dg(measure, spec).draw(clean) + Type.Mindmap -> Mindmap(measure, spec).draw(clean) + Type.Timeline -> Timeline(measure, spec).draw(clean) + Type.Sankey -> Sankey(measure, spec).draw(clean) + Type.XyChart -> XyChart(measure, spec).draw(clean) + Type.Block -> BlockDg(measure, spec).draw(clean) + Type.Packet -> Packet(measure, spec).draw(clean) + Type.Kanban -> Kanban(measure, spec).draw(clean) + Type.Architecture -> Arch(measure, spec).draw(clean) + Type.Radar -> Radar(measure, spec).draw(clean) + Type.Treemap -> Treemap(measure, spec).draw(clean) + Type.Unknown -> Out.Err(Fault.Unsupported, "unsupported diagram type: $type") + } } private suspend fun flow(clean: Clean, spec: Spec): Out { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Mindmap.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Mindmap.kt new file mode 100644 index 00000000000..27d6cd9caae --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Mindmap.kt @@ -0,0 +1,139 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * Mindmap engine. A tidy right-growing tree instead of mermaid's radial layout — a deliberate core + * simplification. Branch tones follow the top-level child. + */ +internal class Mindmap(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + var root: Node? = null + val stack = ArrayDeque>() + var first = true + var count = 0 + for (line in clean.lines) { + coroutineContext.ensureActive() + if (line.text.isBlank()) continue + val depth = line.text.takeWhile { it == ' ' }.length + var text = line.text.trim() + if (first) { + first = false + if (text.lowercase() == "mindmap") continue + } + if (text.startsWith("::icon")) continue + if (text.startsWith("%%")) continue + val icon = text.indexOf("::icon") + if (icon > 0) text = text.substring(0, icon).trim() + val node = node(text) + count++ + if (count > spec.limits.nodes) return Out.Err(Fault.Limit, "mindmap exceeds ${spec.limits.nodes} nodes") + while (stack.isNotEmpty() && stack.last().first >= depth) stack.removeLast() + val parent = stack.lastOrNull()?.second + if (parent == null) { + if (root != null) return Out.Err(Fault.Syntax, "mindmap has more than one root", line.at) + root = node + } else { + parent.kids.add(node) + } + stack.addLast(depth to node) + } + val tree = root ?: return Out.Err(Fault.Syntax, "mindmap has no nodes", 1) + return Out.Ok(marks(tree)) + } + + private fun node(text: String): Node { + for (wrap in WRAPS) { + val open = text.indexOf(wrap.first) + if (open < 0 || !text.endsWith(wrap.second)) continue + if (text.length < open + wrap.first.length + wrap.second.length) continue + return Node(text.substring(open + wrap.first.length, text.length - wrap.second.length).trim(), mutableListOf()) + } + return Node(text, mutableListOf()) + } + + private suspend fun marks(root: Node): Scene { + val sheet = Sheet(measure, spec) + rows(root) + widths(sheet, root, 0) + place(sheet, root, 0, 0.0, 0.0, -1) + return sheet.scene(Type.Mindmap) + } + + /** Rows a subtree needs: leaves take one row each. */ + private fun rows(node: Node): Int { + node.rows = if (node.kids.isEmpty()) 1 else node.kids.sumOf { rows(it) } + return node.rows + } + + private val cols = mutableListOf() + + private fun widths(sheet: Sheet, node: Node, depth: Int) { + if (cols.size <= depth) cols.add(0.0) + cols[depth] = maxOf(cols[depth], sheet.width(node.label, bold = depth == 0) + sheet.pad * 2) + for (kid in node.kids) widths(sheet, kid, depth + 1) + } + + private suspend fun place(sheet: Sheet, node: Node, depth: Int, x: Double, top: Double, tone: Int): Rect { + coroutineContext.ensureActive() + val high = sheet.high + val pad = sheet.pad + val row = high + pad * 2 + sheet.gap + val tall = high + pad * 2 + val wide = cols[depth] + val my = top + (node.rows * row - tall) / 2 + val rect = Rect(x, my, wide, tall) + when { + depth == 0 -> { + sheet.add(Mark.Oval(Rect(rect.x, rect.y - pad, rect.w, rect.h + pad * 2), Role.Surface, Role.Border)) + sheet.label(listOf(node.label), rect, Role.Text, bold = true) + } + else -> { + sheet.add(Mark.Box(rect, rect.h / 2, null, Role.Border, tone = tone, soft = true)) + sheet.label(listOf(node.label), rect, Role.Text) + } + } + var y = top + node.kids.forEachIndexed { idx, kid -> + val hue = if (depth == 0) idx else tone + val child = place(sheet, kid, depth + 1, x + wide + sheet.gap * 2, y, hue) + sheet.add( + Mark.Edge( + listOf( + Pt(rect.x + rect.w, rect.y + rect.h / 2), + Pt(rect.x + rect.w + sheet.gap, rect.y + rect.h / 2), + Pt(child.x - sheet.gap, child.y + child.h / 2), + Pt(child.x, child.y + child.h / 2), + ), + Role.Muted, + ), + ) + y += kid.rows * row + } + return rect + } + + private data class Node(val label: String, val kids: MutableList, var rows: Int = 1) + + private companion object { + val WRAPS = listOf( + "((" to "))", + "([" to "])", + "[(" to ")]", + "{{" to "}}", + "[" to "]", + "(" to ")", + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Packet.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Packet.kt new file mode 100644 index 00000000000..ab916acce42 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Packet.kt @@ -0,0 +1,79 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** Packet diagram engine: bit fields on a 32-bit grid; fields crossing a row boundary split. */ +internal class Packet(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + val fields = mutableListOf() + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + val token = text.substringBefore(' ').lowercase() + if (token == "packet-beta" || token == "packet") continue + } + if (text.substringBefore(' ').lowercase() in setOf("title", "accdescr", "acctitle")) continue + val match = ROW.find(text) ?: return Out.Err(Fault.Syntax, "malformed packet field", line.at) + val start = match.groupValues[1].toInt() + val end = match.groupValues[2].ifEmpty { match.groupValues[1] }.toInt() + if (end < start) return Out.Err(Fault.Syntax, "packet field ends before it starts", line.at) + fields.add(Field(start, end, Source.unquote(match.groupValues[3].trim()))) + if (fields.size > spec.limits.nodes) return Out.Err(Fault.Limit, "packet exceeds ${spec.limits.nodes} fields") + } + if (fields.isEmpty()) return Out.Err(Fault.Syntax, "packet has no fields", 1) + return Out.Ok(marks(fields.sortedBy { it.start })) + } + + private fun marks(fields: List): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val unit = maxOf(high * 1.8, sheet.width("000") + pad) + val tall = high + pad * 2 + val lead = high + for (field in fields) { + var start = field.start + while (start <= field.end) { + val row = start / BITS + val stop = minOf(field.end, (row + 1) * BITS - 1) + val rect = Rect( + (start % BITS) * unit, + lead + row * (tall + lead), + (stop - start + 1) * unit, + tall, + ) + sheet.add(Mark.Box(rect, 0.0, Role.Surface, Role.Border)) + sheet.label(listOf(sheet.fit(field.label, rect.w - pad)), rect, Role.Text) + sheet.add(Mark.Text("$start", Pt(rect.x + 1, rect.y - 1), Anchor.BottomLeft, Role.Muted)) + if (stop > start) { + sheet.add(Mark.Text("$stop", Pt(rect.x + rect.w - 1, rect.y - 1), Anchor.BottomRight, Role.Muted)) + } + start = stop + 1 + } + } + return sheet.scene(Type.Packet) + } + + private data class Field(val start: Int, val end: Int, val label: String) + + private companion object { + const val BITS = 32 + val ROW = Regex("""^\+?(\d+)(?:-(\d+))?\s*:\s*(.+)$""") + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Pie.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Pie.kt new file mode 100644 index 00000000000..fb8253fbf3c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Pie.kt @@ -0,0 +1,103 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlin.math.cos +import kotlin.math.sin +import kotlinx.coroutines.ensureActive + +/** Pie chart engine. Slices sort by value descending and run clockwise from 12 o'clock, like mermaid. */ +internal class Pie(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + var title = "" + var data = false + val slices = mutableListOf() + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + var text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.substringBefore(' ').lowercase() == "pie") { + text = text.substringAfter(' ', "").trim() + if (text.substringBefore(' ').lowercase() == "showdata") { + data = true + text = text.substringAfter(' ', "").trim() + } + if (text.isEmpty()) continue + } + } + val token = text.substringBefore(' ').lowercase() + if (token == "title") { + title = text.substringAfter(' ', "").trim() + continue + } + if (token == "showdata") { + data = true + continue + } + if (token == "accdescr" || token == "acctitle") continue + val colon = colon(text) ?: continue + val label = Source.unquote(text.substring(0, colon).trim()) + val value = Lex.num(text.substring(colon + 1)) + ?: return Out.Err(Fault.Syntax, "pie value must be a number", line.at) + if (value < 0) return Out.Err(Fault.Syntax, "pie value must not be negative", line.at) + slices.add(Slice(label, value)) + if (slices.size > spec.limits.nodes) return Out.Err(Fault.Limit, "pie exceeds ${spec.limits.nodes} slices") + } + if (slices.isEmpty()) return Out.Err(Fault.Syntax, "pie has no data", 1) + val total = slices.sumOf { it.value } + if (total <= 0.0) return Out.Err(Fault.Syntax, "pie values sum to zero", 1) + return Out.Ok(marks(title, data, slices.sortedByDescending { it.value }, total)) + } + + private fun colon(text: String): Int? { + val mask = Source.opens(text) + for (idx in text.indices) if (text[idx] == ':' && mask[idx]) return idx + return null + } + + private fun marks(title: String, data: Boolean, slices: List, total: Double): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val r = high * 6 + var top = 0.0 + if (title.isNotEmpty()) top = sheet.texts(listOf(title), r, 0.0, Role.Text, bold = true) + pad + val at = Pt(r, top + r) + var angle = 90.0 + slices.forEachIndexed { idx, slice -> + val sweep = -360.0 * slice.value / total + sheet.add(Mark.Sector(at, r, angle, sweep, null, Role.Border, tone = idx)) + val frac = slice.value / total + if (frac >= 0.04) { + val mid = Math.toRadians(angle + sweep / 2) + val spot = Pt(at.x + cos(mid) * r * 0.62, at.y - sin(mid) * r * 0.62) + sheet.add(Mark.Text("${Math.round(frac * 100)}%", spot, Anchor.Center, Role.Text)) + } + angle += sweep + } + var row = top + slices.forEachIndexed { idx, slice -> + val swatch = Rect(r * 2 + sheet.gap, row, high * 0.8, high * 0.8) + sheet.add(Mark.Box(swatch, 0.0, null, Role.Border, tone = idx)) + val text = if (data) "${slice.label} [${Axis.label(slice.value)}]" else slice.label + sheet.add(Mark.Text(text, Pt(swatch.x + swatch.w + pad, swatch.y + swatch.h / 2), Anchor.Left, Role.Text)) + row += high + } + return sheet.scene(Type.Pie) + } + + private data class Slice(val label: String, val value: Double) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Quadrant.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Quadrant.kt new file mode 100644 index 00000000000..434b8be13c9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Quadrant.kt @@ -0,0 +1,108 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** Quadrant chart engine: four soft-tone quadrants, axis captions, and labeled points in unit space. */ +internal class Quadrant(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + var title = "" + val axisX = arrayOf("", "") + val axisY = arrayOf("", "") + val names = arrayOfNulls(4) + val points = mutableListOf() + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.lowercase() == "quadrantchart") continue + } + val token = text.substringBefore(' ').lowercase() + val rest = text.substringAfter(' ', "").trim() + when { + token == "title" -> title = rest + token == "x-axis" -> split(rest, axisX) + token == "y-axis" -> split(rest, axisY) + token.startsWith("quadrant-") -> { + val slot = token.removePrefix("quadrant-").toIntOrNull() + ?: return Out.Err(Fault.Syntax, "malformed quadrant label", line.at) + if (slot !in 1..4) return Out.Err(Fault.Syntax, "quadrant index must be 1-4", line.at) + names[slot - 1] = rest + } + token == "accdescr" || token == "acctitle" || token == "classdef" -> Unit + else -> { + val match = POINT.find(text) ?: continue + val x = match.groupValues[2].toDoubleOrNull() + val y = match.groupValues[3].toDoubleOrNull() + if (x == null || y == null) return Out.Err(Fault.Syntax, "point needs [x, y]", line.at) + points.add(Point(match.groupValues[1].trim(), x.coerceIn(0.0, 1.0), y.coerceIn(0.0, 1.0))) + if (points.size > spec.limits.nodes) return Out.Err(Fault.Limit, "chart exceeds ${spec.limits.nodes} points") + } + } + } + if (names.all { it == null } && points.isEmpty()) return Out.Err(Fault.Syntax, "quadrant chart has no content", 1) + return Out.Ok(marks(title, axisX, axisY, names, points)) + } + + /** `Low Reach --> High Reach` — either side may be missing. */ + private fun split(text: String, into: Array) { + val cut = text.indexOf("-->") + if (cut < 0) { + into[0] = text + return + } + into[0] = text.substring(0, cut).trim() + into[1] = text.substring(cut + 3).trim() + } + + private fun marks(title: String, axisX: Array, axisY: Array, names: Array, points: List): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val side = high * 18 + val plot = Rect(0.0, 0.0, side, side) + var top = -pad + if (title.isNotEmpty()) sheet.texts(listOf(title), side / 2, -high - pad * 2, Role.Text, bold = true) + + val zones = listOf( + Rect(plot.x + side / 2, plot.y, side / 2, side / 2), + Rect(plot.x, plot.y, side / 2, side / 2), + Rect(plot.x, plot.y + side / 2, side / 2, side / 2), + Rect(plot.x + side / 2, plot.y + side / 2, side / 2, side / 2), + ) + zones.forEachIndexed { idx, zone -> + sheet.add(Mark.Box(zone, 0.0, null, Role.Cluster, tone = idx, soft = true)) + names[idx]?.let { sheet.label(listOf(it), zone, Role.Muted, bold = true) } + } + for (point in points) { + val at = Pt(plot.x + point.x * side, plot.y + (1.0 - point.y) * side) + sheet.add(Mark.Oval(Rect(at.x - 3.0, at.y - 3.0, 6.0, 6.0), Role.Accent, null)) + sheet.add(Mark.Text(point.label, Pt(at.x, at.y - pad), Anchor.Bottom, Role.Text)) + } + if (axisX[0].isNotEmpty()) sheet.add(Mark.Text(axisX[0], Pt(plot.x, plot.y + side + pad + high / 2), Anchor.Left, Role.Muted)) + if (axisX[1].isNotEmpty()) sheet.add(Mark.Text(axisX[1], Pt(plot.x + side, plot.y + side + pad + high / 2), Anchor.Right, Role.Muted)) + if (axisY[0].isNotEmpty()) sheet.add(Mark.Text(axisY[0], Pt(plot.x - pad, plot.y + side - high / 2), Anchor.Right, Role.Muted)) + if (axisY[1].isNotEmpty()) sheet.add(Mark.Text(axisY[1], Pt(plot.x - pad, plot.y + high / 2), Anchor.Right, Role.Muted)) + return sheet.scene(Type.Quadrant) + } + + private data class Point(val label: String, val x: Double, val y: Double) + + private companion object { + val POINT = Regex("""^(.+?):\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*]$""") + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Radar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Radar.kt new file mode 100644 index 00000000000..89377d16e51 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Radar.kt @@ -0,0 +1,175 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin +import kotlinx.coroutines.ensureActive + +/** Radar chart engine: axes on a circular or polygonal graticule with soft-filled curves. */ +internal class Radar(private val measure: Measure, private val spec: Spec) { + private val axes = linkedMapOf() + private val curves = mutableListOf() + private var legend = true + private var polygon = false + private var ticks = 5 + private var min = 0.0 + private var max: Double? = null + private var title = "" + + suspend fun draw(clean: Clean): Out { + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + val token = text.substringBefore(' ').lowercase() + if (token == "radar-beta" || token == "radar") continue + } + val err = stmt(text) + if (err != null) return Out.Err(Fault.Syntax, err, line.at) + if (axes.size > spec.limits.nodes) return Out.Err(Fault.Limit, "radar exceeds ${spec.limits.nodes} axes") + if (curves.size > spec.limits.nodes) return Out.Err(Fault.Limit, "radar exceeds ${spec.limits.nodes} curves") + } + if (axes.isEmpty()) return Out.Err(Fault.Syntax, "radar has no axes", 1) + if (curves.isEmpty()) return Out.Err(Fault.Syntax, "radar has no curves", 1) + return Out.Ok(marks()) + } + + private fun stmt(text: String): String? { + val token = text.substringBefore(' ').lowercase() + val rest = text.substringAfter(' ', "").trim() + when (token) { + "title" -> { + title = rest + return null + } + "showlegend" -> { + legend = rest.isEmpty() || rest.lowercase() == "true" + return null + } + "graticule" -> { + polygon = rest.lowercase() == "polygon" + return null + } + "ticks" -> { + ticks = rest.toIntOrNull()?.coerceIn(1, 20) ?: return "ticks needs a number" + return null + } + "max" -> { + max = Lex.num(rest) ?: return "max needs a number" + return null + } + "min" -> { + min = Lex.num(rest) ?: return "min needs a number" + return null + } + "axis" -> { + for (part in Lex.args(rest)) { + val tag = Lex.tagged(part) ?: return "malformed axis $part" + axes[tag.first] = tag.second + } + return null + } + "curve" -> return curve(rest) + "accdescr", "acctitle" -> return null + else -> return null + } + } + + /** `alice["Alice"]{85, 90}` — one or more per line; values positional or `axis: value` pairs. */ + private fun curve(text: String): String? { + var rest = text.trim() + while (rest.isNotEmpty()) { + val open = rest.indexOf('{') + if (open <= 0) return "malformed curve" + val close = rest.indexOf('}', open) + if (close < 0) return "curve is missing a closing brace" + val tag = Lex.tagged(rest.substring(0, open).trim().removeSuffix(",").trim()) ?: return "malformed curve" + val cells = Lex.args(rest.substring(open + 1, close)) + val byKey = cells.all { it.contains(':') } + val values = if (byKey) { + val map = cells.associate { cell -> + val key = cell.substringBefore(':').trim() + val value = Lex.num(cell.substringAfter(':')) ?: return "curve values must be numbers" + key to value + } + axes.keys.map { map[it] ?: min } + } else { + cells.map { Lex.num(it) ?: return "curve values must be numbers" } + } + curves.add(Curve(tag.second, values)) + rest = rest.substring(close + 1).trim().removePrefix(",").trim() + } + return null + } + + private fun marks(): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val r = high * 9 + val at = Pt(0.0, 0.0) + val roof = max ?: curves.maxOf { it.values.maxOrNull() ?: 0.0 } + val span = (roof - min).takeIf { it > 0 } ?: 1.0 + val count = axes.size + + if (title.isNotEmpty()) sheet.texts(listOf(title), at.x, at.y - r - high * 2 - pad, Role.Text, bold = true) + fun spoke(idx: Int, radius: Double): Pt { + val angle = Math.PI / 2 - 2 * Math.PI * idx / count + return Pt(at.x + cos(angle) * radius, at.y - sin(angle) * radius) + } + for (ring in 1..ticks) { + val radius = r * ring / ticks + if (polygon) { + sheet.add(Mark.Poly(List(count) { spoke(it, radius) }, null, Role.Cluster)) + continue + } + sheet.add(Mark.Oval(Rect(at.x - radius, at.y - radius, radius * 2, radius * 2), null, Role.Cluster)) + } + axes.values.forEachIndexed { idx, label -> + val end = spoke(idx, r) + sheet.add(Mark.Edge(listOf(at, end), Role.Cluster)) + val tip = spoke(idx, r * 1.08) + val anchor = when { + abs(tip.x - at.x) < r * 0.3 -> if (tip.y < at.y) Anchor.Bottom else Anchor.Top + tip.x > at.x -> Anchor.Left + else -> Anchor.Right + } + sheet.add(Mark.Text(label, tip, anchor, Role.Muted)) + } + curves.forEachIndexed { tone, curve -> + val points = List(count) { idx -> + val value = (curve.values.getOrNull(idx) ?: min).coerceIn(min, roof) + spoke(idx, r * (value - min) / span) + } + sheet.add(Mark.Poly(points, null, null, tone = tone, soft = true)) + sheet.add(Mark.Edge(points + points.first(), Role.Line, thick = true, tone = tone)) + } + if (legend) { + var row = at.y - r + curves.forEachIndexed { tone, curve -> + val swatch = Rect(at.x + r * 1.3, row, high * 0.8, high * 0.8) + sheet.add(Mark.Box(swatch, 0.0, null, null, tone = tone)) + sheet.add(Mark.Text(curve.label, Pt(swatch.x + swatch.w + pad, swatch.y + swatch.h / 2), Anchor.Left, Role.Text)) + row += high + } + } + return sheet.scene(Type.Radar) + } + + private data class Curve(val label: String, val values: List) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ReqDg.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ReqDg.kt new file mode 100644 index 00000000000..d16209dee29 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/ReqDg.kt @@ -0,0 +1,148 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Head +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Size +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** Requirement diagram engine: requirement/element blocks with key-value fields plus labeled relations. */ +internal class ReqDg(private val measure: Measure, private val spec: Spec) { + private val boxes = linkedMapOf() + private val rels = mutableListOf() + private var block: String? = null + + suspend fun draw(clean: Clean): Out { + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.substringBefore(' ').lowercase() == "requirementdiagram") continue + } + val err = stmt(text) + if (err != null) return Out.Err(Fault.Syntax, err, line.at) + if (boxes.size > spec.limits.nodes) return Out.Err(Fault.Limit, "requirement diagram exceeds ${spec.limits.nodes} nodes") + if (rels.size > spec.limits.edges) return Out.Err(Fault.Limit, "requirement diagram exceeds ${spec.limits.edges} relations") + } + if (block != null) return Out.Err(Fault.Syntax, "block is missing a closing brace", clean.lines.lastOrNull()?.at ?: 1) + if (boxes.isEmpty()) return Out.Err(Fault.Syntax, "requirement diagram has no requirements", 1) + return marks() + } + + private fun stmt(text: String): String? { + val open = block + if (open != null) { + if (text == "}") { + block = null + return null + } + field(open, text.removeSuffix("}").trim()) + if (text.endsWith("}")) block = null + return null + } + val token = text.substringBefore(' ') + val kind = KINDS[token.lowercase()] + if (kind != null) { + val rest = text.substringAfter(' ', "").trim() + val id = rest.removeSuffix("{").trim() + if (id.isEmpty()) return "$token needs a name" + boxes[id] = Req(id, kind, linkedMapOf()) + if (rest.endsWith("{")) block = id + return null + } + val match = REL.find(text) ?: return null + val back = match.groupValues[2] == "<-" + val from = if (back) match.groupValues[5] else match.groupValues[1] + val to = if (back) match.groupValues[1] else match.groupValues[5] + boxes.getOrPut(from) { Req(from, "element", linkedMapOf()) } + boxes.getOrPut(to) { Req(to, "element", linkedMapOf()) } + rels.add(RRel(from, to, match.groupValues[3])) + return null + } + + private fun field(id: String, text: String) { + if (text.isEmpty()) return + val colon = text.indexOf(':') + if (colon <= 0) return + val key = text.substring(0, colon).trim().lowercase() + boxes.getValue(id).fields[key] = Source.unquote(text.substring(colon + 1).trim()) + } + + private suspend fun marks(): Out.Ok { + val sheet = Sheet(measure, spec) + val pad = sheet.pad + val high = sheet.high + val sizes = linkedMapOf() + val lines = linkedMapOf>() + for (req in boxes.values) { + coroutineContext.ensureActive() + val room = maxOf(sheet.width(req.id, bold = true), high * 18) + val rows = req.fields.flatMap { (key, value) -> sheet.wrap("$key: $value", room) } + lines[req.id] = rows + val wide = maxOf( + sheet.width("«${req.kind}»"), + sheet.width(req.id, bold = true), + rows.maxOfOrNull { sheet.width(it) } ?: 0.0, + ) + pad * 2 + val tall = high * (2 + rows.size) + pad * 3 + (if (rows.isEmpty()) 0.0 else pad) + sizes[req.id] = Size(wide, tall) + } + val plan = Layered(spec).run(sizes, rels.map { Rail(it.from, it.to) }) + for (rel in rels) { + coroutineContext.ensureActive() + val ends = joint(plan.rects.getValue(rel.from), plan.rects.getValue(rel.to)) + sheet.add(Mark.Edge(listOf(ends.first, ends.second), Role.Line, dash = true, head = Head.Arrow)) + val mid = Pt((ends.first.x + ends.second.x) / 2, (ends.first.y + ends.second.y) / 2) + sheet.texts(listOf("«${rel.label}»"), mid.x, mid.y - high, Role.Muted) + } + for (req in boxes.values) { + coroutineContext.ensureActive() + val rect = plan.rects.getValue(req.id) + sheet.add(Mark.Box(rect, spec.metrics.arc, Role.Surface, Role.Border)) + var top = rect.y + pad + top += sheet.texts(listOf("«${req.kind}»"), rect.x + rect.w / 2, top, Role.Muted) + top += sheet.texts(listOf(req.id), rect.x + rect.w / 2, top, Role.Text, bold = true) + val rows = lines.getValue(req.id) + if (rows.isEmpty()) continue + top += pad + sheet.add(Mark.Edge(listOf(Pt(rect.x, top), Pt(rect.x + rect.w, top)), Role.Border)) + top += pad + for (row in rows) { + sheet.add(Mark.Text(row, Pt(rect.x + pad, top + high * 0.5), Anchor.Left, Role.Text)) + top += high + } + } + return Out.Ok(sheet.scene(Type.Requirement)) + } + + private data class Req(val id: String, val kind: String, val fields: LinkedHashMap) + + private data class RRel(val from: String, val to: String, val label: String) + + private companion object { + val KINDS = mapOf( + "requirement" to "requirement", + "functionalrequirement" to "functionalRequirement", + "interfacerequirement" to "interfaceRequirement", + "performancerequirement" to "performanceRequirement", + "physicalrequirement" to "physicalRequirement", + "designconstraint" to "designConstraint", + "element" to "element", + ) + + /** `a - satisfies -> b` and the reversed `a <- satisfies - b`. */ + val REL = Regex("""^(\S+)\s+(<-|-)\s*(\w+)\s*(->|-)\s+(\S+)$""") + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sankey.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sankey.kt new file mode 100644 index 00000000000..d048cb0e808 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sankey.kt @@ -0,0 +1,153 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * Sankey engine. Nodes column by longest path from a source; links are filled bands sampled from a + * smoothstep curve so no curve primitive is needed in the mark model. + */ +internal class Sankey(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + val flows = mutableListOf() + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + val token = text.substringBefore(' ').lowercase() + if (token == "sankey-beta" || token == "sankey") continue + } + val cells = csv(text) + if (cells.size != 3) return Out.Err(Fault.Syntax, "sankey rows are source,target,value", line.at) + val value = Lex.num(cells[2]) ?: return Out.Err(Fault.Syntax, "sankey value must be a number", line.at) + if (value < 0) return Out.Err(Fault.Syntax, "sankey value must not be negative", line.at) + flows.add(Flow(cells[0], cells[1], value)) + if (flows.size > spec.limits.edges) return Out.Err(Fault.Limit, "sankey exceeds ${spec.limits.edges} links") + } + if (flows.isEmpty()) return Out.Err(Fault.Syntax, "sankey has no links", 1) + return marks(flows) + } + + /** Minimal CSV: double quotes may wrap a cell to protect commas. */ + private fun csv(text: String): List { + val out = mutableListOf() + val cell = StringBuilder() + var quote = false + for (char in text) { + when { + char == '"' -> quote = !quote + char == ',' && !quote -> { + out.add(cell.toString().trim()) + cell.setLength(0) + } + else -> cell.append(char) + } + } + out.add(cell.toString().trim()) + return out + } + + private suspend fun marks(flows: List): Out.Ok { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val nodes = linkedMapOf() + for (flow in flows) { + nodes.getOrPut(flow.from) { Node(flow.from, nodes.size) } + nodes.getOrPut(flow.to) { Node(flow.to, nodes.size) } + } + if (nodes.size > spec.limits.nodes) return Out.Ok(sheet.scene(Type.Sankey)) + // Longest-path depth; passes converge for a DAG, and the node-count bound tames cycles. + var pass = 0 + while (pass++ < nodes.size) { + var moved = false + for (flow in flows) { + val from = nodes.getValue(flow.from) + val to = nodes.getValue(flow.to) + if (to.depth < from.depth + 1 && from.depth + 1 < nodes.size) { + to.depth = from.depth + 1 + moved = true + } + } + if (!moved) break + } + coroutineContext.ensureActive() + for (flow in flows) { + nodes.getValue(flow.from).out += flow.value + nodes.getValue(flow.to).into += flow.value + } + val cols = nodes.values.groupBy { it.depth }.toSortedMap() + val scale = high * 10 / (cols.values.maxOf { col -> col.sumOf { it.size() } }) + val stride = high * 12 + val bar = pad * 1.5 + for ((depth, col) in cols) { + var y = 0.0 + for (node in col) { + node.rect = Rect(depth * stride, y, bar, node.size() * scale) + y += node.size() * scale + high * 1.5 + } + } + for (flow in flows) { + coroutineContext.ensureActive() + val from = nodes.getValue(flow.from) + val to = nodes.getValue(flow.to) + sheet.add(Mark.Poly(band(from, to, flow.value, scale), null, null, tone = from.index, soft = true)) + } + for (node in nodes.values) { + val rect = node.rect + sheet.add(Mark.Box(rect, 0.0, null, null, tone = node.index)) + val last = node.out <= 0.0 + val at = if (last) Pt(rect.x - pad, rect.y + rect.h / 2) else Pt(rect.x + rect.w + pad, rect.y + rect.h / 2) + sheet.add(Mark.Text(node.id, at, if (last) Anchor.Right else Anchor.Left, Role.Text)) + } + return Out.Ok(sheet.scene(Type.Sankey)) + } + + /** Band polygon: smoothstep top edge out, straight caps, smoothstep bottom edge back. */ + private fun band(from: Node, to: Node, value: Double, scale: Double): List { + val tall = value * scale + val a = Pt(from.rect.x + from.rect.w, from.rect.y + from.sent * scale) + val b = Pt(to.rect.x, to.rect.y + to.got * scale) + from.sent += value + to.got += value + val top = curve(a, b) + val bottom = curve(Pt(a.x, a.y + tall), Pt(b.x, b.y + tall)).reversed() + return top + bottom + } + + private fun curve(a: Pt, b: Pt): List = List(SAMPLES + 1) { idx -> + val t = idx.toDouble() / SAMPLES + val ease = t * t * (3 - 2 * t) + Pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * ease) + } + + private data class Flow(val from: String, val to: String, val value: Double) + + private class Node(val id: String, val index: Int) { + var depth = 0 + var into = 0.0 + var out = 0.0 + var sent = 0.0 + var got = 0.0 + var rect = Rect(0.0, 0.0, 0.0, 0.0) + fun size() = maxOf(into, out, 0.1) + } + + private companion object { + const val SAMPLES = 16 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Scopes.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Scopes.kt new file mode 100644 index 00000000000..99134278e23 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Scopes.kt @@ -0,0 +1,51 @@ +package ai.kilocode.client.ui.diagram.mermaid + +/** + * Nested-scope bookkeeping for engines with composite blocks (state composites, C4 boundaries). + * + * A composite is also a layout node of its parent scope, so an edge that crosses scope boundaries is + * re-anchored at the lowest common ancestor: each endpoint becomes either the node itself or the + * composite that contains it there. + */ +internal class Scopes { + private val parents = linkedMapOf() + private val owner = linkedMapOf() + + fun open(id: String, parent: String) { + parents[id] = parent + claim(id, parent) + } + + fun claim(node: String, scope: String) { + if (!owner.containsKey(node)) owner[node] = scope + } + + fun has(node: String) = owner.containsKey(node) + + fun resolve(from: String, to: String): Hop { + val fp = path(owner[from] ?: ROOT) + val tp = path(owner[to] ?: ROOT) + var common = 0 + while (common < fp.size && common < tp.size && fp[common] == tp[common]) common++ + val lca = fp[common - 1] + val a = if (common < fp.size) fp[common] else from + val b = if (common < tp.size) tp[common] else to + return Hop(lca, a, b) + } + + private fun path(scope: String): List { + val out = ArrayDeque() + var cur = scope + while (true) { + out.addFirst(cur) + if (cur == ROOT) return out.toList() + cur = parents[cur] ?: ROOT + } + } + + companion object { + const val ROOT = "" + } +} + +internal data class Hop(val scope: String, val from: String, val to: String) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/SeqLayout.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/SeqLayout.kt index dccb1f076c9..b117f69c8aa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/SeqLayout.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/SeqLayout.kt @@ -239,6 +239,7 @@ internal class SeqLayout(private val measure: Measure, private val spec: Spec) { is Mark.Box -> corners(mark.rect) is Mark.Oval -> corners(mark.rect) is Mark.Poly -> mark.points + is Mark.Sector -> listOf(Pt(mark.at.x - mark.r, mark.at.y - mark.r), Pt(mark.at.x + mark.r, mark.at.y + mark.r)) is Mark.Edge -> mark.points is Mark.Text -> span(mark) is Mark.Group -> mark.marks.flatMap(::pts) @@ -263,6 +264,7 @@ internal class SeqLayout(private val measure: Measure, private val spec: Spec) { is Mark.Box -> mark.copy(rect = move(mark.rect, dx, dy)) is Mark.Oval -> mark.copy(rect = move(mark.rect, dx, dy)) is Mark.Poly -> mark.copy(points = mark.points.map { move(it, dx, dy) }) + is Mark.Sector -> mark.copy(at = move(mark.at, dx, dy)) is Mark.Edge -> mark.copy(points = mark.points.map { move(it, dx, dy) }) is Mark.Text -> mark.copy(at = move(mark.at, dx, dy)) is Mark.Group -> mark.copy(marks = mark.marks.map { move(it, dx, dy) }) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sheet.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sheet.kt new file mode 100644 index 00000000000..7aa86e5b8e7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sheet.kt @@ -0,0 +1,135 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Size +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type + +/** + * Mark collector shared by the chart engines layered on top of [Flow] and [Seq]. + * + * Engines draw in absolute coordinates, possibly negative; [scene] normalizes the origin and reports + * a size that covers every mark including text glyph extents, mirroring the [SeqLayout] behaviour so + * renderers can rely on [Scene.size] for scroll and clip bounds. + */ +internal class Sheet(private val measure: Measure, private val spec: Spec) { + val marks = mutableListOf() + val high = measure.height(spec.font) + val pad = spec.metrics.pad + val gap = spec.metrics.gap + val step = spec.metrics.rank + val arc = spec.metrics.arc + val font = spec.font + private val bold = spec.font.copy(bold = true) + + fun add(mark: Mark) { + marks.add(mark) + } + + fun width(text: String, bold: Boolean = false) = measure.width(text, if (bold) this.bold else font) + + fun widest(lines: List, bold: Boolean = false) = lines.maxOfOrNull { width(it, bold) } ?: 0.0 + + /** Centered multi-line text block below [top]; returns the height consumed. */ + fun texts(lines: List, cx: Double, top: Double, role: Role, bold: Boolean = false): Double { + lines.forEachIndexed { idx, text -> + marks.add(Mark.Text(text, Pt(cx, top + high * (idx + 0.5)), Anchor.Center, role, bold)) + } + return high * lines.size + } + + /** Centered multi-line text block in the middle of [rect]. */ + fun label(lines: List, rect: Rect, role: Role, bold: Boolean = false) { + texts(lines, rect.x + rect.w / 2, rect.y + (rect.h - high * lines.size) / 2, role, bold) + } + + /** + * Truncates [text] with an ellipsis until it fits into [room]. The first guess is proportional so + * a long line does not re-measure once per character; empty when not even one character fits. + */ + fun fit(text: String, room: Double): String { + val full = width(text) + if (full <= room) return text + if (room <= 0.0) return "" + var keep = minOf(text.length - 1, (text.length * room / full).toInt() + 1) + while (keep > 0) { + val cut = text.substring(0, keep).trimEnd() + "…" + if (width(cut) <= room) return cut + keep-- + } + return "" + } + + /** Greedy word wrap by measured width; a single overlong word stays on its own line. */ + fun wrap(text: String, room: Double): List { + val words = text.split(' ').filter { it.isNotEmpty() } + if (words.isEmpty()) return emptyList() + val out = mutableListOf() + var line = words.first() + for (word in words.drop(1)) { + val next = "$line $word" + if (width(next) <= room) { + line = next + continue + } + out.add(line) + line = word + } + out.add(line) + return out + } + + fun scene(type: Type): Scene { + val pts = marks.flatMap(::pts) + val dx = -minOf(0.0, pts.minOfOrNull { it.x } ?: 0.0) + pad + val dy = -minOf(0.0, pts.minOfOrNull { it.y } ?: 0.0) + pad + val moved = marks.map { move(it, dx, dy) } + val ends = pts.map { Pt(it.x + dx, it.y + dy) } + val size = Size((ends.maxOfOrNull { it.x } ?: 0.0) + pad, (ends.maxOfOrNull { it.y } ?: 0.0) + pad) + return Scene(type, moved, size) + } + + private fun pts(mark: Mark): List = when (mark) { + is Mark.Box -> corners(mark.rect) + is Mark.Oval -> corners(mark.rect) + is Mark.Poly -> mark.points + is Mark.Sector -> listOf(Pt(mark.at.x - mark.r, mark.at.y - mark.r), Pt(mark.at.x + mark.r, mark.at.y + mark.r)) + is Mark.Edge -> mark.points + is Mark.Text -> span(mark) + is Mark.Group -> mark.marks.flatMap(::pts) + } + + private fun span(mark: Mark.Text): List { + val room = width(mark.text, mark.bold) + return when (mark.anchor) { + Anchor.Left, Anchor.TopLeft, Anchor.BottomLeft -> listOf(mark.at, Pt(mark.at.x + room, mark.at.y)) + Anchor.Right, Anchor.TopRight, Anchor.BottomRight -> listOf(Pt(mark.at.x - room, mark.at.y), mark.at) + else -> listOf(Pt(mark.at.x - room / 2, mark.at.y), Pt(mark.at.x + room / 2, mark.at.y)) + } + } + + private fun corners(rect: Rect) = listOf(Pt(rect.x, rect.y), Pt(rect.x + rect.w, rect.y + rect.h)) + + private fun move(mark: Mark, dx: Double, dy: Double) = moved(mark, dx, dy) +} + +/** Shifts a mark; used by engines that lay out nested scopes locally and then offset them. */ +internal fun moved(mark: Mark, dx: Double, dy: Double): Mark = when (mark) { + is Mark.Box -> mark.copy(rect = moved(mark.rect, dx, dy)) + is Mark.Oval -> mark.copy(rect = moved(mark.rect, dx, dy)) + is Mark.Poly -> mark.copy(points = mark.points.map { moved(it, dx, dy) }) + is Mark.Sector -> mark.copy(at = moved(mark.at, dx, dy)) + is Mark.Edge -> mark.copy(points = mark.points.map { moved(it, dx, dy) }) + is Mark.Text -> mark.copy(at = moved(mark.at, dx, dy)) + is Mark.Group -> mark.copy(marks = mark.marks.map { moved(it, dx, dy) }) +} + +internal fun moved(rect: Rect, dx: Double, dy: Double) = Rect(rect.x + dx, rect.y + dy, rect.w, rect.h) + +internal fun moved(pt: Pt, dx: Double, dy: Double) = Pt(pt.x + dx, pt.y + dy) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/StateDg.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/StateDg.kt new file mode 100644 index 00000000000..a1b84d13afe --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/StateDg.kt @@ -0,0 +1,223 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Head +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Size +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * State diagram engine. Composite states lay out recursively: an inner scope becomes one node of its + * parent scope, and transitions crossing a composite boundary re-anchor at the composite frame. + */ +internal class StateDg(private val measure: Measure, private val spec: Spec) { + private val scopes = Scopes() + private val labels = linkedMapOf>() + private val kinds = linkedMapOf() + private val members = linkedMapOf>() + private val moves = linkedMapOf>() + private val stack = ArrayDeque() + private var noting = false + private var count = 0 + + suspend fun draw(clean: Clean): Out { + members[Scopes.ROOT] = mutableListOf() + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + val token = text.substringBefore(' ').lowercase() + if (token == "statediagram" || token == "statediagram-v2") continue + } + if (noting) { + if (text.lowercase() == "end note") noting = false + continue + } + val err = stmt(text) + if (err != null) return Out.Err(Fault.Syntax, err, line.at) + if (count > spec.limits.nodes) return Out.Err(Fault.Limit, "state diagram exceeds ${spec.limits.nodes} states") + if (moves.values.sumOf { it.size } > spec.limits.edges) { + return Out.Err(Fault.Limit, "state diagram exceeds ${spec.limits.edges} transitions") + } + } + if (stack.isNotEmpty()) return Out.Err(Fault.Syntax, "state block is missing a closing brace", clean.lines.lastOrNull()?.at ?: 1) + if (count == 0) return Out.Err(Fault.Syntax, "state diagram has no states", 1) + val sheet = Sheet(measure, spec) + val part = scope(Scopes.ROOT, sheet) + for (mark in part.marks) sheet.add(mark) + return Out.Ok(sheet.scene(Type.State)) + } + + private fun stmt(text: String): String? { + val token = text.substringBefore(' ').lowercase() + if (token in SKIP) return null + if (token == "note") { + if (!text.contains(':')) noting = true + return null + } + if (text == "}") { + if (stack.isEmpty()) return "unexpected closing brace" + stack.removeLast() + return null + } + if (token == "state") return define(text) + val arrow = text.indexOf("-->") + if (arrow < 0) return null + val from = claim(text.substring(0, arrow).trim(), source = true) ?: return "transition needs a source state" + val rest = text.substring(arrow + 3) + val colon = rest.indexOf(':') + val target = (if (colon < 0) rest else rest.substring(0, colon)).trim() + val to = claim(target, source = false) ?: return "transition needs a target state" + val label = if (colon < 0) emptyList() else Source.label(rest.substring(colon + 1)) + val hop = scopes.resolve(from, to) + moves.getOrPut(hop.scope) { mutableListOf() }.add(Move(hop.from, hop.to, label)) + return null + } + + /** `state "long name" as id`, `state Name {`, or a bare `state Name`. */ + private fun define(text: String): String? { + val rest = text.substringAfter(' ', "").trim() + if (rest.isEmpty()) return "state needs a name" + val opens = rest.endsWith("{") + val body = rest.removeSuffix("{").trim() + val alias = AS.find(body) + if (alias != null) { + val id = body.substring(alias.range.last + 1).trim() + if (id.isEmpty()) return "state alias needs an id" + claim(id, source = true) + labels[id] = Source.label(body.substring(0, alias.range.first)) + return null + } + if (body.isEmpty()) return "state needs a name" + if (!opens) { + claim(body, source = true) + return null + } + val here = stack.lastOrNull() ?: Scopes.ROOT + if (kinds[body] == null) { + count++ + members.getValue(here).add(body) + } + kinds[body] = Kind.Composite + labels[body] = Source.label(body) + scopes.open(body, here) + members.getOrPut(body) { mutableListOf() } + stack.addLast(body) + return null + } + + /** Registers a plain state on first mention; `[*]` maps to a per-scope start or end marker. */ + private fun claim(name: String, source: Boolean): String? { + val text = name.trim() + if (text.isEmpty()) return null + val here = stack.lastOrNull() ?: Scopes.ROOT + val id = if (text == "[*]") "$here/${if (source) "#start" else "#end"}" else text + if (kinds[id] == null && !scopes.has(id)) { + count++ + kinds[id] = when { + id.endsWith("#start") -> Kind.Start + id.endsWith("#end") -> Kind.End + else -> Kind.Plain + } + labels[id] = Source.label(Source.unquote(id)) + scopes.claim(id, here) + members.getValue(here).add(id) + } + return id + } + + /** Lays out one scope; returned marks are in local coordinates with the origin at the top left. */ + private suspend fun scope(id: String, sheet: Sheet): Part { + coroutineContext.ensureActive() + val pad = sheet.pad + val high = sheet.high + val dot = pad * 1.5 + val parts = linkedMapOf() + val sizes = linkedMapOf() + for (node in members.getValue(id)) { + when (kinds[node]) { + Kind.Composite -> { + val inner = scope(node, sheet) + val title = labels.getValue(node) + val wide = maxOf(inner.size.w + pad * 2, sheet.widest(title, bold = true) + pad * 2) + parts[node] = inner + sizes[node] = Size(wide, inner.size.h + high * title.size + pad * 3) + } + Kind.Start, Kind.End -> sizes[node] = Size(dot, dot) + else -> { + val label = labels.getValue(node) + sizes[node] = Size(sheet.widest(label) + pad * 2, high * label.size + pad * 2) + } + } + } + val rails = moves[id].orEmpty().map { Rail(it.from, it.to) } + val plan = Layered(spec).run(sizes, rails) + val marks = mutableListOf() + for (move in moves[id].orEmpty()) { + val ends = joint(plan.rects.getValue(move.from), plan.rects.getValue(move.to)) + marks.add(Mark.Edge(listOf(ends.first, ends.second), Role.Line, head = Head.Arrow)) + if (move.label.isNotEmpty()) { + val mid = Pt((ends.first.x + ends.second.x) / 2, (ends.first.y + ends.second.y) / 2) + move.label.forEachIndexed { idx, label -> + marks.add(Mark.Text(label, Pt(mid.x + pad, mid.y - high * (move.label.size - idx - 0.5)), Anchor.Left, Role.Muted)) + } + } + } + for (node in members.getValue(id)) { + val rect = plan.rects.getValue(node) + when (kinds[node]) { + Kind.Composite -> { + val title = labels.getValue(node) + marks.add(Mark.Box(rect, spec.metrics.arc, null, Role.Border)) + var top = rect.y + pad + title.forEachIndexed { idx, text -> + marks.add(Mark.Text(text, Pt(rect.x + rect.w / 2, top + high * (idx + 0.5)), Anchor.Center, Role.Text, true)) + } + top += high * title.size + pad + marks.add(Mark.Edge(listOf(Pt(rect.x, top), Pt(rect.x + rect.w, top)), Role.Border)) + val inner = parts.getValue(node) + val dx = rect.x + (rect.w - inner.size.w) / 2 + for (mark in inner.marks) marks.add(moved(mark, dx, top + pad)) + } + Kind.Start -> marks.add(Mark.Oval(rect, Role.Line, null)) + Kind.End -> { + marks.add(Mark.Oval(rect, null, Role.Line)) + val inset = rect.w / 4 + marks.add(Mark.Oval(Rect(rect.x + inset, rect.y + inset, rect.w - inset * 2, rect.h - inset * 2), Role.Line, null)) + } + else -> { + marks.add(Mark.Box(rect, spec.metrics.arc * 2, Role.Surface, Role.Border)) + val label = labels.getValue(node) + label.forEachIndexed { idx, text -> + val top = rect.y + (rect.h - high * label.size) / 2 + marks.add(Mark.Text(text, Pt(rect.x + rect.w / 2, top + high * (idx + 0.5)), Anchor.Center, Role.Text)) + } + } + } + } + return Part(marks, plan.size) + } + + private data class Part(val marks: List, val size: Size) + + private data class Move(val from: String, val to: String, val label: List) + + private enum class Kind { Plain, Start, End, Composite } + + private companion object { + val SKIP = setOf("direction", "classdef", "class", "style", "accdescr", "acctitle", "hide", "%%") + val AS = Regex("""\s+as\s+""", RegexOption.IGNORE_CASE) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Timeline.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Timeline.kt new file mode 100644 index 00000000000..4908c774718 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Timeline.kt @@ -0,0 +1,87 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** Timeline engine. Periods form columns; `: event` continuation lines stack under the last period. */ +internal class Timeline(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + var title = "" + val periods = mutableListOf() + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.lowercase() == "timeline") continue + } + val token = text.substringBefore(' ').lowercase() + if (token == "title") { + title = text.substringAfter(' ', "").trim() + continue + } + if (token == "section" || token == "accdescr" || token == "acctitle") continue + if (text.startsWith(":")) { + val period = periods.lastOrNull() ?: return Out.Err(Fault.Syntax, "event before any period", line.at) + for (event in text.split(':').map { it.trim() }.filter { it.isNotEmpty() }) period.events.add(event) + continue + } + val colon = text.indexOf(':') + val period = Period((if (colon < 0) text else text.substring(0, colon)).trim(), mutableListOf()) + if (colon >= 0) { + for (event in text.substring(colon + 1).split(':').map { it.trim() }.filter { it.isNotEmpty() }) { + period.events.add(event) + } + } + periods.add(period) + if (periods.size > spec.limits.nodes) return Out.Err(Fault.Limit, "timeline exceeds ${spec.limits.nodes} periods") + } + if (periods.isEmpty()) return Out.Err(Fault.Syntax, "timeline has no periods", 1) + return Out.Ok(marks(title, periods)) + } + + private fun marks(title: String, periods: List): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val widths = periods.map { + maxOf(sheet.width(it.label, bold = true), it.events.maxOfOrNull { event -> sheet.width(event) } ?: 0.0) + pad * 4 + } + var top = 0.0 + if (title.isNotEmpty()) { + val wide = widths.sum() + sheet.gap * (periods.size - 1) + top = sheet.texts(listOf(title), wide / 2, 0.0, Role.Text, bold = true) + pad + } + var x = 0.0 + periods.forEachIndexed { idx, period -> + val wide = widths[idx] + val head = Rect(x, top, wide, high + pad * 2) + sheet.add(Mark.Box(head, spec.metrics.arc, null, Role.Border, tone = idx, soft = true)) + sheet.label(listOf(period.label), head, Role.Text, bold = true) + var y = head.y + head.h + pad + for (event in period.events) { + val card = Rect(x + pad, y, wide - pad * 2, high + pad) + sheet.add(Mark.Edge(listOf(Pt(x + wide / 2, y - pad), Pt(x + wide / 2, y)), Role.Muted)) + sheet.add(Mark.Box(card, spec.metrics.arc, Role.Note, Role.Border)) + sheet.label(listOf(event), card, Role.Text) + y += card.h + pad + } + x += wide + sheet.gap + } + return sheet.scene(Type.Timeline) + } + + private data class Period(val label: String, val events: MutableList) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Treemap.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Treemap.kt new file mode 100644 index 00000000000..54b72426d74 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Treemap.kt @@ -0,0 +1,110 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** + * Treemap engine. Alternating slice/dice layout: deterministic and simple, at the cost of the + * squarified aspect ratios mermaid produces. Leaf tones follow the top-level branch. + */ +internal class Treemap(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + val roots = mutableListOf() + val stack = ArrayDeque>() + var first = true + var count = 0 + for (line in clean.lines) { + coroutineContext.ensureActive() + if (line.text.isBlank()) continue + val depth = line.text.takeWhile { it == ' ' }.length + val text = line.text.trim() + if (first) { + first = false + val token = text.substringBefore(' ').lowercase() + if (token == "treemap-beta" || token == "treemap") continue + } + if (text.substringBefore(' ').lowercase() in setOf("title", "accdescr", "acctitle")) continue + val colon = split(text) + val label = Source.unquote((if (colon < 0) text else text.substring(0, colon)).trim()) + val value = if (colon < 0) null else Lex.num(text.substring(colon + 1)) + if (colon >= 0 && value == null) return Out.Err(Fault.Syntax, "treemap value must be a number", line.at) + val node = Node(label, value ?: 0.0, mutableListOf()) + count++ + if (count > spec.limits.nodes) return Out.Err(Fault.Limit, "treemap exceeds ${spec.limits.nodes} nodes") + while (stack.isNotEmpty() && stack.last().first >= depth) stack.removeLast() + val parent = stack.lastOrNull()?.second + if (parent == null) roots.add(node) else parent.kids.add(node) + stack.addLast(depth to node) + } + if (roots.isEmpty()) return Out.Err(Fault.Syntax, "treemap has no nodes", 1) + for (root in roots) sum(root) + if (roots.sumOf { it.value } <= 0.0) return Out.Err(Fault.Syntax, "treemap values sum to zero", 1) + return Out.Ok(marks(roots)) + } + + /** The colon separating a leaf value sits after the quoted name, outside quotes. */ + private fun split(text: String): Int { + val mask = Source.opens(text) + for (idx in text.indices) if (text[idx] == ':' && mask[idx]) return idx + return -1 + } + + private fun sum(node: Node): Double { + if (node.kids.isEmpty()) return node.value + node.value = node.kids.sumOf { sum(it) } + return node.value + } + + private suspend fun marks(roots: List): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val area = Rect(0.0, 0.0, high * 36, high * 24) + place(sheet, roots, area, 0, -1) + return sheet.scene(Type.Treemap) + } + + private suspend fun place(sheet: Sheet, nodes: List, rect: Rect, depth: Int, tone: Int) { + coroutineContext.ensureActive() + val pad = sheet.pad + val high = sheet.high + val total = nodes.sumOf { it.value } + if (total <= 0.0) return + var offset = 0.0 + nodes.forEachIndexed { idx, node -> + val frac = node.value / total + val cell = if (depth % 2 == 0) { + Rect(rect.x + offset, rect.y, rect.w * frac, rect.h).also { offset += rect.w * frac } + } else { + Rect(rect.x, rect.y + offset, rect.w, rect.h * frac).also { offset += rect.h * frac } + } + val hue = if (tone < 0) idx else tone + if (node.kids.isEmpty()) { + sheet.add(Mark.Box(cell, 0.0, null, Role.Border, tone = hue, soft = true)) + val label = sheet.fit(node.label, cell.w - pad) + if (label.isNotEmpty() && cell.h >= high * 2) { + sheet.label(listOf(label, Axis.label(node.value)), cell, Role.Text) + } + return@forEachIndexed + } + sheet.add(Mark.Box(cell, 0.0, null, Role.Cluster)) + val title = sheet.fit(node.label, cell.w - pad) + if (title.isNotEmpty() && cell.h >= high * 2) { + sheet.texts(listOf(title), cell.x + cell.w / 2, cell.y + pad / 2, Role.Muted, bold = true) + } + val head = if (cell.h >= high * 2) high + pad else 0.0 + val inner = Rect(cell.x + pad / 2, cell.y + head, (cell.w - pad).coerceAtLeast(1.0), (cell.h - head - pad / 2).coerceAtLeast(1.0)) + place(sheet, node.kids, inner, depth + 1, hue) + } + } + + private data class Node(val label: String, var value: Double, val kids: MutableList) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/XyChart.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/XyChart.kt new file mode 100644 index 00000000000..9568a331f79 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/XyChart.kt @@ -0,0 +1,145 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.Anchor +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Measure +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Pt +import ai.kilocode.client.ui.diagram.Rect +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.Scene +import ai.kilocode.client.ui.diagram.Spec +import ai.kilocode.client.ui.diagram.Type +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive + +/** XY chart engine: category or numeric x axis, bar and line series with per-series tones. */ +internal class XyChart(private val measure: Measure, private val spec: Spec) { + suspend fun draw(clean: Clean): Out { + var title = "" + var xlabel = "" + var ylabel = "" + var cats = emptyList() + var min: Double? = null + var max: Double? = null + val series = mutableListOf() + var first = true + for (line in clean.lines) { + coroutineContext.ensureActive() + val text = line.text.trim() + if (text.isEmpty()) continue + if (first) { + first = false + if (text.substringBefore(' ').lowercase().startsWith("xychart")) continue + } + val token = text.substringBefore(' ').lowercase() + val rest = text.substringAfter(' ', "").trim() + when (token) { + "title" -> title = Source.unquote(rest) + "x-axis" -> { + val open = rest.indexOf('[') + if (open >= 0 && rest.endsWith("]")) { + xlabel = Source.unquote(rest.substring(0, open).trim()) + cats = Lex.args(rest.substring(open + 1, rest.length - 1)).map { Source.unquote(it) } + } else { + val range = RANGE.find(rest) + if (range != null) { + xlabel = Source.unquote(rest.substring(0, range.range.first).trim()) + cats = listOf(range.groupValues[1], range.groupValues[2]) + } else { + xlabel = Source.unquote(rest) + } + } + } + "y-axis" -> { + val range = RANGE.find(rest) + if (range != null) { + ylabel = Source.unquote(rest.substring(0, range.range.first).trim()) + min = range.groupValues[1].toDouble() + max = range.groupValues[2].toDouble() + } else { + ylabel = Source.unquote(rest) + } + } + "bar", "line" -> { + val open = text.indexOf('[') + if (open < 0 || !text.endsWith("]")) return Out.Err(Fault.Syntax, "$token needs [values]", line.at) + val values = Lex.args(text.substring(open + 1, text.length - 1)).map { + Lex.num(it) ?: return Out.Err(Fault.Syntax, "$token values must be numbers", line.at) + } + series.add(Run(token == "bar", values)) + if (series.size > spec.limits.nodes) return Out.Err(Fault.Limit, "chart exceeds ${spec.limits.nodes} series") + } + else -> Unit + } + } + if (series.isEmpty() || series.all { it.values.isEmpty() }) return Out.Err(Fault.Syntax, "chart has no data", 1) + val count = series.maxOf { it.values.size } + val labels = if (cats.size >= count) cats.take(count) else List(count) { idx -> cats.getOrNull(idx) ?: "${idx + 1}" } + val lo = min ?: minOf(0.0, series.minOf { run -> run.values.minOrNull() ?: 0.0 }) + val hi = max ?: series.maxOf { run -> run.values.maxOrNull() ?: 0.0 } + return Out.Ok(marks(title, xlabel, ylabel, labels, lo, hi, series)) + } + + private fun marks( + title: String, + xlabel: String, + ylabel: String, + cats: List, + lo: Double, + hi: Double, + series: List, + ): Scene { + val sheet = Sheet(measure, spec) + val high = sheet.high + val pad = sheet.pad + val ticks = Axis.ticks(lo, hi) + val floor = ticks.first() + val ceil = ticks.last() + val band = maxOf(cats.maxOf { sheet.width(it) } + pad * 2, high * 4) + val plot = Rect(0.0, 0.0, band * cats.size, high * 14) + fun y(value: Double) = plot.y + plot.h - (value - floor) / (ceil - floor) * plot.h + + var head = 0.0 + if (title.isNotEmpty()) head += sheet.texts(listOf(title), plot.w / 2, -high * 2 - pad, Role.Text, bold = true) + if (ylabel.isNotEmpty()) sheet.add(Mark.Text(ylabel, Pt(plot.x, plot.y - high), Anchor.BottomLeft, Role.Muted)) + for (tick in ticks) { + val at = y(tick) + sheet.add(Mark.Edge(listOf(Pt(plot.x, at), Pt(plot.x + plot.w, at)), Role.Cluster, dash = true)) + sheet.add(Mark.Text(Axis.label(tick), Pt(plot.x - pad, at), Anchor.Right, Role.Muted)) + } + val bars = series.count { it.bar } + var slot = 0 + for (run in series.filter { it.bar }) { + val wide = band * 0.7 / bars + run.values.forEachIndexed { idx, value -> + val cx = plot.x + band * idx + band * 0.15 + wide * slot + val tone = series.indexOf(run) + sheet.add(Mark.Box(Rect(cx, y(value), wide, plot.y + plot.h - y(value)), 0.0, null, Role.Border, tone = tone)) + } + slot++ + } + for (run in series.filter { !it.bar }) { + val tone = series.indexOf(run) + val points = run.values.mapIndexed { idx, value -> Pt(plot.x + band * (idx + 0.5), y(value)) } + sheet.add(Mark.Edge(points, Role.Line, thick = true, tone = tone)) + for (point in points) { + sheet.add(Mark.Oval(Rect(point.x - 3.0, point.y - 3.0, 6.0, 6.0), null, null, tone = tone)) + } + } + sheet.add(Mark.Edge(listOf(Pt(plot.x, plot.y), Pt(plot.x, plot.y + plot.h)), Role.Border)) + sheet.add(Mark.Edge(listOf(Pt(plot.x, plot.y + plot.h), Pt(plot.x + plot.w, plot.y + plot.h)), Role.Border)) + cats.forEachIndexed { idx, cat -> + sheet.texts(listOf(cat), plot.x + band * (idx + 0.5), plot.y + plot.h + pad, Role.Muted) + } + if (xlabel.isNotEmpty()) sheet.texts(listOf(xlabel), plot.x + plot.w / 2, plot.y + plot.h + pad + high, Role.Muted) + return sheet.scene(Type.XyChart) + } + + private data class Run(val bar: Boolean, val values: List) + + private companion object { + val RANGE = Regex("""(-?[\d.]+)\s*-->\s*(-?[\d.]+)\s*$""") + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramTheme.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramTheme.kt index d5ff58b2fa9..4d41642c329 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramTheme.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ui/DiagramTheme.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.ui.diagram.Palette import ai.kilocode.client.ui.diagram.Spec import ai.kilocode.client.ui.md.MdCommon import ai.kilocode.client.ui.md.MdStyle +import java.awt.Color internal fun diagramPalette(style: SessionEditorStyle, opts: MdStyle = MdCommon.defaults(style)) = Palette( surface = UiStyle.Colors.contrast(opts.preBg, 8), @@ -19,6 +20,19 @@ internal fun diagramPalette(style: SessionEditorStyle, opts: MdStyle = MdCommon. line = opts.quoteFg, font = style.editorFont, bold = style.boldEditorFont, + tones = diagramTones(opts.linkColor), ) +/** + * Categorical chart colors derived from the theme accent by rotating hue at a golden-angle-ish step, + * so every theme gets a distinct but related series without hardcoding raw colors. Saturation and + * brightness are clamped into a band that stays readable on both light and dark surfaces. + */ +internal fun diagramTones(accent: Color): List { + val hsb = Color.RGBtoHSB(accent.red, accent.green, accent.blue, null) + val sat = hsb[1].coerceIn(0.45f, 0.7f) + val bri = hsb[2].coerceIn(0.55f, 0.85f) + return List(8) { idx -> Color.getHSBColor(hsb[0] + idx * 0.118f, sat, bri) } +} + internal fun diagramSpec(style: SessionEditorStyle) = Spec(FontSpec(style.editorFamily, style.editorSize)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/CancelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/CancelTest.kt index e7a35f2de3b..81f717f9385 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/CancelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/CancelTest.kt @@ -37,6 +37,20 @@ class CancelTest { assertFailsWith { cancel(source) } } + @Test + fun `class layout stops when the job is cancelled`() { + val source = "classDiagram\n" + (1..40).joinToString("\n") { " C$it <|-- C${it + 1}" } + + assertFailsWith { cancel(source) } + } + + @Test + fun `treemap layout stops when the job is cancelled`() { + val source = "treemap-beta\n\"root\"\n" + (1..40).joinToString("\n") { " \"leaf$it\": $it" } + + assertFailsWith { cancel(source) } + } + /** Parsing runs before any measurement, so its cancellation checks need their own proof. */ @Test fun `parsing stops before layout when the job is cancelled`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ConformanceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ConformanceTest.kt index 6f6af6c29bc..bc2ee5d8d55 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ConformanceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ConformanceTest.kt @@ -16,7 +16,7 @@ class ConformanceTest { @Test fun `every corpus diagram produces a finite scene`() { - for (name in CORPUS) { + for (name in CORPUS.keys) { val out = runBlocking { engine.draw(read(name), spec()) } val scene = scene(out) @@ -28,9 +28,8 @@ class ConformanceTest { @Test fun `corpus diagrams report the detected type`() { - for (name in CORPUS) { + for ((name, expected) in CORPUS) { val out = runBlocking { engine.draw(read(name), spec()) } - val expected = if (name.startsWith("flow")) Type.Flowchart else Type.Sequence assertEquals(expected, scene(out).type, "$name resolved the wrong type") } @@ -38,7 +37,7 @@ class ConformanceTest { @Test fun `rendering is deterministic across runs`() { - for (name in CORPUS) { + for (name in CORPUS.keys) { val first = runBlocking { engine.draw(read(name), spec()) } val second = runBlocking { Mermaid(FakeMeasure()).draw(read(name), spec()) } @@ -48,7 +47,7 @@ class ConformanceTest { @Test fun `text marks never lose their content`() { - for (name in CORPUS) { + for (name in CORPUS.keys) { val out = runBlocking { engine.draw(read(name), spec()) } val texts = flatten(scene(out).marks).filterIsInstance() @@ -64,15 +63,35 @@ class ConformanceTest { } internal companion object { - val CORPUS = listOf( - "flow-basic", - "flow-shapes", - "flow-subgraph", - "flow-cycle", - "flow-long", - "seq-basic", - "seq-blocks", - "seq-notes", + val CORPUS = mapOf( + "flow-basic" to Type.Flowchart, + "flow-shapes" to Type.Flowchart, + "flow-subgraph" to Type.Flowchart, + "flow-cycle" to Type.Flowchart, + "flow-long" to Type.Flowchart, + "seq-basic" to Type.Sequence, + "seq-blocks" to Type.Sequence, + "seq-notes" to Type.Sequence, + "class-basic" to Type.Class, + "state-basic" to Type.State, + "er-basic" to Type.Er, + "journey-basic" to Type.Journey, + "gantt-basic" to Type.Gantt, + "pie-basic" to Type.Pie, + "quadrant-basic" to Type.Quadrant, + "requirement-basic" to Type.Requirement, + "git-basic" to Type.Git, + "c4-basic" to Type.C4, + "mindmap-basic" to Type.Mindmap, + "timeline-basic" to Type.Timeline, + "sankey-basic" to Type.Sankey, + "xychart-basic" to Type.XyChart, + "block-basic" to Type.Block, + "packet-basic" to Type.Packet, + "kanban-basic" to Type.Kanban, + "architecture-basic" to Type.Architecture, + "radar-basic" to Type.Radar, + "treemap-basic" to Type.Treemap, ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/DiagramAsserts.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/DiagramAsserts.kt index 449969bed0a..7b82936e404 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/DiagramAsserts.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/DiagramAsserts.kt @@ -112,6 +112,7 @@ private fun points(mark: Mark, measure: Measure, spec: Spec): List = when (m is Mark.Box -> corners(mark.rect) is Mark.Oval -> corners(mark.rect) is Mark.Poly -> mark.points + is Mark.Sector -> listOf(Pt(mark.at.x - mark.r, mark.at.y - mark.r), Pt(mark.at.x + mark.r, mark.at.y + mark.r)) is Mark.Edge -> mark.points is Mark.Text -> span(mark, measure, spec) is Mark.Group -> emptyList() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ErrorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ErrorTest.kt index 65f3ac9571d..07cb5513451 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ErrorTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ErrorTest.kt @@ -10,7 +10,7 @@ class ErrorTest { @Test fun `unsupported diagram types are rejected without parsing`() { - val out = draw("pie title Pets\n \"Dogs\" : 40") + val out = draw("zenuml\n A->B: hi") assertEquals(Fault.Unsupported, err(out).fault) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/InvariantTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/InvariantTest.kt index 4980ae5a389..d3f9b2cab4e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/InvariantTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/InvariantTest.kt @@ -25,7 +25,7 @@ class InvariantTest { private fun check(measure: Measure) { val engine = Mermaid(measure) val spec = spec(size = 12) - for (name in ConformanceTest.CORPUS) { + for (name in ConformanceTest.CORPUS.keys) { val out = runBlocking { engine.draw(read(name), spec) } val scene = scene(out) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ScenePainterTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ScenePainterTest.kt index 77189e9aff3..920fa203c4a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ScenePainterTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/ScenePainterTest.kt @@ -19,6 +19,7 @@ class ScenePainterTest { Mark.Poly(listOf(Pt(15.0, 50.0), Pt(35.0, 45.0), Pt(45.0, 65.0)), Role.Cluster, Role.Border), Mark.Edge(listOf(Pt(70.0, 50.0), Pt(110.0, 50.0)), Role.Accent, dash = true, thick = true, head = Head.Arrow), Mark.Group("g", listOf(Mark.Text("T", Pt(90.0, 25.0), Anchor.Center, Role.Text, bold = true))), + Mark.Sector(Pt(115.0, 65.0), 12.0, 90.0, -270.0, null, Role.Border, tone = 0), ), Size(130.0, 80.0), ) @@ -30,9 +31,43 @@ class ScenePainterTest { assertNotEquals(0, img.rgb(60, 20)) assertNotEquals(0, img.rgb(25, 55)) assertNotEquals(0, img.rgb(105, 50)) + assertNotEquals(0, img.rgb(112, 70)) assertTrue(nonEmpty(img) > 300) } + /** Every head variant paints something at the arrow tip without throwing. */ + @Test + fun `test painter renders every head variant`() { + for (head in Head.entries.filter { it != Head.None }) { + val scene = Scene( + Type.Class, + listOf(Mark.Edge(listOf(Pt(10.0, 20.0), Pt(50.0, 20.0)), Role.Line, head = head)), + Size(70.0, 40.0), + ) + val img = BufferedImage(70, 40, BufferedImage.TYPE_INT_ARGB) + + ScenePainter.paint(img.createGraphics(), scene, palette()) + + assertTrue(nonEmpty(img) > 10, "head $head painted nothing") + } + } + + /** Soft tones must fill translucently so overlapping chart bands stay readable. */ + @Test + fun `test soft tone fills are translucent`() { + val scene = Scene( + Type.Radar, + listOf(Mark.Poly(listOf(Pt(5.0, 5.0), Pt(60.0, 5.0), Pt(60.0, 35.0), Pt(5.0, 35.0)), null, null, tone = 0, soft = true)), + Size(70.0, 40.0), + ) + val img = BufferedImage(70, 40, BufferedImage.TYPE_INT_ARGB) + + ScenePainter.paint(img.createGraphics(), scene, palette()) + + val alpha = img.rgb(30, 20) + assertTrue(alpha in 1..254, "expected a translucent fill but alpha was $alpha") + } + @Test fun `test registry chooses scene painter`() { val scene = Scene(Type.Sequence, emptyList(), Size(1.0, 2.0)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/SerializeTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/SerializeTest.kt index 325d76a3613..bf970151871 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/SerializeTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/SerializeTest.kt @@ -40,8 +40,10 @@ class SerializeTest { Type.Flowchart, listOf( Mark.Box(Rect(1.0, 2.0, 30.0, 40.0), 4.0, Role.Surface, Role.Border, dash = true), + Mark.Box(Rect(1.0, 2.0, 3.0, 4.0), 0.0, null, null, tone = 3, soft = true), Mark.Oval(Rect(5.0, 6.0, 10.0, 10.0), Role.Note, null), Mark.Poly(listOf(Pt(0.0, 0.0), Pt(4.0, 0.0), Pt(2.0, 6.0)), Role.Surface, Role.Border), + Mark.Sector(Pt(10.0, 10.0), 8.0, 90.0, -120.0, null, Role.Border, tone = 1), Mark.Edge( listOf(Pt(0.0, 0.0), Pt(9.0, 9.0)), Role.Line, @@ -50,6 +52,7 @@ class SerializeTest { head = Head.Arrow, tail = Head.Dot, ), + Mark.Edge(listOf(Pt(0.0, 0.0), Pt(1.0, 1.0)), Role.Line, head = Head.Crow, tail = Head.Triangle, tone = 2), Mark.Text("hello", Pt(3.0, 4.0), Anchor.Center, Role.Text, bold = true), Mark.Group("cluster", listOf(Mark.Box(Rect(0.0, 0.0, 2.0, 2.0), 0.0, null, Role.Cluster))), ), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/TypeTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/TypeTest.kt index a0a1c16af0b..ab31af345d5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/TypeTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/TypeTest.kt @@ -32,6 +32,24 @@ class TypeTest { assertEquals(Type.Er, Type.of("erDiagram\n A ||--o{ B : has")) assertEquals(Type.Gantt, Type.of("gantt\n title x")) assertEquals(Type.Pie, Type.of("pie title Pets")) + assertEquals(Type.Journey, Type.of("journey\n title x")) + assertEquals(Type.Quadrant, Type.of("quadrantChart\n title x")) + assertEquals(Type.Requirement, Type.of("requirementDiagram\n requirement r {")) + assertEquals(Type.Git, Type.of("gitGraph\n commit")) + assertEquals(Type.Git, Type.of("gitGraph LR:\n commit")) + assertEquals(Type.C4, Type.of("C4Context\n title x")) + assertEquals(Type.C4, Type.of("C4Container\n title x")) + assertEquals(Type.Mindmap, Type.of("mindmap\n root((x))")) + assertEquals(Type.Timeline, Type.of("timeline\n 2023 : x")) + assertEquals(Type.Sankey, Type.of("sankey-beta\na,b,1")) + assertEquals(Type.XyChart, Type.of("xychart-beta\n bar [1]")) + assertEquals(Type.Block, Type.of("block-beta\n a b")) + assertEquals(Type.Packet, Type.of("packet-beta\n 0-15: \"x\"")) + assertEquals(Type.Packet, Type.of("packet\n 0-15: \"x\"")) + assertEquals(Type.Kanban, Type.of("kanban\n todo[To do]")) + assertEquals(Type.Architecture, Type.of("architecture-beta\n service a(cloud)[A]")) + assertEquals(Type.Radar, Type.of("radar-beta\n axis a")) + assertEquals(Type.Treemap, Type.of("treemap-beta\n\"a\": 1")) } @Test diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/mermaid/EnginesTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/mermaid/EnginesTest.kt new file mode 100644 index 00000000000..4c6504f81ac --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/mermaid/EnginesTest.kt @@ -0,0 +1,347 @@ +package ai.kilocode.client.ui.diagram.mermaid + +import ai.kilocode.client.ui.diagram.FakeMeasure +import ai.kilocode.client.ui.diagram.Fault +import ai.kilocode.client.ui.diagram.Head +import ai.kilocode.client.ui.diagram.Limits +import ai.kilocode.client.ui.diagram.Mark +import ai.kilocode.client.ui.diagram.Out +import ai.kilocode.client.ui.diagram.Role +import ai.kilocode.client.ui.diagram.err +import ai.kilocode.client.ui.diagram.flatten +import ai.kilocode.client.ui.diagram.scene +import ai.kilocode.client.ui.diagram.spec +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Behaviour of the engines added on top of Flow and Seq, one section per diagram family. */ +class EnginesTest { + private val engine = Mermaid(FakeMeasure()) + + // --- class --- + + @Test + fun `class inheritance draws a hollow triangle at the parent end`() { + val scene = scene(draw("classDiagram\n Shape <|-- Circle")) + val edge = flatten(scene.marks).filterIsInstance().single { it.role == Role.Line } + + assertEquals(Head.Triangle, edge.tail) + assertEquals(Head.None, edge.head) + } + + @Test + fun `class relation keeps cardinality and label`() { + val scene = scene(draw("classDiagram\n Canvas o-- \"0..*\" Shape : holds")) + val texts = texts(scene) + + assertTrue(texts.contains("0..*"), "missing cardinality in $texts") + assertTrue(texts.contains("holds"), "missing label in $texts") + } + + @Test + fun `class members split into attribute and operation compartments`() { + val scene = scene(draw("classDiagram\n class A {\n +int x\n +go() void\n }")) + val texts = texts(scene) + + assertTrue(texts.containsAll(listOf("A", "+int x", "+go() void")), "missing members in $texts") + } + + @Test + fun `class block without closing brace is a syntax error`() { + assertEquals(Fault.Syntax, err(draw("classDiagram\n class A {\n +x")).fault) + } + + // --- state --- + + @Test + fun `state diagram renders start and end markers`() { + val scene = scene(draw("stateDiagram-v2\n [*] --> A\n A --> [*]")) + val ovals = flatten(scene.marks).filterIsInstance() + + assertTrue(ovals.count { it.fill == Role.Line } >= 2, "missing start/end dots") + } + + @Test + fun `composite states nest their members`() { + val scene = scene(draw("stateDiagram-v2\n [*] --> Run\n state Run {\n [*] --> Work\n }")) + val texts = texts(scene) + + assertTrue(texts.contains("Run") && texts.contains("Work"), "missing states in $texts") + } + + // --- er --- + + @Test + fun `er cardinalities map to crows feet`() { + val scene = scene(draw("erDiagram\n A ||--o{ B : has")) + val edge = flatten(scene.marks).filterIsInstance().single { it.role == Role.Line } + + assertEquals(Head.Bar, edge.tail) + assertEquals(Head.Crow, edge.head) + } + + @Test + fun `er attributes render inside the entity table`() { + val scene = scene(draw("erDiagram\n A {\n int id PK\n }")) + val texts = texts(scene) + + assertTrue(texts.contains("int") && texts.contains("id PK"), "missing attributes in $texts") + } + + // --- requirement --- + + @Test + fun `requirement relation labels the arrow with the keyword`() { + val scene = scene(draw("requirementDiagram\n requirement r {\n id: 1\n }\n element e {\n type: module\n }\n e - satisfies -> r")) + val texts = texts(scene) + + assertTrue(texts.contains("«satisfies»"), "missing relation label in $texts") + assertTrue(texts.contains("«requirement»") && texts.contains("«element»"), "missing stereotypes in $texts") + } + + // --- c4 --- + + @Test + fun `c4 relation technology renders as a bracketed second line`() { + val scene = scene(draw("C4Context\n System(a, \"A\", \"x\")\n System(b, \"B\", \"y\")\n Rel(a, b, \"Uses\", \"HTTPS\")")) + val texts = texts(scene) + + assertTrue(texts.contains("Uses") && texts.contains("[HTTPS]"), "missing rel label in $texts") + } + + @Test + fun `c4 external systems get a dashed border`() { + val scene = scene(draw("C4Context\n System_Ext(p, \"P\", \"x\")")) + val boxes = flatten(scene.marks).filterIsInstance() + + assertTrue(boxes.any { it.dash }, "expected a dashed external box") + } + + // --- pie --- + + @Test + fun `pie renders one sector per slice and sorts by value`() { + val scene = scene(draw("pie showData\n title T\n \"A\" : 10\n \"B\" : 30")) + val sectors = flatten(scene.marks).filterIsInstance() + + assertEquals(2, sectors.size) + assertEquals(0, sectors.first().tone, "largest slice should get the first tone") + assertTrue(texts(scene).contains("B [30]"), "showData should append values") + } + + @Test + fun `pie rejects non numeric values`() { + assertEquals(Fault.Syntax, err(draw("pie\n \"A\" : x")).fault) + } + + // --- journey --- + + @Test + fun `journey plots one dot per task`() { + val scene = scene(draw("journey\n title T\n section S\n Wake up: 3: Me\n Work: 5: Me")) + val dots = flatten(scene.marks).filterIsInstance() + + assertEquals(2, dots.size) + assertTrue(texts(scene).contains("Wake up")) + } + + // --- timeline --- + + @Test + fun `timeline continuation lines join the previous period`() { + val scene = scene(draw("timeline\n 2024 : Plugin API\n : Cloud sync\n 2025 : AI")) + val texts = texts(scene) + + assertTrue(texts.containsAll(listOf("2024", "Plugin API", "Cloud sync", "2025", "AI")), "missing entries in $texts") + } + + // --- kanban --- + + @Test + fun `kanban strips card metadata`() { + val scene = scene(draw("kanban\n todo[To do]\n t1[Ship it]@{ assigned: 'kb' }")) + val texts = texts(scene) + + assertTrue(texts.contains("Ship it"), "missing card in $texts") + assertTrue(texts.none { it.contains("@{") }, "metadata leaked into $texts") + } + + // --- packet --- + + @Test + fun `packet fields crossing a row boundary split`() { + val scene = scene(draw("packet-beta\n 0-15: \"a\"\n 16-40: \"b\"")) + val boxes = flatten(scene.marks).filterIsInstance() + + assertEquals(3, boxes.size, "16-40 must split at bit 32") + } + + // --- treemap --- + + @Test + fun `treemap sums internal nodes from their leaves`() { + val scene = scene(draw("treemap-beta\n\"root\"\n \"a\": 30\n \"b\": 10")) + val boxes = flatten(scene.marks).filterIsInstance().filter { it.tone != null } + + assertEquals(2, boxes.size) + val big = boxes.maxBy { it.rect.w * it.rect.h } + val small = boxes.minBy { it.rect.w * it.rect.h } + assertTrue(big.rect.w * big.rect.h > small.rect.w * small.rect.h * 2, "areas should follow values") + } + + // --- block --- + + @Test + fun `block cells honour columns and space slots`() { + val scene = scene(draw("block-beta\n columns 3\n a space b\n space:3\n space c space\n a --> b")) + val boxes = flatten(scene.marks).filterIsInstance() + + assertEquals(3, boxes.size) + val a = boxes.first() + val c = boxes.last() + assertTrue(c.rect.x > a.rect.x, "c should sit in the middle column") + assertTrue(c.rect.y > a.rect.y, "c should sit two rows down") + } + + // --- mindmap --- + + @Test + fun `mindmap rejects a second root`() { + assertEquals(Fault.Syntax, err(draw("mindmap\n a\n b")).fault) + } + + @Test + fun `mindmap unwraps node shapes`() { + val scene = scene(draw("mindmap\n root((IDE))\n (Editor)\n [Tools]")) + val texts = texts(scene) + + assertTrue(texts.containsAll(listOf("IDE", "Editor", "Tools")), "shape brackets leaked into $texts") + } + + // --- xychart --- + + @Test + fun `xychart draws bars and a line for each series`() { + val scene = scene(draw("xychart-beta\n x-axis [a, b]\n y-axis \"Y\" 0 --> 10\n bar [1, 2]\n line [3, 4]")) + val bars = flatten(scene.marks).filterIsInstance().filter { it.tone != null } + val lines = flatten(scene.marks).filterIsInstance().filter { it.tone != null } + + assertEquals(2, bars.size) + assertEquals(1, lines.size) + } + + // --- quadrant --- + + @Test + fun `quadrant places points inside the plot`() { + val scene = scene(draw("quadrantChart\n quadrant-1 Q\n P: [0.5, 0.5]")) + val zones = flatten(scene.marks).filterIsInstance().filter { it.soft } + + assertEquals(4, zones.size) + assertTrue(texts(scene).contains("P")) + } + + // --- radar --- + + @Test + fun `radar accepts key value curves`() { + val scene = scene(draw("radar-beta\n axis a, b\n curve c{ b: 2, a: 1 }")) + val fills = flatten(scene.marks).filterIsInstance().filter { it.soft } + + assertEquals(1, fills.size) + } + + // --- gantt --- + + @Test + fun `gantt chains after tasks and renders milestones as diamonds`() { + val source = """ + gantt + dateFormat YYYY-MM-DD + section S + First :a1, 2026-09-01, 10d + Second :after a1, 5d + Freeze :milestone, 2026-09-16, 0d + """.trimIndent() + val scene = scene(draw(source)) + val bars = flatten(scene.marks).filterIsInstance().filter { it.tone != null } + val diamonds = flatten(scene.marks).filterIsInstance().filter { it.tone != null } + + assertEquals(2, bars.size) + assertEquals(1, diamonds.size) + val first = bars.first() + val second = bars.last() + assertEquals(first.rect.x + first.rect.w, second.rect.x, 0.5, "after a1 must start where a1 ends") + } + + @Test + fun `gantt rejects a task without a date`() { + assertEquals(Fault.Syntax, err(draw("gantt\n section S\n Task :nonsense")).fault) + } + + // --- git --- + + @Test + fun `git graph places commits on branch lanes and links merges`() { + val source = "gitGraph\n commit id: \"a\"\n branch f\n commit id: \"b\"\n checkout main\n merge f tag: \"v1\"" + val scene = scene(draw(source)) + val dots = flatten(scene.marks).filterIsInstance() + val texts = texts(scene) + + assertEquals(3, dots.size) + assertTrue(texts.containsAll(listOf("main", "f", "a", "b", "v1")), "missing git labels in $texts") + } + + @Test + fun `git checkout of an unknown branch is a syntax error`() { + assertEquals(Fault.Syntax, err(draw("gitGraph\n commit\n checkout nope")).fault) + } + + // --- sankey --- + + @Test + fun `sankey draws one band per flow`() { + val scene = scene(draw("sankey-beta\nA,B,10\nB,C,5")) + val bands = flatten(scene.marks).filterIsInstance().filter { it.soft } + val bars = flatten(scene.marks).filterIsInstance().filter { it.tone != null } + + assertEquals(2, bands.size) + assertEquals(3, bars.size) + } + + @Test + fun `sankey rejects malformed rows`() { + assertEquals(Fault.Syntax, err(draw("sankey-beta\nA,B")).fault) + } + + // --- architecture --- + + @Test + fun `architecture honours edge side anchors`() { + val source = "architecture-beta\n service a(server)[A]\n service b(database)[B]\n b:L -- R:a" + val scene = scene(draw(source)) + val boxes = flatten(scene.marks).filterIsInstance().filter { it.fill == Role.Surface } + + assertEquals(2, boxes.size) + val a = boxes.first() + val b = boxes.last() + assertTrue(a.rect.x < b.rect.x, "a should sit left of b (b:L -- R:a)") + } + + // --- limits --- + + @Test + fun `new engines enforce the node cap`() { + val classes = (1..30).joinToString("\n") { "class C$it" } + val out = runBlocking { engine.draw("classDiagram\n$classes", spec().copy(limits = Limits(nodes = 5))) } + + assertEquals(Fault.Limit, err(out).fault) + } + + private fun draw(source: String) = runBlocking { engine.draw(source, spec()) } + + private fun texts(scene: ai.kilocode.client.ui.diagram.Scene) = + flatten(scene.marks).filterIsInstance().map { it.text } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewDiagramTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewDiagramTest.kt index 776528aa768..28ab22c7c72 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewDiagramTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewDiagramTest.kt @@ -163,13 +163,13 @@ class MdViewDiagramTest : BasePlatformTestCase() { } /** - * `classDiagram`, `stateDiagram` and friends are valid mermaid this engine does not draw. Marking them + * `zenuml` and other types this engine does not draw are still valid mermaid. Marking them * red would report working markdown as broken, so they read as a note over the source instead. */ fun `test an unsupported diagram type reads as a note rather than an error`() { - engine.out = Out.Err(Fault.Unsupported, "unsupported diagram type: Class") + engine.out = Out.Err(Fault.Unsupported, "unsupported diagram type: Unknown") - view.set("```mermaid\nclassDiagram\nA <|-- B\n```") + view.set("```mermaid\nzenuml\nA->B: hi\n```") drain() assertFalse(diagram().isVisible) diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/architecture-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/architecture-basic.mmd new file mode 100644 index 00000000000..6702c91537a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/architecture-basic.mmd @@ -0,0 +1,9 @@ +architecture-beta + group api(cloud)[API] + + service db(database)[Database] in api + service disk1(disk)[Storage] in api + service server(server)[Server] in api + + db:L -- R:server + disk1:T -- B:server diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/block-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/block-basic.mmd new file mode 100644 index 00000000000..c4b10adce47 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/block-basic.mmd @@ -0,0 +1,7 @@ +block-beta + columns 3 + Client space Server + space:3 + space DB[("Database")] space + Client --> Server + Server --> DB diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/c4-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/c4-basic.mmd new file mode 100644 index 00000000000..01d3db8cf13 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/c4-basic.mmd @@ -0,0 +1,7 @@ +C4Context + title System context for an online store + Person(customer, "Customer", "A person who buys products.") + System(store, "Online store", "Sells products.") + System_Ext(payment, "Payment provider", "Processes card payments.") + Rel(customer, store, "Uses") + Rel(store, payment, "Sends charges to", "HTTPS") diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/class-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/class-basic.mmd new file mode 100644 index 00000000000..f30c8d8c560 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/class-basic.mmd @@ -0,0 +1,20 @@ +classDiagram + class Shape { + <> + +area() double + } + class Circle { + +double radius + +area() double + } + class Rectangle { + +double width + +double height + +area() double + } + class Canvas { + +draw() + } + Shape <|-- Circle + Shape <|-- Rectangle + Canvas o-- "0..*" Shape : holds diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/er-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/er-basic.mmd new file mode 100644 index 00000000000..a2479b46016 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/er-basic.mmd @@ -0,0 +1,13 @@ +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ ORDER_LINE : contains + PRODUCT ||--o{ ORDER_LINE : "appears in" + CUSTOMER { + int id PK + string name + string email + } + ORDER { + int id PK + date created + } diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/gantt-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/gantt-basic.mmd new file mode 100644 index 00000000000..fdab081c899 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/gantt-basic.mmd @@ -0,0 +1,9 @@ +gantt + title A release plan + dateFormat YYYY-MM-DD + section Development + Feature work :a1, 2026-09-01, 10d + Bug fixing :after a1, 5d + section Release + Code freeze :milestone, freeze, 2026-09-16, 0d + Beta :2026-09-16, 7d diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/git-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/git-basic.mmd new file mode 100644 index 00000000000..bd8c3285182 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/git-basic.mmd @@ -0,0 +1,8 @@ +gitGraph + commit id: "init" + branch feature + commit id: "add parser" + commit id: "add tests" + checkout main + commit id: "hotfix" + merge feature tag: "v1.0" diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/journey-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/journey-basic.mmd new file mode 100644 index 00000000000..91b0218085c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/journey-basic.mmd @@ -0,0 +1,8 @@ +journey + title A morning routine + section Get ready + Wake up: 3: Me + Drink coffee: 5: Me + section Commute + Ride the train: 4: Me + Walk to the office: 5: Me diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/kanban-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/kanban-basic.mmd new file mode 100644 index 00000000000..fad3afe351e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/kanban-basic.mmd @@ -0,0 +1,8 @@ +kanban + todo[To do] + t1[Write the spec] + t2[Design the UI] + wip[In progress] + t3[Implement the parser] + done[Done] + t4[Set up CI]@{ assigned: 'kb' } diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/mindmap-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/mindmap-basic.mmd new file mode 100644 index 00000000000..196d65841f0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/mindmap-basic.mmd @@ -0,0 +1,10 @@ +mindmap + root((IDE)) + Editor + Completion + Highlighting + Tools + Debugger + Terminal + Plugins + Themes diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/packet-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/packet-basic.mmd new file mode 100644 index 00000000000..cd95c30bbd3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/packet-basic.mmd @@ -0,0 +1,14 @@ +packet-beta + 0-15: "Source port" + 16-31: "Destination port" + 32-63: "Sequence number" + 64-95: "Acknowledgment number" + 96-99: "Data offset" + 100-105: "Reserved" + 106: "URG" + 107: "ACK" + 108: "PSH" + 109: "RST" + 110: "SYN" + 111: "FIN" + 112-127: "Window" diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/pie-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/pie-basic.mmd new file mode 100644 index 00000000000..74d0665d07f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/pie-basic.mmd @@ -0,0 +1,6 @@ +pie showData + title Bug reports by component + "Editor" : 45 + "Debugger" : 25 + "Terminal" : 15 + "Other" : 15 diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/quadrant-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/quadrant-basic.mmd new file mode 100644 index 00000000000..1c736c75095 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/quadrant-basic.mmd @@ -0,0 +1,11 @@ +quadrantChart + title Reach and engagement of campaigns + x-axis Low Reach --> High Reach + y-axis Low Engagement --> High Engagement + quadrant-1 Expand + quadrant-2 Promote + quadrant-3 Re-evaluate + quadrant-4 Improve + Campaign A: [0.30, 0.60] + Campaign B: [0.45, 0.23] + Campaign C: [0.80, 0.75] diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/radar-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/radar-basic.mmd new file mode 100644 index 00000000000..756c7a35971 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/radar-basic.mmd @@ -0,0 +1,8 @@ +radar-beta + title Grades + axis m["Math"], s["Science"], e["English"] + axis h["History"], g["Geography"], a["Art"] + curve alice["Alice"]{85, 90, 80, 70, 75, 90} + curve bob["Bob"]{70, 75, 85, 80, 90, 85} + max 100 + min 0 diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/requirement-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/requirement-basic.mmd new file mode 100644 index 00000000000..0edda1e6b33 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/requirement-basic.mmd @@ -0,0 +1,11 @@ +requirementDiagram + requirement save_req { + id: 1 + text: The editor shall save the state on exit. + risk: high + verifymethod: test + } + element state_saver { + type: module + } + state_saver - satisfies -> save_req diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/sankey-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/sankey-basic.mmd new file mode 100644 index 00000000000..1635497a181 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/sankey-basic.mmd @@ -0,0 +1,6 @@ +sankey-beta +Budget,Engineering,60 +Budget,Marketing,25 +Budget,Operations,15 +Engineering,Frontend,25 +Engineering,Backend,35 diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/state-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/state-basic.mmd new file mode 100644 index 00000000000..7f80a6aa163 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/state-basic.mmd @@ -0,0 +1,11 @@ +stateDiagram-v2 + [*] --> Idle + Idle --> Running : start + Running --> Paused : pause + Paused --> Running : resume + Running --> [*] : stop + state Running { + [*] --> Working + Working --> Waiting : block + Waiting --> Working : unblock + } diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/timeline-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/timeline-basic.mmd new file mode 100644 index 00000000000..bd1043bacc6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/timeline-basic.mmd @@ -0,0 +1,6 @@ +timeline + title The history of a product + 2023 : First release + 2024 : Plugin API + : Cloud sync + 2025 : AI assistant diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/treemap-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/treemap-basic.mmd new file mode 100644 index 00000000000..5a167dddb39 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/treemap-basic.mmd @@ -0,0 +1,8 @@ +treemap-beta +"Codebase" + "Platform" + "Editor": 40 + "VCS": 20 + "Plugins" + "Java": 25 + "Python": 15 diff --git a/packages/kilo-jetbrains/frontend/src/test/resources/diagram/xychart-basic.mmd b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/xychart-basic.mmd new file mode 100644 index 00000000000..5eb05713adc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/resources/diagram/xychart-basic.mmd @@ -0,0 +1,6 @@ +xychart-beta + title "Monthly revenue" + x-axis [Jan, Feb, Mar, Apr, May] + y-axis "Revenue (kUSD)" 0 --> 120 + bar [45, 60, 75, 90, 110] + line [45, 60, 75, 90, 110] From 0eab3a47a2bbfdf1f875f30323e0b4be22d6fd95 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 28 Aug 2026 17:52:58 -0400 Subject: [PATCH 2/2] fix(jetbrains): harden new Mermaid engines against malformed input Addresses PR review. Three of these were unrecoverable rather than merely wrong: the loops run outside any suspend point, so the render timeout could not break them, and a StackOverflowError is an Error that Diagrams.draw's catch(Exception) never converts to Out.Err. - Packet: bit indexes are parsed with toIntOrNull and capped, and row splitting checks cancellation. A field ending at Int.MAX_VALUE used to overflow the row arithmetic into an endless loop. - Scopes.open rejects nesting a scope inside itself and returns a syntax error; StateDg and C4Dg surface that instead of looping (state) or recursing to a StackOverflowError (C4 boundary). C4 also rejects duplicate element/boundary ids that caused double-draws. - Layered lays an empty scope out as an empty frame instead of throwing, so 'state Empty { }' renders. - Layered iterates rows in rank order. They are keyed by rank but filled in declaration order, so 'Circle --|> Shape' drew the child above the parent. - Sankey returns Fault.Limit when unique nodes exceed the cap instead of a blank successful scene. - Radar orders min/max before clamping, so all-negative curves no longer hand coerceIn an empty range. - ScenePainter resets to a solid stroke before drawing heads, so outline heads on dashed edges (class realization, dashed ER) stop rendering as broken glyphs. Each fix has a regression test in EnginesTest. --- .../client/ui/diagram/ScenePainter.kt | 3 + .../client/ui/diagram/mermaid/C4Dg.kt | 6 +- .../client/ui/diagram/mermaid/Layered.kt | 9 ++- .../client/ui/diagram/mermaid/Packet.kt | 16 ++++- .../client/ui/diagram/mermaid/Radar.kt | 12 ++-- .../client/ui/diagram/mermaid/Sankey.kt | 4 +- .../client/ui/diagram/mermaid/Scopes.kt | 29 +++++++-- .../client/ui/diagram/mermaid/StateDg.kt | 2 +- .../client/ui/diagram/mermaid/EnginesTest.kt | 65 +++++++++++++++++++ 9 files changed, 127 insertions(+), 19 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ScenePainter.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ScenePainter.kt index 19490542f8e..d354e458e12 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ScenePainter.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/ScenePainter.kt @@ -127,6 +127,9 @@ internal object ScenePainter : Painter { g.color = fill(palette, mark.role, mark.tone, mark.soft) ?: palette.color(mark.role) g.stroke = stroke(mark.dash, mark.thick) g.draw(path(mark.points, false)) + // Outline heads keep the line width but never the dash: a dashed triangle or crow's foot reads + // as a broken glyph on realization arrows and dashed ER relations. + g.stroke = stroke(thick = mark.thick) head(g, palette, mark.points[mark.points.lastIndex - 1], mark.points.last(), mark.head) head(g, palette, mark.points[1], mark.points.first(), mark.tail) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/C4Dg.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/C4Dg.kt index 5735d1ecd56..6c8f4d5bfd7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/C4Dg.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/C4Dg.kt @@ -74,11 +74,14 @@ internal class C4Dg(private val measure: Measure, private val spec: Spec) { val args = Lex.args(body).map { Source.unquote(it) } val id = args.firstOrNull().orEmpty() if (id.isEmpty()) return "boundary needs an id" + // A repeated id would make the boundary a member of itself; the recursive layout below has + // no cycle check and would fail with a StackOverflowError, which never becomes an Out.Err. + if (bounds.containsKey(id) || cells.containsKey(id)) return "duplicate boundary id $id" val here = stack.lastOrNull() ?: Scopes.ROOT + if (!scopes.open(id, here)) return "boundary $id cannot be nested inside itself" bounds[id] = args.getOrNull(1) ?: id members.getValue(here).add(id) members.getOrPut(id) { mutableListOf() } - scopes.open(id, here) stack.addLast(id) return null } @@ -100,6 +103,7 @@ internal class C4Dg(private val measure: Measure, private val spec: Spec) { val args = Lex.args(body).map { Source.unquote(it) } val id = args.firstOrNull().orEmpty() if (id.isEmpty()) return "$word needs an alias" + if (cells.containsKey(id) || bounds.containsKey(id)) return "duplicate element id $id" val here = stack.lastOrNull() ?: Scopes.ROOT val label = args.getOrNull(1) ?: id val tech = if (kind.tech) args.getOrNull(2).orEmpty() else "" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Layered.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Layered.kt index b541b265729..e05a4d6c26d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Layered.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Layered.kt @@ -21,6 +21,9 @@ internal data class Plan(val rects: Map, val size: Size) */ internal class Layered(private val spec: Spec) { suspend fun run(sizes: Map, rails: List): Plan { + // An empty scope is valid mermaid (`state Empty { }`), so it lays out as an empty frame rather + // than throwing out of the row aggregations below. + if (sizes.isEmpty()) return Plan(emptyMap(), Size(0.0, 0.0)) val gap = spec.metrics.gap val step = spec.metrics.rank val links = rails.filter { it.from != it.to && sizes.containsKey(it.from) && sizes.containsKey(it.to) } @@ -37,7 +40,11 @@ internal class Layered(private val spec: Spec) { val wide = rows.values.maxOf { row -> row.sumOf { sizes.getValue(it).w } + gap * (row.size - 1) } val rects = linkedMapOf() var top = 0.0 - for (row in rows.values) { + // Rows are keyed by rank but filled in declaration order, so layout has to sort them the way + // order() does. Otherwise the first-declared rank paints at the top and a parent-on-top + // relation like `Circle --|> Shape` comes out upside down. + for (key in rows.keys.sorted()) { + val row = rows.getValue(key) val tall = row.maxOf { sizes.getValue(it).h } var x = (wide - (row.sumOf { sizes.getValue(it).w } + gap * (row.size - 1))) / 2 for (id in row) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Packet.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Packet.kt index ab916acce42..4c9cf3d9fa5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Packet.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Packet.kt @@ -30,8 +30,13 @@ internal class Packet(private val measure: Measure, private val spec: Spec) { } if (text.substringBefore(' ').lowercase() in setOf("title", "accdescr", "acctitle")) continue val match = ROW.find(text) ?: return Out.Err(Fault.Syntax, "malformed packet field", line.at) - val start = match.groupValues[1].toInt() - val end = match.groupValues[2].ifEmpty { match.groupValues[1] }.toInt() + // Bit indexes are bounded before any row math: an unbounded end would both overflow the + // row arithmetic below and expand into millions of marks in a phase with no suspend point. + val start = match.groupValues[1].toIntOrNull() + val end = match.groupValues[2].ifEmpty { match.groupValues[1] }.toIntOrNull() + if (start == null || end == null || start > CAP || end > CAP) { + return Out.Err(Fault.Limit, "packet bit indexes must stay under $CAP", line.at) + } if (end < start) return Out.Err(Fault.Syntax, "packet field ends before it starts", line.at) fields.add(Field(start, end, Source.unquote(match.groupValues[3].trim()))) if (fields.size > spec.limits.nodes) return Out.Err(Fault.Limit, "packet exceeds ${spec.limits.nodes} fields") @@ -40,7 +45,7 @@ internal class Packet(private val measure: Measure, private val spec: Spec) { return Out.Ok(marks(fields.sortedBy { it.start })) } - private fun marks(fields: List): Scene { + private suspend fun marks(fields: List): Scene { val sheet = Sheet(measure, spec) val high = sheet.high val pad = sheet.pad @@ -50,6 +55,7 @@ internal class Packet(private val measure: Measure, private val spec: Spec) { for (field in fields) { var start = field.start while (start <= field.end) { + coroutineContext.ensureActive() val row = start / BITS val stop = minOf(field.end, (row + 1) * BITS - 1) val rect = Rect( @@ -74,6 +80,10 @@ internal class Packet(private val measure: Measure, private val spec: Spec) { private companion object { const val BITS = 32 + + /** Highest bit index accepted; keeps row arithmetic inside `Int` and mark counts bounded. */ + const val CAP = BITS * 64 + val ROW = Regex("""^\+?(\d+)(?:-(\d+))?\s*:\s*(.+)$""") } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Radar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Radar.kt index 89377d16e51..0ee5606d2b6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Radar.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Radar.kt @@ -123,8 +123,12 @@ internal class Radar(private val measure: Measure, private val spec: Spec) { val pad = sheet.pad val r = high * 9 val at = Pt(0.0, 0.0) - val roof = max ?: curves.maxOf { it.values.maxOrNull() ?: 0.0 } - val span = (roof - min).takeIf { it > 0 } ?: 1.0 + // `min` defaults to 0, so all-negative data (or an inverted explicit min/max) would otherwise + // hand coerceIn an empty range and throw. + val top = max ?: curves.maxOf { it.values.maxOrNull() ?: 0.0 } + val floor = minOf(min, top) + val roof = maxOf(min, top) + val span = (roof - floor).takeIf { it > 0 } ?: 1.0 val count = axes.size if (title.isNotEmpty()) sheet.texts(listOf(title), at.x, at.y - r - high * 2 - pad, Role.Text, bold = true) @@ -153,8 +157,8 @@ internal class Radar(private val measure: Measure, private val spec: Spec) { } curves.forEachIndexed { tone, curve -> val points = List(count) { idx -> - val value = (curve.values.getOrNull(idx) ?: min).coerceIn(min, roof) - spoke(idx, r * (value - min) / span) + val value = (curve.values.getOrNull(idx) ?: floor).coerceIn(floor, roof) + spoke(idx, r * (value - floor) / span) } sheet.add(Mark.Poly(points, null, null, tone = tone, soft = true)) sheet.add(Mark.Edge(points + points.first(), Role.Line, thick = true, tone = tone)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sankey.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sankey.kt index d048cb0e808..4c332e187d4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sankey.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Sankey.kt @@ -61,7 +61,7 @@ internal class Sankey(private val measure: Measure, private val spec: Spec) { return out } - private suspend fun marks(flows: List): Out.Ok { + private suspend fun marks(flows: List): Out { val sheet = Sheet(measure, spec) val high = sheet.high val pad = sheet.pad @@ -70,7 +70,7 @@ internal class Sankey(private val measure: Measure, private val spec: Spec) { nodes.getOrPut(flow.from) { Node(flow.from, nodes.size) } nodes.getOrPut(flow.to) { Node(flow.to, nodes.size) } } - if (nodes.size > spec.limits.nodes) return Out.Ok(sheet.scene(Type.Sankey)) + if (nodes.size > spec.limits.nodes) return Out.Err(Fault.Limit, "sankey exceeds ${spec.limits.nodes} nodes") // Longest-path depth; passes converge for a DAG, and the node-count bound tames cycles. var pass = 0 while (pass++ < nodes.size) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Scopes.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Scopes.kt index 99134278e23..f90ee20d0d1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Scopes.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/Scopes.kt @@ -11,9 +11,22 @@ internal class Scopes { private val parents = linkedMapOf() private val owner = linkedMapOf() - fun open(id: String, parent: String) { + /** + * Registers a nested scope. Returns false when the nesting would create a cycle — re-opening a + * composite inside itself, as in `state A { state A { ... } }`. A cycle here is unrecoverable + * rather than ugly: [path] and the recursive scope layout both walk these links outside any + * suspend point, so the render timeout could never break the loop. + */ + fun open(id: String, parent: String): Boolean { + if (id == parent) return false + var cur = parent + while (cur != ROOT) { + if (cur == id) return false + cur = parents[cur] ?: ROOT + } parents[id] = parent claim(id, parent) + return true } fun claim(node: String, scope: String) { @@ -26,21 +39,23 @@ internal class Scopes { val fp = path(owner[from] ?: ROOT) val tp = path(owner[to] ?: ROOT) var common = 0 - while (common < fp.size && common < tp.size && fp[common] == tp[common]) common++ - val lca = fp[common - 1] - val a = if (common < fp.size) fp[common] else from - val b = if (common < tp.size) tp[common] else to + while (common < fp.size && common < tp.size && fp.getOrNull(common) == tp.getOrNull(common)) common++ + val lca = fp.getOrNull(common - 1) ?: ROOT + val a = fp.getOrNull(common) ?: from + val b = tp.getOrNull(common) ?: to return Hop(lca, a, b) } private fun path(scope: String): List { val out = ArrayDeque() + val seen = mutableSetOf() var cur = scope - while (true) { + while (seen.add(cur)) { out.addFirst(cur) - if (cur == ROOT) return out.toList() + if (cur == ROOT) break cur = parents[cur] ?: ROOT } + return out.toList() } companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/StateDg.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/StateDg.kt index a1b84d13afe..cf786e9fce7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/StateDg.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/diagram/mermaid/StateDg.kt @@ -106,13 +106,13 @@ internal class StateDg(private val measure: Measure, private val spec: Spec) { return null } val here = stack.lastOrNull() ?: Scopes.ROOT + if (!scopes.open(body, here)) return "state $body cannot be nested inside itself" if (kinds[body] == null) { count++ members.getValue(here).add(body) } kinds[body] = Kind.Composite labels[body] = Source.label(body) - scopes.open(body, here) members.getOrPut(body) { mutableListOf() } stack.addLast(body) return null diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/mermaid/EnginesTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/mermaid/EnginesTest.kt index 4c6504f81ac..a3fb7cb9ec4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/mermaid/EnginesTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/diagram/mermaid/EnginesTest.kt @@ -340,6 +340,71 @@ class EnginesTest { assertEquals(Fault.Limit, err(out).fault) } + @Test + fun `sankey refuses more unique nodes than the cap allows`() { + val rows = (1..30).joinToString("\n") { "s$it,t$it,1" } + val out = runBlocking { engine.draw("sankey-beta\n$rows", spec().copy(limits = Limits(nodes = 5))) } + + assertEquals(Fault.Limit, err(out).fault) + } + + // --- malformed input that must not hang or crash --- + + /** + * Row splitting is `Int` arithmetic with no suspend point, so an unbounded end bit would overflow + * into an endless loop that the render timeout cannot interrupt. + */ + @Test + fun `packet refuses an out of range bit index instead of looping`() { + assertEquals(Fault.Limit, err(draw("packet-beta\n 0-2147483647: \"x\"")).fault) + assertEquals(Fault.Limit, err(draw("packet-beta\n 99999999999999: \"x\"")).fault) + } + + /** Re-opening a composite inside itself used to make the scope walk spin forever. */ + @Test + fun `state refuses a composite nested inside itself`() { + val out = draw("stateDiagram-v2\n state A {\n state A {\n [*] --> B\n }\n }") + + assertEquals(Fault.Syntax, err(out).fault) + } + + /** The same shape in C4 made a boundary a member of itself, recursing to a StackOverflowError. */ + @Test + fun `c4 refuses a duplicate boundary id`() { + val out = draw("C4Context\n Enterprise_Boundary(a, \"A\") {\n Enterprise_Boundary(a, \"A\") {\n System(s, \"S\")\n }\n }") + + assertEquals(Fault.Syntax, err(out).fault) + } + + @Test + fun `an empty composite state still renders a frame`() { + val scene = scene(draw("stateDiagram-v2\n [*] --> A\n state A {\n }")) + + assertTrue(texts(scene).contains("A"), "expected the composite title") + } + + @Test + fun `radar handles values below the default minimum`() { + val scene = scene(draw("radar-beta\n axis a, b\n curve c{-5, -10}")) + + assertTrue(flatten(scene.marks).filterIsInstance().any { it.soft }, "expected a curve fill") + } + + // --- layout order --- + + /** `Circle --|> Shape` points the triangle at Shape, so Shape is the parent and sits on top. */ + @Test + fun `class parents sit above children regardless of declaration order`() { + val scene = scene(draw("classDiagram\n Circle --|> Shape")) + val boxes = flatten(scene.marks).filterIsInstance() + val texts = flatten(scene.marks).filterIsInstance() + val shape = texts.single { it.text == "Shape" } + val circle = texts.single { it.text == "Circle" } + + assertEquals(2, boxes.size) + assertTrue(shape.at.y < circle.at.y, "Shape should sit above Circle") + } + private fun draw(source: String) = runBlocking { engine.draw(source, spec()) } private fun texts(scene: ai.kilocode.client.ui.diagram.Scene) =