diff --git a/cmd/contr/e2e_see_flow_test.go b/cmd/contr/e2e_see_flow_test.go new file mode 100644 index 00000000..51109a8c --- /dev/null +++ b/cmd/contr/e2e_see_flow_test.go @@ -0,0 +1,55 @@ +//go:build e2e + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/contursofran/contr/internal/agents" + "github.com/contursofran/contr/internal/session" +) + +// seeChatFlow spawns lead and writes a transcript whose answer has a flow diagram (D284). +func seeChatFlow(t *testing.T, h *harness) { + repo := h.repo(t, "demo", "main") + lead, err := h.mgr.Spawn(h.ctx, agents.Spec{Name: "lead", Profile: "claude-idle", Role: agents.RoleOrchestrator, Project: "demo", Worktree: repo}) + if err != nil { + t.Fatalf("spawn lead: %v", err) + } + path := filepath.Join(h.home, ".claude", "projects", "demo", "flow.jsonl") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(flowTranscript()), 0o644); err != nil { + t.Fatal(err) + } + s := &session.Session{UpdatedAt: uint64(time.Now().Unix())} + s.SetTranscript("flow", path) + if err := session.Write(filepath.Join(h.dir, "state"), lead.Address, s); err != nil { + t.Fatal(err) + } +} + +// flowTranscript is a prompt and an answer with a contr-flow block: a branch, a loop back and +// two states. +func flowTranscript() string { + start := time.Now().Add(-2 * time.Minute) + line := func(n int, typ string, content any) string { + b, _ := json.Marshal(map[string]any{"type": typ, "uuid": fmt.Sprintf("u%d", n), "sessionId": "flow", + "timestamp": start.Add(time.Duration(n*10) * time.Second).UTC().Format(time.RFC3339Nano), + "message": map[string]any{"role": typ, "content": content}}) + return string(b) + "\n" + } + return strings.Join([]string{ + line(1, "user", "how does a fix get merged?"), + line(2, "assistant", []any{map[string]any{"type": "text", "text": "It goes through review:\n\n```contr-flow\n" + + "issue -> plan: owner picks\nplan -> build\nbuild -> review: PR\nreview -> merge: approved\n" + + "review -> build: blockers\nmerge -> release\nmerge [done]\nreview [waiting]\n```\n\nA blocker sends it back to the builder."}}), + }, "") +} diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index 6d8bf30a..32cd9e92 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -228,6 +228,12 @@ var seeFixtures = map[string]seeFixture{ ready: "lead, ", press: []string{"Chat", "wait:image 2"}, }, + "chat-flow": { + about: "the orchestrator lead in Chat mode with an answer that holds a contr-flow block, drawn as a flow diagram (D284): six boxes, edge labels, a loop back under the boxes, review waiting and merge done", + setup: seeChatFlow, + ready: "lead, ", + press: []string{"Chat", "wait:Flow diagram"}, + }, "chat-tools": { about: "the chat fixture with a turn of mixed tool calls, its work group open (D274): a call line of each kind with its icon (skill, Slack connector, contr plugin, Linear MCP and Notion connector with their brand marks, an unknown MCP server with the connector icon, agent, fetch, Bash, Read, Grep), a tool of no kind with an empty slot, the label and key argument of each, and the Bash call open on its detail", setup: seeChatTools, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5cca7621..6dd911a7 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -258,5 +258,6 @@ replaces it. | D281 | 2026-11-11 | The composer shows Claude Code's suggested next prompt (#727, tracker #634). Claude Code does not write the suggestion to the transcript (it is made with the transcript off) and `terminal.read` gives text without styles, so contr reads the selected terminal's screen as escape sequences (the MyGo terminal's `Snapshot`) and takes the text after the last `❯` only when all of it is faint (SGR 2), as a real session draws it; typed text, a sent prompt or a menu choice is not faint. The read runs 300 ms after output, off the UI thread, through a new `OnOutput` hook of `ghostty.Conn`. While the field is empty, the suggestion is the field's placeholder; Tab or Right takes it, after the slash popup's Tab. Only the selected terminal has a screen, so other sessions show none (docs/spec/ChatView.md, Composer). | Owner in #727. | | D282 | 2026-11-11 | A Claude worker gets the PATH of the process that spawns it (#728). `hc update` restarts the core under launchd, whose PATH is short, and each terminal inherits the PATH of the core, so `claude` was not found and the worker ended at once. Spawn now sets `CONTR_PATH` in the terminal: the PATH of the spawning process, then `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`, without doubles. The clean wrapper (D216) adds these folders after the PATH of the core and removes the variable, so the PATH of the core still comes first. A program that does not start through the clean wrapper (not Claude) gets no change. | Seen on 2026-11-11 after the first `Update hc` (D277): two spawns ended with "claude: not found". | | D283 | 2026-11-11 | The contr plugin row of Settings → Setup gets one action (#713, S3; owner decision D65). `Install` when the plugin is not installed, `Enable` when it is disabled. A click opens a confirm with the exact command, the same text as the copy line; only `Run` runs it, off the UI thread, with a 2 min limit, and only these two lines with the `claude` the checks find. A spinner shows while it runs; a failure shows the last stderr line under the row; a success runs a recheck. The copy line stays. No other row gets an action (meter D66, gh and hc copy only); contr starts no hc core here and edits no file in Claude Code's folders. | Before, the owner had to copy the line to a terminal. The confirm keeps the run explicit. | +| D284 | 2026-11-11 | A fenced `contr-flow` block in Claude's answer draws as a flow diagram (#696; ChatView.md, Diagrams). contr has its own syntax, one arrow per line (`A -> B: label`, `A [state]`), and draws it with MyGo's Painter (package `internal/app/diagram`), with tokens only and the chat text size. A web view with Mermaid's JS was ruled out (a web process, no theme tokens) and so was a Mermaid subset (most real Mermaid would fall back). A block that does not parse or is too large (more than 12 nodes, past the layer limits, wider than the column) shows as its code block. Sequence diagrams come in a second PR. | | D285 | 2026-11-11 | In the chat, a worker shows the mark of its model family in place of its 6px dot (#714, slice A): the Claude Code mascot, the OpenAI mark (Codex) or the DeepSeek mark, 12px, in the note lines and the work group's fold row. The family comes from what contr already knows of the worker: its model label, else the model of its profile, else its program (`claude`, `codex`); no new read. A worker of another program keeps the dot. The state reads as the dot's did: the mark takes the dot's ink (blocked `ink-100`, working `ink-88`, else `ink-30`) and hops as the working Claude mascot does while the worker works (still with Reduce Motion). No hue: blocked stays grey in the chat (D259, D261), and the mascot is grey too, as `claude` is the orchestrator's. Only the chat changes; the sidebar and the tabs keep their glyphs. The marks are from Simple Icons (CC0). | | D286 | 2026-11-11 | PR and issue chips show GitHub's Octicons (#714, slice B; docs/spec/ChatView.md, Chips and Blocks). A PR shows the pull request icon of its state: open, draft, merged or closed; an issue the issue icon, open or closed. The icons are in `assets/icons/github` (Octicons, MIT, in NOTICE) and `icons.PRIcon`, `icons.IssueIcon` map gh's states to them. They take an ink token (`ink-48`), never GitHub's colors (D119, D261). The dot of the checks (running, passed, failed) stays after the icon, so the chip still shows CI; the open and closed dots go, as the icon says it. The read of a number (`gh pr view`, D262) adds `isDraft`; no other gh read is added. The board's PR cell shows the same icon. The work rows of the board panel (hidden, D147) are not changed. | The owner asked for GitHub's icons (#714). | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index 59a3710b..0c65cade 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -71,7 +71,7 @@ hasn't heard from you…" nudges, and a user line that is all inside one ## Content -How Claude's Markdown shows (D261). MyGo's theme options style what they can; the Block hook builds the rest (remarks, tables, numbered and task lists), and a Code builder the diff blocks. The Markdown inside a remark, a cell or an item shows in a `markdown.View` of its own that shares the selection. +How Claude's Markdown shows (D261). MyGo's theme options style what they can; the Block hook builds the rest (remarks, tables, numbered and task lists), a Code builder the diff blocks, and the `contr-flow` blocks draw as [diagrams](#diagrams). The Markdown inside a remark, a cell or an item shows in a `markdown.View` of its own that shares the selection. | Block | Value | |---|---| @@ -304,6 +304,16 @@ State of a queue step: a merged step has a 12px check in `content-done` and "mer Live and frozen: only the newest board of the thread is live. Under it, a 5px `content-running` dot and "live · updated 18:49" (mono `meta`, `ink-48`): the time its values last changed. An older board keeps the values it showed while it was live, dims to 60% and says "as of 18:45 · a newer board is below". When contr did not show it live (an older answer read after the app started), it shows only the three columns the agent wrote and the time of its answer, so it never shows a state newer than its answer. Nothing ticks (D55): the times are fixed texts. +## Diagrams + +Claude writes a flow diagram as a fenced block `contr-flow` (D284). contr draws it with MyGo's Painter, in the column; the lines read well in a terminal too. + +- Syntax: one line per arrow, `A -> B` or `A -> B: label`; a state as `A [done]`, with `done`, `running`, `waiting`, `failed` or `info`. A node is any text without `->`; empty lines are skipped. +- Layout: layers left to right from the longest path, one sweep orders each layer; an arrow back (a cycle) is an arc under the boxes, its label under the arc. +- Box: height at least 36px, text `body` (at the chat text size, D272) in `ink-88`, centered, 14px side padding, at least 88px wide, radius 8; `hover` fill and an `ink-30` line; a node with a state takes the tint of its content color and a line in that color. +- Arrow: a 1.2px `ink-30` line, an open `ink-48` head; its label in mono `meta` (at the chat text size), `ink-48`, on a patch of the stage, in the middle of the gap. +- Falls back to the code block: a block that does not parse (a chain `A -> B -> C`, a line without an arrow, no arrow at all), more than 12 nodes, a label longer than 28 characters, more than 6 layers, more than 4 nodes in a layer, wider than the column, or a block that is still being written. + ## Behavior - The view reads the transcript only while it shows: a read runs off the diff --git a/internal/app/chatview/content.go b/internal/app/chatview/content.go index 0e86eecc..01063b9f 100644 --- a/internal/app/chatview/content.go +++ b/internal/app/chatview/content.go @@ -17,6 +17,7 @@ import ( "github.com/egoist/mygo/ui/markdown" "github.com/egoist/mygo/ui/markdown/parser" + "github.com/contursofran/contr/internal/app/diagram" "github.com/contursofran/contr/internal/app/kit" "github.com/contursofran/contr/internal/app/theme" ) @@ -129,7 +130,7 @@ func quoteText(src string) string { // mdHooks sets the hooks of the chat's Markdown options. func (ch *Chat) mdHooks() { - code := map[string]func(*ui.Context, *markdown.CodeBlockView){"diff": ch.diffBlock, "patch": ch.diffBlock} + code := map[string]func(*ui.Context, *markdown.CodeBlockView){"diff": ch.diffBlock, "patch": ch.diffBlock, diagram.Lang: ch.flowBlock} ch.md.Block, ch.md.Code = ch.block, code ch.mdNest.Block, ch.mdNest.Code = ch.block, code // The views inside a remark, a cell or an item show the chips of the pre-pass (chips.go) as the @@ -490,6 +491,21 @@ func DiffKind(line string) DiffLineKind { return DiffContext } +// flowBlock is a contr-flow block drawn as a flow diagram (D284), at most the column wide. A +// block that does not parse, does not fit or is still being written shows as its code block. +func (ch *Chat) flowBlock(c *ui.Context, b *markdown.CodeBlockView) { + f, err := diagram.Parse(b.Code()) + maxW := float32(columnMax) + if w := ch.contentWidth(c); w > 0 { + maxW = min(maxW, w) + } + st := diagram.Style{Node: ch.ts(theme.StyleBody), Label: ch.ts(theme.StyleMeta)} + if err == nil && !b.Open && diagram.View(c, f, maxW, st) { + return + } + markdown.DefaultCodeBlock(c, b) +} + // diffBlock is a diff or patch code block: the language and Copy above its lines, an added line // in content-done and a removed one in content-failed, each on a tint of its color. It scrolls // sideways as other code blocks do. diff --git a/internal/app/chatview/content_test.go b/internal/app/chatview/content_test.go index dab7acb1..d08342f9 100644 --- a/internal/app/chatview/content_test.go +++ b/internal/app/chatview/content_test.go @@ -12,6 +12,7 @@ import ( "github.com/egoist/mygo/ui/markdown" "github.com/egoist/mygo/ui/markdown/parser" + "github.com/contursofran/contr/internal/app/diagram" "github.com/contursofran/contr/internal/app/theme" "github.com/contursofran/contr/internal/app/theme/themetest" "github.com/contursofran/contr/internal/app/view" @@ -291,3 +292,30 @@ func TestLinksHaveTheLinkColor(t *testing.T) { } } } + +// wideFlow is six long boxes in a row: wider than the column. +const wideFlow = "the first long box name -> the second long box name\nthe second long box name -> the third long box name\n" + + "the third long box name -> the fourth long box name\nthe fourth long box name -> the fifth long box name\n" + +const bigFlow = "a -> b\nb -> c\nc -> d\nd -> e\ne -> f\nf -> g\ng -> h\nh -> i\ni -> j\nj -> k\nk -> l\nl -> m\n" + +// A contr-flow block draws as a flow diagram (D284); one that does not parse or is too large +// shows as its code block. +func TestFlowBlock(t *testing.T) { + for _, tc := range []struct { + name, src string + drawn bool + }{ + {"good", "a -> b: go\nb [done]\n", true}, + {"bad", "a -> b -> c\n", false}, + {"large", bigFlow, false}, + {"wide", wideFlow, false}, + } { + tt, _ := contentRig(t, mygo.ThemeDark, "```contr-flow\n"+tc.src+"```\n") + _, drawn := tt.Access(diagram.LabelDiagram) + _, code := tt.AccessByRole(ui.RoleGroup, "Code, contr-flow") + if drawn != tc.drawn || code == tc.drawn { + t.Errorf("%s: diagram %v, code block %v", tc.name, drawn, code) + } + } +} diff --git a/internal/app/chatview/sheet_test.go b/internal/app/chatview/sheet_test.go index 5d997aaa..eb5611b1 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -71,6 +71,7 @@ func TestSpecSheet(t *testing.T) { open := true detail.s.ch.calls[id] = &open } + flow := sheetChat(t, "idle", lPrompt, said("f1", flowAnswer)) marks := sheetMarks(t) apptest.SpecSheet(t, []apptest.SheetPart{ {Name: "ChatView", W: sheetW, H: sheetH, View: working.view}, @@ -88,7 +89,8 @@ func TestSpecSheet(t *testing.T) { {Name: "CallLinesBrands", W: sheetW, H: 420, View: brandsView.view}, {Name: "CallDetail", W: sheetW, H: 360, View: detail.view}, {Name: "ChatWorkerMarks", W: sheetW, H: 600, View: marks.view}, - }, []string{"ChatWorkerMarks", "ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallLinesBrands", "CallDetail"}) + {Name: "ChatFlow", W: sheetW, H: 620, View: flow.view}, + }, []string{"ChatFlow", "ChatWorkerMarks", "ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallLinesBrands", "CallDetail"}) } // sheetMarks is an open work group with a note of a worker of each family (Claude, OpenAI, @@ -131,6 +133,12 @@ const contentAnswer = "Four builders work with the M1 rules: red never fills or "```go\n// Sync follows a terminal.\nfunc (s *Source) Sync(id string) error {\n\tif id == \"\" {\n\t\treturn errors.New(\"no terminal\")\n\t}\n\ts.limit = 920\n\treturn nil\n}\n```\n\n" + "```diff\n--- a/kit/agentrow.go\n+++ b/kit/agentrow.go\n@@ -10,3 +10,3 @@\n if row.Selected {\n-\tb.Background(t.Selected)\n+\tb.Background(t.SelectedQuiet)\n }\n```\n" +// flowAnswer has a flow diagram with states, a branch and a loop back, and a block too large to +// draw, which shows as code (D284). +const flowAnswer = "The fix goes through review:\n\n```contr-flow\nissue -> plan: owner picks\nplan -> build\n" + + "build -> review: PR\nreview -> merge: approved\nreview -> build: blockers\nmerge -> release\nmerge [done]\nreview [waiting]\n```\n\n" + + "Too many steps for a drawing:\n\n```contr-flow\n" + bigFlow + "```\n" + // chipsText has each kind of chip in each state (D262), and names that stay text: an unknown // number, a name in code and a path that does not exist. const chipsText = "PRs: #668 open, #656 checks running, #667 passed, #664 failed, #663 merged, #660 closed, #661 draft. " + diff --git a/internal/app/diagram/diagram_test.go b/internal/app/diagram/diagram_test.go new file mode 100644 index 00000000..7d069ac8 --- /dev/null +++ b/internal/app/diagram/diagram_test.go @@ -0,0 +1,116 @@ +package diagram + +import ( + "errors" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +const branch = "issue -> plan: owner picks\nplan -> build\nbuild -> review: PR\n" + + "review -> merge: approved\nreview -> build: blockers\nmerge -> release\nmerge [done]\nreview [waiting]\n" + +func TestParse(t *testing.T) { + f, err := Parse(branch) + if err != nil { + t.Fatal(err) + } + if len(f.Nodes) != 6 || len(f.Edges) != 6 { + t.Fatalf("nodes %d, edges %d", len(f.Nodes), len(f.Edges)) + } + if e := f.Edges[0]; f.Nodes[e.From].Label != "issue" || f.Nodes[e.To].Label != "plan" || e.Label != "owner picks" { + t.Errorf("first edge %+v", e) + } + if f.Edges[1].Label != "" { + t.Errorf("an edge without a label has %q", f.Edges[1].Label) + } + if f.Nodes[4].Label != "merge" || f.Nodes[4].State != "done" || f.Nodes[3].State != "waiting" || f.Nodes[0].State != "" { + t.Errorf("states %+v", f.Nodes) + } + if f, err := Parse("a -> b: x: y"); err != nil || f.Edges[0].Label != "x: y" { + t.Errorf("a label with a colon: %+v %v", f, err) + } + if f, err := Parse("a [running]\n\n a -> b \n"); err != nil || f.Nodes[0].State != "running" || len(f.Nodes) != 2 { + t.Errorf("a state before the arrow, blank lines: %+v %v", f, err) + } +} + +func TestParseErrors(t *testing.T) { + for src, want := range map[string]error{ + "a -> b -> c": ErrChain, + "": ErrEmpty, + "a [done]\n": ErrEmpty, + } { + if _, err := Parse(src); !errors.Is(err, want) { + t.Errorf("%q: %v, want %v", src, err, want) + } + } + for _, src := range []string{"just words", "a -> b\nno arrow", "a [busy]"} { + if _, err := Parse(src); err == nil || !strings.Contains(err.Error(), "line") { + t.Errorf("%q: %v, want a line error", src, err) + } + } +} + +func TestLimits(t *testing.T) { + var b strings.Builder + for i := range MaxNodes - 1 { + b.WriteString(string(rune('a'+i)) + " -> " + string(rune('a'+i+1)) + "\n") + } + twelve, _ := Parse(b.String()) + if ok, _ := twelve.Fits(); !ok { + t.Error("12 nodes do not fit") + } + if ok, _ := Layout(twelve).Fits(); ok { + t.Error("12 layers fit") + } + thirteen, _ := Parse(b.String() + "l -> m\n") + if ok, _ := thirteen.Fits(); ok { + t.Error("13 nodes fit") + } + long, _ := Parse("a -> " + strings.Repeat("x", MaxLabel+1)) + if ok, _ := long.Fits(); ok { + t.Error("a long label fits") + } + fan, _ := Parse("a -> b\na -> c\na -> d\na -> e\na -> f\n") + if ok, _ := Layout(fan).Fits(); ok { + t.Error("5 nodes in a layer fit") + } + l := Layout(mustParse(t, branch)) + back := 0 + for _, x := range l.Back { + if x { + back++ + } + } + if back != 1 || len(l.Layers) != 6 { + t.Errorf("back edges %d, layers %d", back, len(l.Layers)) + } +} + +func mustParse(t *testing.T, s string) Flow { + t.Helper() + f, err := Parse(s) + if err != nil { + t.Fatal(err) + } + return f +} + +// View draws a flow that fits, and builds nothing for one wider than maxW. +func TestView(t *testing.T) { + f := mustParse(t, branch) + for _, tc := range []struct { + maxW float32 + want bool + }{{2000, true}, {200, false}} { + var got bool + tt := ui.NewTester(func(c *ui.Context) { got = View(c, f, tc.maxW, DefaultStyle) }, 1200, 400) + tt.Frame() + _, drawn := tt.Access(LabelDiagram) + if got != tc.want || drawn != tc.want { + t.Errorf("maxW %v: View %v, element %v; want %v", tc.maxW, got, drawn, tc.want) + } + } +} diff --git a/internal/app/diagram/layout.go b/internal/app/diagram/layout.go new file mode 100644 index 00000000..842ffbde --- /dev/null +++ b/internal/app/diagram/layout.go @@ -0,0 +1,148 @@ +package diagram + +import "sort" + +// The layout rule: layers left to right from a longest-path order, the nodes of a +// layer in the order that one barycenter sweep gives, fixed box heights, box widths from the text. +// Back edges of a cycle are drawn as arcs under the boxes. + +// Limits past which a diagram shows as its code block. +const ( + MaxNodes = 12 + MaxLayers = 6 + MaxRows = 4 // nodes in one layer + MaxLabel = 28 +) + +// Layered is a flow cut into layers: Layer[i] is the layer of node i, Row[i] its place in it. +// Ids from len(f.Nodes) on are virtual: the points where an edge that skips layers crosses one. +type Layered struct { + Layer, Row []int + Layers [][]int + Back []bool // per edge: an edge that goes back (a cycle), drawn as an arc + Via [][]int // per edge: its virtual points, in order +} + +// Fits reports whether the flow is small enough to draw, and the reason when it is not. +func (f Flow) Fits() (bool, string) { + switch { + case len(f.Nodes) > MaxNodes: + return false, "more than 12 nodes" + } + for _, n := range f.Nodes { + if len([]rune(n.Label)) > MaxLabel { + return false, "a label longer than 28 characters" + } + } + return true, "" +} + +// Layout cuts f into layers. +func Layout(f Flow) Layered { + n := len(f.Nodes) + out := make([]([]int), n) + for i, e := range f.Edges { + out[e.From] = append(out[e.From], i) + } + // Back edges: an edge to a node on the DFS stack, from the nodes in the order written. + back := make([]bool, len(f.Edges)) + mark := make([]int, n) // 0 new, 1 on the stack, 2 done + var dfs func(v int) + dfs = func(v int) { + mark[v] = 1 + for _, ei := range out[v] { + w := f.Edges[ei].To + switch mark[w] { + case 0: + dfs(w) + case 1: + back[ei] = true + } + } + mark[v] = 2 + } + for v := range n { + if mark[v] == 0 { + dfs(v) + } + } + // Longest path from the sources, without back edges; nodes are in DFS post order reversed. + layer := make([]int, n) + for changed := true; changed; { + changed = false + for i, e := range f.Edges { + if !back[i] && e.From != e.To && layer[e.To] < layer[e.From]+1 { + layer[e.To], changed = layer[e.From]+1, true + } + } + } + nl := 0 + for _, l := range layer { + nl = max(nl, l+1) + } + // Virtual points on edges that skip layers, and the segments of all forward edges. + via := make([][]int, len(f.Edges)) + type seg struct{ from, to int } + var segs []seg + for i, e := range f.Edges { + if back[i] || e.From == e.To { + continue + } + prev := e.From + for l := layer[e.From] + 1; l < layer[e.To]; l++ { + v := len(layer) + layer = append(layer, l) + via[i] = append(via[i], v) + segs = append(segs, seg{prev, v}) + prev = v + } + segs = append(segs, seg{prev, e.To}) + } + layers := make([][]int, nl) + for v, l := range layer { + layers[l] = append(layers[l], v) + } + row := make([]int, len(layer)) + place := func() { + for _, ls := range layers { + for r, v := range ls { + row[v] = r + } + } + } + place() + // One sweep: order each layer by the mean row of the nodes that point to it. + for l := 1; l < nl; l++ { + bary := map[int]float64{} + for _, v := range layers[l] { + sum, k := 0.0, 0 + for _, sg := range segs { + if sg.to == v { + sum += float64(row[sg.from]) + k++ + } + } + if k > 0 { + bary[v] = sum / float64(k) + } else { + bary[v] = float64(row[v]) + } + } + sort.SliceStable(layers[l], func(a, b int) bool { return bary[layers[l][a]] < bary[layers[l][b]] }) + place() + } + return Layered{Layer: layer, Row: row, Layers: layers, Back: back, Via: via} +} + +// Fits reports whether the layers fit the limits. +func (l Layered) Fits() (bool, string) { + if len(l.Layers) > MaxLayers { + return false, "more than 6 layers" + } + for _, ls := range l.Layers { + if len(ls) > MaxRows { + return false, "more than 4 nodes in a layer" + } + } + return true, "" +} diff --git a/internal/app/diagram/parse.go b/internal/app/diagram/parse.go new file mode 100644 index 00000000..da9c79ec --- /dev/null +++ b/internal/app/diagram/parse.go @@ -0,0 +1,86 @@ +// Package diagram draws the flow diagrams of Claude's answers (D284): a fenced contr-flow block, +// one arrow per line, drawn with MyGo's Painter. +package diagram + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +// Lang is the language of the fenced block that holds a flow. +const Lang = "contr-flow" + +// State is the state of a node: none, or one of the content palette (D261). +type State string + +// Flow is a flow diagram: nodes in order of first use, edges in the order written. +type Flow struct { + Nodes []Node + Edges []Edge +} + +// Node is a box of a flow. +type Node struct { + Label string + State State +} + +// Edge is an arrow of a flow, with an optional label. +type Edge struct { + From, To int + Label string +} + +// Errors of Parse. +var ( + ErrEmpty = errors.New("no arrow") + ErrChain = errors.New("more than one arrow on a line") +) + +var ( + // "Claude Code -> transcript.jsonl: writes"; a node name is any text without "->" or ":". + edgeRe = regexp.MustCompile(`^(.+?)\s*->\s*(.+?)(?:\s*:\s*(.+?))?\s*$`) + // "reader [running]": a node with a state. + stateRe = regexp.MustCompile(`^(.+?)\s*\[(done|running|waiting|failed|info)\]\s*$`) +) + +func (f *Flow) node(label string) int { + for i, n := range f.Nodes { + if n.Label == label { + return i + } + } + f.Nodes = append(f.Nodes, Node{Label: label}) + return len(f.Nodes) - 1 +} + +// Parse reads the text of a contr-flow block. A line is "A -> B", "A -> B: label" or "A [state]"; +// empty lines are skipped. A chain "A -> B -> C" is an error. +func Parse(src string) (Flow, error) { + var f Flow + for k, l := range strings.Split(src, "\n") { + l = strings.TrimSpace(l) + if l == "" { + continue + } + if m := stateRe.FindStringSubmatch(l); m != nil && !strings.Contains(m[1], "->") { + f.Nodes[f.node(m[1])].State = State(m[2]) + continue + } + m := edgeRe.FindStringSubmatch(l) + if m == nil { + return Flow{}, fmt.Errorf("line %d: not \"A -> B\" or \"A [state]\"", k+1) + } + if strings.Contains(m[2], "->") { + return Flow{}, fmt.Errorf("line %d: %w", k+1, ErrChain) + } + from, to := f.node(m[1]), f.node(m[2]) + f.Edges = append(f.Edges, Edge{From: from, To: to, Label: m[3]}) + } + if len(f.Edges) == 0 { + return Flow{}, ErrEmpty + } + return f, nil +} diff --git a/internal/app/diagram/view.go b/internal/app/diagram/view.go new file mode 100644 index 00000000..72ef5657 --- /dev/null +++ b/internal/app/diagram/view.go @@ -0,0 +1,264 @@ +package diagram + +import ( + "math" + "sort" + + "github.com/egoist/mygo/ui" + + "github.com/contursofran/contr/internal/app/theme" +) + +// Sizes of the drawing, in points. +const ( + boxMinH = 36 + boxPadX = 14 + boxMinW = 88 + boxRadius = theme.Radius8 + rowGap = 26 + colGapMin = 64 + headLen = 7 + backDrop = 26 // how far under the boxes a back edge goes +) + +// Style is the text of a diagram: the box text and the edge labels. The chat gives them at its +// text size (D272). +type Style struct { + Node, Label theme.TextStyle +} + +// DefaultStyle is the text at the base size: body text in the boxes, meta text on the edges. +var DefaultStyle = Style{Node: theme.StyleBody, Label: theme.StyleMeta} + +func (st Style) node(s string) ui.Span { + return ui.Span{Text: s, Font: theme.FontFamilySans, Size: st.Node.Size, Weight: st.Node.Weight} +} + +func (st Style) label(s string) ui.Span { + return ui.Span{Text: s, Font: theme.FontFamily, Size: st.Label.Size} +} + +func stateColor(pal *theme.Palette, s State) (ui.Color, bool) { + switch s { + case "done": + return pal.ContentDone, true + case "running": + return pal.ContentRunning, true + case "waiting": + return pal.ContentWaiting, true + case "failed": + return pal.ContentFailed, true + case "info": + return pal.ContentInfo, true + } + return ui.Color{}, false +} + +// head is an open arrow head at (x, y), pointing along (dx, dy). +func head(x, y, dx, dy float32) *ui.Path { + l := float32(math.Hypot(float64(dx), float64(dy))) + if l == 0 { + return &ui.Path{} + } + dx, dy = dx/l, dy/l + nx, ny := -dy, dx + const w = 3.5 + return (&ui.Path{}).MoveTo(x-dx*headLen+nx*w, y-dy*headLen+ny*w).LineTo(x, y). + LineTo(x-dx*headLen-nx*w, y-dy*headLen-ny*w) +} + +// LabelDiagram is the label of a drawn flow, for the screen reader and the tests. +const LabelDiagram = "Flow diagram" + +// View draws f, at most maxW wide, and returns true. It builds nothing and returns false when f +// is too large: more than MaxNodes nodes, past the layer limits or wider than maxW. +func View(c *ui.Context, f Flow, maxW float32, st Style) bool { + if ok, _ := f.Fits(); !ok { + return false + } + lay := Layout(f) + if ok, _ := lay.Fits(); !ok { + return false + } + boxH := max(boxMinH, st.Node.Line+18) + pal := theme.Current() + // Box widths from the text; a layer is as wide as its widest box. + nw := make([]float32, len(lay.Layer)) // a virtual point has no box + for i, n := range f.Nodes { + w, _ := c.MeasureText(0, st.node(n.Label)) + nw[i] = max(boxMinW, float32(math.Ceil(float64(w)))+2*boxPadX) + } + colW := make([]float32, len(lay.Layers)) + for l, ls := range lay.Layers { + for _, v := range ls { + colW[l] = max(colW[l], nw[v]) + } + } + // The gap after a layer holds the widest label of the edges that leave it. + gap := make([]float32, len(lay.Layers)) + for i := range gap { + gap[i] = colGapMin + } + for i, e := range f.Edges { + if e.Label == "" { + continue + } + w, _ := c.MeasureText(0, st.label(e.Label)) + if !lay.Back[i] { + gap[lay.Layer[e.From]] = max(gap[lay.Layer[e.From]], w+24) + } + } + colX := make([]float32, len(lay.Layers)) + x := float32(1) + for l := range lay.Layers { + colX[l] = x + x += colW[l] + gap[l] + } + totalW := x - gap[len(gap)-1] + 1 + if totalW > maxW { + return false + } + rows := 0 + for _, ls := range lay.Layers { + rows = max(rows, len(ls)) + } + pitch := float32(boxH + rowGap) + top := float32(4) + bodyH := float32(rows)*pitch - rowGap + hasBack := false + for _, b := range lay.Back { + hasBack = hasBack || b + } + totalH := top + bodyH + 4 + if hasBack { + totalH += backDrop + 8 + } + box := func(v int) ui.Rect { + l := lay.Layer[v] + n := len(lay.Layers[l]) + y := top + (bodyH-(float32(n)*pitch-rowGap))/2 + float32(lay.Row[v])*pitch + if v >= len(f.Nodes) { + return ui.Rect{X: colX[l], Y: y, W: colW[l], H: boxH} // the line crosses the layer + } + return ui.Rect{X: colX[l] + (colW[l]-nw[v])/2, Y: y, W: nw[v], H: boxH} + } + // The first and the last hop of a forward edge: a virtual point or the other end. + firstHop := func(i int) int { + if len(lay.Via[i]) > 0 { + return lay.Via[i][0] + } + return f.Edges[i].To + } + lastHop := func(i int) int { + if v := lay.Via[i]; len(v) > 0 { + return v[len(v)-1] + } + return f.Edges[i].From + } + // Ports: the edges that leave or enter one side of a box spread 8pt apart. + port := func(v int, out bool, ei int) float32 { + var mine []int + for i, e := range f.Edges { + if lay.Back[i] { + continue + } + if out && e.From == v || !out && e.To == v { + mine = append(mine, i) + } + } + sort.SliceStable(mine, func(a, b int) bool { + if out { + return lay.Row[firstHop(mine[a])] < lay.Row[firstHop(mine[b])] + } + return lay.Row[lastHop(mine[a])] < lay.Row[lastHop(mine[b])] + }) + k := 0 + for j, i := range mine { + if i == ei { + k = j + } + } + return (float32(k) - float32(len(mine)-1)/2) * 8 + } + ui.Box(c).Label(LabelDiagram).Width(totalW).Height(totalH).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + at := func(b ui.Rect) ui.Rect { b.X += r.X; b.Y += r.Y; return b } + type lbl struct { + x, y float32 + i int + } + var labels []lbl + for i, e := range f.Edges { + a, b := at(box(e.From)), at(box(e.To)) + var path *ui.Path + var hx, hy, hdx, hdy, mx, my float32 + if lay.Back[i] { + // Under the boxes: from the bottom of the source to the bottom of the target. + x0, x1 := a.X+a.W/2, b.X+b.W/2 + y0, y1 := a.Y+a.H, b.Y+b.H + low := r.Y + top + bodyH + backDrop + path = (&ui.Path{}).MoveTo(x0, y0).CubeTo(x0, low, x1, low, x1, y1+1) + hx, hy, hdx, hdy = x1, y1+1, 0, -1 + mx, my = (x0+x1)/2, low-2 + } else { + x0, y0 := a.X+a.W, a.Y+a.H/2+port(e.From, true, i) + x1, y1 := b.X-1, b.Y+b.H/2+port(e.To, false, i) + path = (&ui.Path{}).MoveTo(x0, y0) + px, py := x0, y0 + to := func(x, y float32) { + if y == py { + path.LineTo(x, y) + } else { + cx := (x - px) / 2 + path.CubeTo(px+cx, py, x-cx, y, x, y) + } + px, py = x, y + } + for _, v := range lay.Via[i] { + vb := at(box(v)) + to(vb.X, vb.Y+vb.H/2) + path.LineTo(vb.X+vb.W, vb.Y+vb.H/2) + px = vb.X + vb.W + } + to(x1, y1) + hdx, hdy = 1, 0 + hx, hy = x1, y1 + // The label sits on the middle of the first gap. + l := lay.Layer[e.From] + mx = r.X + colX[l] + colW[l] + gap[l]/2 + ny := y1 + if len(lay.Via[i]) > 0 { + vb := at(box(lay.Via[i][0])) + ny = vb.Y + vb.H/2 + } + my = (y0 + ny) / 2 + } + p.StrokePath(path, 1.2, pal.Ink30) + p.StrokePath(head(hx, hy, hdx, hdy), 1.2, pal.Ink48) + if e.Label != "" { + labels = append(labels, lbl{mx, my, i}) + } + } + for v, n := range f.Nodes { + b := at(box(v)) + if col, ok := stateColor(pal, n.State); ok { + p.Fill(b, theme.DiffTint(col), boxRadius) + p.Stroke(b, col, boxRadius, 1) + } else { + p.Fill(b, pal.Hover, boxRadius) + p.Stroke(b, pal.Ink30, boxRadius, 1) + } + s := st.node(n.Label) + s.Color = pal.Ink88 + w, h := p.MeasureText(0, s) + p.RichText(b.X+(b.W-w)/2, b.Y+(b.H-h)/2, 0, s) + } + // Labels last, on a patch of the surface, so a line under one does not cross its text. + for _, l := range labels { + s := st.label(f.Edges[l.i].Label) + s.Color, s.Background, s.PaddingX = pal.Ink48, pal.StageBase(), 3 + w, h := p.MeasureText(0, s) + p.RichText(l.x-w/2, l.y-h/2, 0, s) + } + }) + return true +}