From f18dd884659747d83e803b076e9723c1a6acfae1 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 23 Oct 2026 08:12:23 -0700 Subject: [PATCH] Export diagrams as interactive HTML from the app and an agent tool --- internal/agents/diagram.go | 47 ++++++- internal/agents/kb/diagrams.md | 2 + internal/agents/manager.go | 4 +- mac/Sources/Byre/BoxDiagram.swift | 35 ++++-- mac/Sources/Byre/ByreApp.swift | 5 +- mac/Sources/Byre/DiagramHTML.swift | 189 +++++++++++++++++++++++++++++ mac/Sources/Byre/Render.swift | 6 +- 7 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 mac/Sources/Byre/DiagramHTML.swift diff --git a/internal/agents/diagram.go b/internal/agents/diagram.go index 4989c52..cda5a25 100644 --- a/internal/agents/diagram.go +++ b/internal/agents/diagram.go @@ -44,7 +44,50 @@ func (m *Manager) renderDiagramTool() mcp.Tool { } } -func renderDiagram(ctx context.Context, source string) ([]byte, error) { +func (m *Manager) exportDiagramTool(l *live) mcp.Tool { + return mcp.Tool{ + Name: "export_diagram", + Description: "Export a diagram as a self-contained HTML page, drawn exactly as byre's app lays it out and as interactive as its full-size view: clicking a box highlights it, its arrows and its neighbors, each highlight line is a button, and it zooms and pans. " + + "Pass the body of a ```diagram block and the file to write; show it with show_page.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "source": {"type": "string", "description": "The block's body, without the fence lines."}, + "path": {"type": "string", "description": "The HTML file to write, relative to your worktree, absolute, or starting with ~/."} + }, + "required": ["source", "path"] + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + args := struct { + Source string `json:"source"` + Path string `json:"path"` + }{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed export_diagram call: %w", err) + } + full, err := l.resolve(args.Path) + if err != nil { + return "", err + } + html, err := renderDiagram(ctx, args.Source, "--html") + if err != nil { + return "", err + } + err = os.MkdirAll(filepath.Dir(full), 0o755) + if err != nil { + return "", err + } + err = os.WriteFile(full, html, 0o644) + if err != nil { + return "", err + } + return fmt.Sprintf("Wrote the diagram to %s.", args.Path), nil + }, + } +} + +func renderDiagram(ctx context.Context, source string, flags ...string) ([]byte, error) { exe, err := os.Executable() if err != nil { return nil, err @@ -52,7 +95,7 @@ func renderDiagram(ctx context.Context, source string) ([]byte, error) { app := filepath.Join(filepath.Dir(exe), "..", "MacOS", "Byre") ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, app, "render-diagram") + cmd := exec.CommandContext(ctx, app, append([]string{"render-diagram"}, flags...)...) cmd.Stdin = strings.NewReader(source) stderr := bytes.Buffer{} cmd.Stderr = &stderr diff --git a/internal/agents/kb/diagrams.md b/internal/agents/kb/diagrams.md index c71fd33..7f54e00 100644 --- a/internal/agents/kb/diagrams.md +++ b/internal/agents/kb/diagrams.md @@ -33,3 +33,5 @@ highlight "Event loop" store server coord Keep each box to a title and one or two short detail lines, and draw only the arrows the reader needs; a picture with every dependency in it reads worse than prose. Check a diagram before you send it: `render_diagram` takes the block's body and returns a PNG of what the app draws, or the reason it can't. Look for crossing or overlapping arrows and labels sitting on top of each other, and adjust the rows, `aside` or arrows until it reads cleanly. + +To hand a diagram over as a file, `export_diagram` writes it as one self-contained HTML page with the app's exact layout and its full-size view's interactivity (click to highlight, highlight buttons, zoom and pan, light and dark mode); show it with `show_page`. The full-size view in the app has the same export as its Export HTML button. diff --git a/internal/agents/manager.go b/internal/agents/manager.go index 04ddceb..cf286dc 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -867,7 +867,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api if err != nil { return nil, err } - tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setTitlesTool(l), m.stopMergeTool(l), m.mergeToMainTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l), m.bashTool(l), m.readKnowledgeTool(), m.renderDiagramTool()}, append(append(m.fileTools(l), m.subtaskTools(l)...), m.clientTools(l)...)...) + tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setTitlesTool(l), m.stopMergeTool(l), m.mergeToMainTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l), m.bashTool(l), m.readKnowledgeTool(), m.renderDiagramTool(), m.exportDiagramTool(l)}, append(append(m.fileTools(l), m.subtaskTools(l)...), m.clientTools(l)...)...) lead := true if sess.Role == api.Implementer { g += longTitleGuidance(task) @@ -937,7 +937,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api return nil, err } l.notes = "\n\n" + notes - tools = append(append(m.leadTools(l), m.readKnowledgeTool(), m.renderDiagramTool()), m.clientShellTools()...) + tools = append(append(m.leadTools(l), m.readKnowledgeTool(), m.renderDiagramTool(), m.exportDiagramTool(l)), m.clientShellTools()...) } env := git.IdentityEnv(l.gitName, l.gitEmail) servers := []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: tools}} diff --git a/mac/Sources/Byre/BoxDiagram.swift b/mac/Sources/Byre/BoxDiagram.swift index 5376235..a986f2d 100644 --- a/mac/Sources/Byre/BoxDiagram.swift +++ b/mac/Sources/Byre/BoxDiagram.swift @@ -1,5 +1,6 @@ import CoreText import SwiftUI +import UniformTypeIdentifiers enum BoxTint: String { case plain, blue, green, amber, red @@ -270,7 +271,7 @@ struct BoxFocus { } } -private let pad: CGFloat = 12 +let pad: CGFloat = 12 private let childGap: CGFloat = 14 private let boxGap: CGFloat = 20 private let rowGap: CGFloat = 48 @@ -278,17 +279,17 @@ private let columnGap: CGFloat = 80 private let margin: CGFloat = 20 @MainActor -private var titleFont: CTFont { +var titleFont: CTFont { Fonts.native(13, weight: 600) as CTFont } @MainActor -private var detailFont: CTFont { +var detailFont: CTFont { Fonts.native(12) as CTFont } @MainActor -private var edgeLabelFont: CTFont { +var edgeLabelFont: CTFont { Fonts.native(11) as CTFont } @@ -788,7 +789,7 @@ func cachedBoxLayout(_ source: String) -> Result { return result } -private func fill(_ b: PlacedBox) -> Color { +func fill(_ b: PlacedBox) -> Color { switch b.node.tint { case .blue: Palette.accentTint case .green: Palette.okTint @@ -905,6 +906,7 @@ struct DiagramViewer: View { let layout: BoxLayout @ViewState private var picked: String? @ViewState private var preset: Int? + @ViewState private var exporting: DiagramHTMLFile? private var focus: BoxFocus? { if let picked { @@ -918,16 +920,23 @@ struct DiagramViewer: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - if !layout.presets.isEmpty { - HStack(spacing: 6) { - ForEach(Array(layout.presets.enumerated()), id: \.offset) { i, p in - Button(p.name) { - picked = nil - preset = preset == i ? nil : i - } - .buttonStyle(WebButtonStyle(primary: preset == i)) + HStack(spacing: 6) { + ForEach(Array(layout.presets.enumerated()), id: \.offset) { i, p in + Button(p.name) { + picked = nil + preset = preset == i ? nil : i } + .buttonStyle(WebButtonStyle(primary: preset == i)) } + Spacer(minLength: 0) + Button("Export HTML") { + exporting = DiagramHTMLFile(html: diagramHTML(layout)) + } + .buttonStyle(WebButtonStyle()) + .help("Save the diagram as a web page that zooms and highlights like this view") + } + .fileExporter(isPresented: Binding { exporting != nil } set: { if !$0 { exporting = nil } }, document: exporting, contentType: .html, defaultFilename: "diagram.html") { _ in + exporting = nil } ZoomingCanvas(layout: layout, focus: focus) { id in preset = nil diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index 6d411c5..701e8be 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -57,7 +57,10 @@ enum Main { static func main() { let args = CommandLine.arguments if args.count == 2 && args[1] == "render-diagram" { - exit(renderDiagram()) + exit(renderDiagram(html: false)) + } + if args.count == 3 && args[1] == "render-diagram" && args[2] == "--html" { + exit(renderDiagram(html: true)) } ByreApp.main() } diff --git a/mac/Sources/Byre/DiagramHTML.swift b/mac/Sources/Byre/DiagramHTML.swift new file mode 100644 index 0000000..3ef9fd1 --- /dev/null +++ b/mac/Sources/Byre/DiagramHTML.swift @@ -0,0 +1,189 @@ +import CoreText +import SwiftUI +import UniformTypeIdentifiers + +private func hex(_ c: Color, _ scheme: ColorScheme) -> String { + var env = EnvironmentValues() + env.colorScheme = scheme + let comps = c.resolve(in: env).cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil)!.components! + return "#" + comps.prefix(3).map { String(Int(($0 * 255).rounded()) + 0x100, radix: 16).dropFirst() }.joined() +} + +private func num(_ v: CGFloat) -> String { + "\((v * 10).rounded() / 10)" +} + +private func esc(_ s: String) -> String { + s.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<").replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "\"", with: """) +} + +private func json(_ v: Any) -> String { + String(decoding: try! JSONSerialization.data(withJSONObject: v), as: UTF8.self).replacingOccurrences(of: " String { + let url = Bundle.main.resourceURL!.appending(path: "fonts/atkinson-hyperlegible-next-\(subset)-wght-normal.woff2") + let data = try! Data(contentsOf: url) + return "@font-face{font-family:Atkinson;font-weight:200 800;src:url(data:font/woff2;base64,\(data.base64EncodedString())) format(\"woff2\")}" +} + +private func edgePath(_ pts: [CGPoint]) -> String { + guard let first = pts.first else { + return "" + } + var d = "M\(num(first.x)) \(num(first.y))" + if pts.count > 2 { + for i in 1..<(pts.count - 1) { + let a = pts[i - 1] + let b = pts[i] + let c = pts[i + 1] + let lin = hypot(b.x - a.x, b.y - a.y) + let lout = hypot(c.x - b.x, c.y - b.y) + let r = min(6, lin / 2, lout / 2) + let p = CGPoint(x: b.x - (b.x - a.x) / lin * r, y: b.y - (b.y - a.y) / lin * r) + let q = CGPoint(x: b.x + (c.x - b.x) / lout * r, y: b.y + (c.y - b.y) / lout * r) + d += "L\(num(p.x)) \(num(p.y))Q\(num(b.x)) \(num(b.y)) \(num(q.x)) \(num(q.y))" + } + } + let last = pts[pts.count - 1] + return d + "L\(num(last.x)) \(num(last.y))" +} + +private func arrowHead(_ from: CGPoint, _ to: CGPoint) -> String { + let angle = atan2(to.y - from.y, to.x - from.x) + let size: CGFloat = 8 + let a = CGPoint(x: to.x - size * cos(angle - .pi / 7), y: to.y - size * sin(angle - .pi / 7)) + let b = CGPoint(x: to.x - size * cos(angle + .pi / 7), y: to.y - size * sin(angle + .pi / 7)) + return "\(num(to.x)),\(num(to.y)) \(num(a.x)),\(num(a.y)) \(num(b.x)),\(num(b.y))" +} + +@MainActor +func diagramHTML(_ layout: BoxLayout) -> String { + let colors: [(String, Color)] = [("bg", Palette.bg), ("border", Palette.border), ("text", Palette.text), ("muted", Palette.muted), ("accent", Palette.accent)] + let light = colors.map { "--\($0.0):\(hex($0.1, .light))" }.joined(separator: ";") + let dark = colors.map { "--\($0.0):\(hex($0.1, .dark))" }.joined(separator: ";") + let titleLine = textSize("X", titleFont).height + let detailLine = textSize("X", detailFont).height + var svg = "" + for b in layout.boxes { + let r = b.rect + let f = fill(b) + svg += "" + svg += "" + var y = r.minY + pad + svg += "\(esc(b.node.title))" + y += titleLine + 2 + for d in b.node.details { + svg += "\(esc(d))" + y += detailLine + } + svg += "" + } + for (i, e) in layout.edges.enumerated() { + svg += "" + svg += "" + if e.points.count >= 2 { + svg += "" + } + if let label = e.edge.label, let rect = e.labelRect { + svg += "\(esc(label))" + } + svg += "" + } + let data: [String: Any] = [ + "boxes": layout.boxes.map { ["id": $0.node.id, "parent": $0.parent.map { $0 as Any } ?? NSNull()] }, + "edges": layout.edges.map { ["from": $0.edge.from, "to": $0.edge.to] }, + "presets": layout.presets.map { ["name": $0.name, "ids": Array($0.ids)] }, + "width": layout.size.width, + "height": layout.size.height, + ] + let buttons = layout.presets.enumerated().map { "" }.joined() + return """ + + + Diagram + + + \(svg) + + """ +} + +struct DiagramHTMLFile: FileDocument { + static let readableContentTypes: [UTType] = [.html] + let html: String + + init(html: String) { + self.html = html + } + + init(configuration: ReadConfiguration) throws { + throw CocoaError(.fileReadUnsupportedScheme) + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(html.utf8)) + } +} diff --git a/mac/Sources/Byre/Render.swift b/mac/Sources/Byre/Render.swift index 370a5f9..cd2cc53 100644 --- a/mac/Sources/Byre/Render.swift +++ b/mac/Sources/Byre/Render.swift @@ -2,7 +2,7 @@ import SwiftUI @MainActor -func renderDiagram() -> Int32 { +func renderDiagram(html: Bool) -> Int32 { FontSize.stored = false let source = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self) let layout: BoxLayout @@ -13,6 +13,10 @@ func renderDiagram() -> Int32 { FileHandle.standardError.write(Data(error.reason.utf8)) return 1 } + if html { + FileHandle.standardOutput.write(Data(diagramHTML(layout).utf8)) + return 0 + } let content = BoxCanvas(layout: layout) .frame(width: layout.size.width, height: layout.size.height) .padding(16)