Export diagrams as interactive HTML from the app and an agent tool

This commit is contained in:
Ian Gulliver
2026-10-23 08:12:23 -07:00
parent caf5b123d2
commit f18dd88465
7 changed files with 269 additions and 19 deletions
+45 -2
View File
@@ -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
+2
View File
@@ -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.
+2 -2
View File
@@ -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}}
+22 -13
View File
@@ -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<BoxLayout, DiagramError> {
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
+4 -1
View File
@@ -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()
}
+189
View File
@@ -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: "&amp;").replacingOccurrences(of: "<", with: "&lt;").replacingOccurrences(of: ">", with: "&gt;").replacingOccurrences(of: "\"", with: "&quot;")
}
private func json(_ v: Any) -> String {
String(decoding: try! JSONSerialization.data(withJSONObject: v), as: UTF8.self).replacingOccurrences(of: "</", with: "<\\/")
}
private func fontFace(_ subset: String) -> 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 += "<g class=\"box\" data-id=\"\(esc(b.node.id))\" style=\"--l:\(hex(f, .light));--d:\(hex(f, .dark))\">"
svg += "<rect x=\"\(num(r.minX))\" y=\"\(num(r.minY))\" width=\"\(num(r.width))\" height=\"\(num(r.height))\" rx=\"\(b.depth == 0 ? 8 : 6)\"/>"
var y = r.minY + pad
svg += "<text class=\"title\" x=\"\(num(r.minX + pad))\" y=\"\(num(y + CTFontGetAscent(titleFont)))\">\(esc(b.node.title))</text>"
y += titleLine + 2
for d in b.node.details {
svg += "<text class=\"detail\" x=\"\(num(r.minX + pad))\" y=\"\(num(y + CTFontGetAscent(detailFont)))\">\(esc(d))</text>"
y += detailLine
}
svg += "</g>"
}
for (i, e) in layout.edges.enumerated() {
svg += "<g class=\"edge\" data-i=\"\(i)\">"
svg += "<path d=\"\(edgePath(e.points))\"\(e.edge.dashed ? " stroke-dasharray=\"5 4\"" : "")/>"
if e.points.count >= 2 {
svg += "<polygon points=\"\(arrowHead(e.points[e.points.count - 2], e.points[e.points.count - 1]))\"/>"
}
if let label = e.edge.label, let rect = e.labelRect {
svg += "<text x=\"\(num(rect.minX))\" y=\"\(num(rect.minY + CTFontGetAscent(edgeLabelFont)))\">\(esc(label))</text>"
}
svg += "</g>"
}
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 { "<button data-p=\"\($0.offset)\">\(esc($0.element.name))</button>" }.joined()
return """
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Diagram</title>
<style>
\(fontFace("latin-ext"))
\(fontFace("latin"))
:root{\(light)}
@media (prefers-color-scheme:dark){:root{\(dark)}.box{--l:var(--d)!important}}
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:Atkinson,-apple-system,sans-serif}
body{display:flex;flex-direction:column}
nav{display:flex;flex-wrap:wrap;gap:6px;padding:12px 12px 0}
nav:empty{display:none}
button{font:600 13px Atkinson,-apple-system,sans-serif;color:var(--text);background:transparent;border:1px solid var(--border);border-radius:6px;padding:4px 10px;cursor:pointer}
button.on{color:var(--bg);background:var(--accent);border-color:var(--accent)}
svg{flex:1;width:100%;min-height:0;touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none}
.box rect{fill:var(--l);stroke:var(--border);stroke-width:1}
.box.lit rect{stroke:var(--accent);stroke-width:1.5}
.box.dim{opacity:.3}
.box{cursor:pointer}
.title{font-size:\(num(CTFontGetSize(titleFont)))px;font-weight:600;fill:var(--text)}
.detail{font-size:\(num(CTFontGetSize(detailFont)))px;fill:var(--muted)}
.edge{pointer-events:none}
.edge path{fill:none;stroke:var(--muted);stroke-width:1.3;stroke-linecap:round;stroke-linejoin:round}
.edge polygon{fill:var(--muted)}
.edge text{font-size:\(num(CTFontGetSize(edgeLabelFont)))px;fill:var(--muted)}
.edge.lit path{stroke:var(--accent);stroke-width:2}
.edge.lit polygon,.edge.lit text{fill:var(--accent)}
.edge.faint{opacity:.2}
</style></head><body>
<nav>\(buttons)</nav>
<svg xmlns="http://www.w3.org/2000/svg"><g id="world">\(svg)</g></svg>
<script>
const D=\(json(data));
const svg=document.querySelector("svg"),world=document.getElementById("world");
const parent={};D.boxes.forEach(b=>parent[b.id]=b.parent);
const anc=id=>{const o=[];for(let p=parent[id];p!=null;p=parent[p])o.push(p);return o};
let picked=null,preset=null;
function render(){
let boxes=null,edges=new Set();
if(picked!=null){boxes=new Set([picked]);D.edges.forEach((e,i)=>{if(e.from==picked||e.to==picked){edges.add(i);boxes.add(e.from);boxes.add(e.to)}})}
else if(preset!=null){const ids=D.presets[preset].ids;boxes=new Set(ids);D.edges.forEach((e,i)=>{if(boxes.has(e.from)&&boxes.has(e.to))edges.add(i)})}
document.querySelectorAll(".box").forEach(g=>{const id=g.dataset.id;
g.classList.toggle("lit",!!boxes&&boxes.has(id));
const dim=!!boxes&&!boxes.has(id)&&!anc(id).some(a=>boxes.has(a))&&![...boxes].some(b=>anc(b).includes(id));
g.classList.toggle("dim",dim)});
document.querySelectorAll(".edge").forEach(g=>{const i=+g.dataset.i;g.classList.toggle("lit",edges.has(i));g.classList.toggle("faint",!!boxes&&!edges.has(i))});
document.querySelectorAll("nav button").forEach(b=>b.classList.toggle("on",+b.dataset.p===preset));
}
document.querySelectorAll("nav button").forEach(b=>b.onclick=()=>{const p=+b.dataset.p;picked=null;preset=preset===p?null:p;render()});
let s=1,tx=0,ty=0;
const apply=()=>world.setAttribute("transform",`translate(${tx} ${ty}) scale(${s})`);
function fit(){const r=svg.getBoundingClientRect();s=Math.min(2,r.width/D.width,r.height/D.height);tx=(r.width-D.width*s)/2;ty=(r.height-D.height*s)/2;apply()}
function zoom(f,cx,cy){const n=Math.min(6,Math.max(.25,s*f));tx=cx-(cx-tx)*n/s;ty=cy-(cy-ty)*n/s;s=n;apply()}
const local=e=>{const r=svg.getBoundingClientRect();return[e.clientX-r.left,e.clientY-r.top]};
svg.addEventListener("wheel",e=>{e.preventDefault();if(e.ctrlKey){const[x,y]=local(e);zoom(Math.exp(-e.deltaY*.01),x,y)}else{tx-=e.deltaX;ty-=e.deltaY;apply()}},{passive:false});
const ptrs=new Map();let moved=0,pinch=0;
svg.addEventListener("pointerdown",e=>{svg.setPointerCapture(e.pointerId);ptrs.set(e.pointerId,local(e));if(ptrs.size==1)moved=0;pinch=0});
svg.addEventListener("pointermove",e=>{if(!ptrs.has(e.pointerId))return;const p=local(e),o=ptrs.get(e.pointerId);
if(ptrs.size==1){tx+=p[0]-o[0];ty+=p[1]-o[1];moved+=Math.abs(p[0]-o[0])+Math.abs(p[1]-o[1]);apply();ptrs.set(e.pointerId,p);return}
ptrs.set(e.pointerId,p);moved=99;const[a,b]=[...ptrs.values()];const d=Math.hypot(a[0]-b[0],a[1]-b[1]);
if(pinch)zoom(d/pinch,(a[0]+b[0])/2,(a[1]+b[1])/2);pinch=d});
const up=e=>{ptrs.delete(e.pointerId);pinch=0};
svg.addEventListener("pointerup",e=>{const tap=ptrs.size==1&&moved<4;up(e);if(!tap)return;
const g=document.elementsFromPoint(e.clientX,e.clientY).find(el=>el.closest&&el.closest(".box"));
const id=g?g.closest(".box").dataset.id:null;preset=null;picked=id==picked?null:id;render()});
svg.addEventListener("pointercancel",up);
addEventListener("resize",fit);
fit();
</script></body></html>
"""
}
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))
}
}
+5 -1
View File
@@ -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)