diff --git a/cmd/contr/e2e_see_flow_test.go b/cmd/contr/e2e_see_flow_test.go index 51109a8c..df74626c 100644 --- a/cmd/contr/e2e_see_flow_test.go +++ b/cmd/contr/e2e_see_flow_test.go @@ -16,7 +16,13 @@ import ( ) // seeChatFlow spawns lead and writes a transcript whose answer has a flow diagram (D284). -func seeChatFlow(t *testing.T, h *harness) { +func seeChatFlow(t *testing.T, h *harness) { seeChatAnswer(t, h, flowTranscript()) } + +// seeChatSeq spawns lead and writes a transcript whose answer has a sequence diagram (D287). +func seeChatSeq(t *testing.T, h *harness) { seeChatAnswer(t, h, seqTranscript()) } + +// seeChatAnswer spawns lead and writes transcript as its session. +func seeChatAnswer(t *testing.T, h *harness, transcript string) { 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 { @@ -26,7 +32,7 @@ func seeChatFlow(t *testing.T, h *harness) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte(flowTranscript()), 0o644); err != nil { + if err := os.WriteFile(path, []byte(transcript), 0o644); err != nil { t.Fatal(err) } s := &session.Session{UpdatedAt: uint64(time.Now().Unix())} @@ -36,9 +42,23 @@ func seeChatFlow(t *testing.T, h *harness) { } } +// seqTranscript is a prompt and an answer with a contr-seq block: replies and a message to itself. +func seqTranscript() string { + return answerTranscript("who does what on a fix?", "Each step reports back:\n\n```contr-seq\n"+ + "owner -> lead: fix #696\nlead -> b-696: build step 2\nb-696 -> b-696: run check.sh\n"+ + "b-696 --> lead: PR #740 open\nlead --> owner: ready to merge\n```\n\nYou only answer the last one.") +} + // flowTranscript is a prompt and an answer with a contr-flow block: a branch, a loop back and // two states. func flowTranscript() string { + return answerTranscript("how does a fix get merged?", "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.") +} + +// answerTranscript is a prompt and one answer. +func answerTranscript(prompt, answer string) 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", @@ -47,9 +67,7 @@ func flowTranscript() string { 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."}}), + line(1, "user", prompt), + line(2, "assistant", []any{map[string]any{"type": "text", "text": answer}}), }, "") } diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index 32cd9e92..84725568 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -234,6 +234,12 @@ var seeFixtures = map[string]seeFixture{ ready: "lead, ", press: []string{"Chat", "wait:Flow diagram"}, }, + "chat-seq": { + about: "the orchestrator lead in Chat mode with an answer that holds a contr-seq block, drawn as a sequence diagram (D287): three actors with lifelines, messages, two dashed replies and a message to itself", + setup: seeChatSeq, + ready: "lead, ", + press: []string{"Chat", "wait:Sequence 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 558e2eb3..1e23747a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -258,7 +258,8 @@ 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. | +| 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 (D287). | | 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 (replaced by D288); 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). | +| D287 | 2026-11-11 | A fenced `contr-seq` block in Claude's answer draws as a sequence diagram (#696, PR 2; ChatView.md, Diagrams), in package `internal/app/diagram` like the flow (D284): actors as columns with a dashed lifeline, one line per message (`A -> B: text`, `A --> B: text` for a dashed reply, `A -> A: text` to itself), tokens only and the chat text size. A block that does not parse or is too large (more than 6 actors or 16 messages, an actor over 28 or a message over 40 characters, wider than the column) shows as its code block. The `chat-writing` skill tells Claude when and how to write a `contr-flow` and a `contr-seq` block, and that other diagrams stay as text. | Issue #696, step 2. | | D288 | 2026-11-11 | PR and issue chips have no checks dot; the Octicon's color shows the state (#741; docs/spec/ChatView.md, Chips and Blocks). It replaces the ink-48 icon and the dot of D286. While a PR is open its checks win: failed `content-failed`, running `content-running`, passed `content-done`; then open `content-done`, draft `ink-48`. Merged `content-link` and closed `ink-48`, so a closed PR does not read as failed. An issue: open `content-done`, closed `content-link`. The palette has no purple, so merged and a closed issue take `content-link`, the nearest hue; no token is added. The number keeps `content-link`. The tooltip and the label still name the state. The board's PR cell does the same. | The owner asked to remove the dots and show the state by color (#741). | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index 0a90c59a..462642a5 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), 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. +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` and `contr-seq` 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 | |---|---| @@ -306,7 +306,7 @@ Live and frozen: only the newest board of the thread is live. Under it, a 5px `c ## 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. +Claude writes a flow diagram as a fenced block `contr-flow` (D284) and a sequence diagram as a fenced block `contr-seq` (D287). contr draws them 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. @@ -314,6 +314,13 @@ Claude writes a flow diagram as a fenced block `contr-flow` (D284). contr draws - 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. +Sequence (`contr-seq`, D287): + +- Syntax: one line per message, top to bottom: `A -> B: text`, `A --> B: text` for a reply, `A -> A: text` for a message to itself. The text is required; an actor is any text without `:` or `->`, in order of first use, left to right. Empty lines are skipped. +- Actors: names in `body` strong (at the chat text size) in `ink-88`, centered over their lifeline, a dashed 1px `ink-30` line down to the end. Lifelines are at least 120px apart, and far enough apart for the names and the widest label between them. +- Message: one row each (at least 32px), a 1.2px `ink-48` line with an open `ink-48` head; a reply dashed; its label in mono `meta` (at the chat text size), `ink-64`, centered above the line. A message to itself is a small loop at the right of the lifeline, its label to the right of the loop. +- Falls back to the code block: a block that does not parse (a line without `->` or text, a chain, no message), more than 6 actors, more than 16 messages, an actor longer than 28 characters, a message longer than 40 characters, 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 01063b9f..ec9a97e9 100644 --- a/internal/app/chatview/content.go +++ b/internal/app/chatview/content.go @@ -130,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, diagram.Lang: ch.flowBlock} + code := map[string]func(*ui.Context, *markdown.CodeBlockView){"diff": ch.diffBlock, "patch": ch.diffBlock, diagram.Lang: ch.flowBlock, diagram.LangSeq: ch.seqBlock} 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 @@ -506,6 +506,20 @@ func (ch *Chat) flowBlock(c *ui.Context, b *markdown.CodeBlockView) { markdown.DefaultCodeBlock(c, b) } +// seqBlock is a contr-seq block drawn as a sequence diagram (D287), as flowBlock draws a flow. +func (ch *Chat) seqBlock(c *ui.Context, b *markdown.CodeBlockView) { + s, err := diagram.ParseSeq(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.SeqView(c, s, 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 d08342f9..1848771f 100644 --- a/internal/app/chatview/content_test.go +++ b/internal/app/chatview/content_test.go @@ -319,3 +319,29 @@ func TestFlowBlock(t *testing.T) { } } } + +// A contr-seq block draws as a sequence diagram (D287); one that does not parse or is too large +// shows as its code block. +func TestSeqBlock(t *testing.T) { + // Six actors with long messages between each pair: wider than the column. + var wide string + for _, p := range [][2]string{{"a", "b"}, {"b", "c"}, {"c", "d"}, {"d", "e"}, {"e", "f"}} { + wide += p[0] + " -> " + p[1] + ": a long message label of forty chars\n" + } + for _, tc := range []struct { + name, src string + drawn bool + }{ + {"good", "a -> b: go\nb --> a: done\nb -> b: think\n", true}, + {"bad", "a -> b\n", false}, + {"large", "a -> b: x\nb -> c: x\nc -> d: x\nd -> e: x\ne -> f: x\nf -> g: x\n", false}, + {"wide", wide, false}, + } { + tt, _ := contentRig(t, mygo.ThemeDark, "```contr-seq\n"+tc.src+"```\n") + _, drawn := tt.Access(diagram.LabelSeq) + _, code := tt.AccessByRole(ui.RoleGroup, "Code, contr-seq") + 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 eb5611b1..f27b9032 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -72,6 +72,7 @@ func TestSpecSheet(t *testing.T) { detail.s.ch.calls[id] = &open } flow := sheetChat(t, "idle", lPrompt, said("f1", flowAnswer)) + seq := sheetChat(t, "idle", lPrompt, said("s1", seqAnswer)) marks := sheetMarks(t) apptest.SpecSheet(t, []apptest.SheetPart{ {Name: "ChatView", W: sheetW, H: sheetH, View: working.view}, @@ -90,7 +91,8 @@ func TestSpecSheet(t *testing.T) { {Name: "CallDetail", W: sheetW, H: 360, View: detail.view}, {Name: "ChatWorkerMarks", W: sheetW, H: 600, View: marks.view}, {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"}) + {Name: "ChatSeq", W: sheetW, H: 620, View: seq.view}, + }, []string{"ChatSeq", "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, @@ -139,6 +141,12 @@ const flowAnswer = "The fix goes through review:\n\n```contr-flow\nissue -> plan "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" +// seqAnswer has a sequence diagram with replies and a message to itself, and a block that does +// not parse, which shows as code (D287). +const seqAnswer = "How a fix reaches you:\n\n```contr-seq\nowner -> lead: fix #696\nlead -> b-696: build step 2\n" + + "b-696 -> b-696: run check.sh\nb-696 --> lead: PR #740 open\nlead --> owner: ready to merge\n```\n\n" + + "A message without text stays code:\n\n```contr-seq\nlead -> owner\n```\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/parse.go b/internal/app/diagram/parse.go index da9c79ec..d2ad45d7 100644 --- a/internal/app/diagram/parse.go +++ b/internal/app/diagram/parse.go @@ -1,5 +1,5 @@ -// 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 draws the diagrams of Claude's answers: a fenced contr-flow block (D284), +// one arrow per line, and a contr-seq block (D287), one message per line, drawn with MyGo's Painter. package diagram import ( diff --git a/internal/app/diagram/seq.go b/internal/app/diagram/seq.go new file mode 100644 index 00000000..2182ef49 --- /dev/null +++ b/internal/app/diagram/seq.go @@ -0,0 +1,208 @@ +package diagram + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/egoist/mygo/ui" + + "github.com/contursofran/contr/internal/app/theme" +) + +// LangSeq is the language of the fenced block that holds a sequence diagram (D287). +const LangSeq = "contr-seq" + +// LabelSeq is the label of a drawn sequence, for the screen reader and the tests. +const LabelSeq = "Sequence diagram" + +// Limits past which a sequence shows as its code block. +const ( + MaxActors = 6 + MaxMsgs = 16 + MaxMsgLabel = 40 +) + +// Seq is a sequence diagram: actors left to right in order of first use, messages top to bottom. +type Seq struct { + Actors []string + Msgs []Msg +} + +// Msg is one arrow of a sequence. Reply is a dashed arrow. From == To is a message to itself. +type Msg struct { + From, To int + Label string + Reply bool +} + +// ErrNoMsg is the error of ParseSeq for a block without a message. +var ErrNoMsg = errors.New("no message") + +// Was: or "b-649 --> lead: done" (a reply). +var msgRe = regexp.MustCompile(`^([^:]+?)\s*(-->|->)\s*([^:\s][^:]*?)\s*:\s*(.+?)\s*$`) + +func (s *Seq) actor(name string) int { + for i, a := range s.Actors { + if a == name { + return i + } + } + s.Actors = append(s.Actors, name) + return len(s.Actors) - 1 +} + +// ParseSeq reads the text of a contr-seq block. A line is "A -> B: text" or "A --> B: text" (a +// reply); the text is required; empty lines are skipped. +func ParseSeq(src string) (Seq, error) { + var s Seq + for k, l := range strings.Split(src, "\n") { + l = strings.TrimSpace(l) + if l == "" { + continue + } + m := msgRe.FindStringSubmatch(l) + if m == nil || strings.Contains(m[3], "->") { + return Seq{}, fmt.Errorf("line %d: not \"A -> B: text\" or \"A --> B: text\"", k+1) + } + from, to := s.actor(m[1]), s.actor(m[3]) + s.Msgs = append(s.Msgs, Msg{From: from, To: to, Label: m[4], Reply: m[2] == "-->"}) + } + if len(s.Msgs) == 0 { + return Seq{}, ErrNoMsg + } + return s, nil +} + +// Fits reports whether the sequence is small enough to draw, and the reason when it is not. +func (s Seq) Fits() (bool, string) { + switch { + case len(s.Actors) > MaxActors: + return false, "more than 6 actors" + case len(s.Msgs) > MaxMsgs: + return false, "more than 16 messages" + } + for _, a := range s.Actors { + if len([]rune(a)) > MaxLabel { + return false, "an actor longer than 28 characters" + } + } + for _, m := range s.Msgs { + if len([]rune(m.Label)) > MaxMsgLabel { + return false, "a message longer than 40 characters" + } + } + return true, "" +} + +// Sizes of a sequence, in points. +const ( + laneGap = 40 // the least room between two lifelines, beyond the actor names + laneMin = 120 + edgeMin = 40 // the least room left of the first and right of the last lifeline + loopW = 24 // the width of a message to itself + labelGap = 24 +) + +// SeqView draws s, at most maxW wide, and returns true: actor names on top, a dashed lifeline +// under each, one arrow per message top to bottom. It builds nothing and returns false when s is +// too large or wider than maxW. +func SeqView(c *ui.Context, s Seq, maxW float32, st Style) bool { + if ok, _ := s.Fits(); !ok { + return false + } + pal := theme.Current() + actor := func(a string) ui.Span { + sp := st.node(a) + sp.Weight = theme.StyleBodyStrong.Weight + return sp + } + aw := make([]float32, len(s.Actors)) + for i, a := range s.Actors { + aw[i], _ = c.MeasureText(0, actor(a)) + } + _, lh := c.MeasureText(0, st.label("M")) + _, ah := c.MeasureText(0, actor("M")) + actorH := ah + 12 + pitch := max(32, lh+20) + // The room between two neighbor lanes holds the names and the widest label between them. + last := len(s.Actors) - 1 + room := make([]float32, last) + for i := range room { + room[i] = max(laneMin, (aw[i]+aw[i+1])/2+laneGap) + } + left, right := max(aw[0]/2, edgeMin), max(aw[last]/2, edgeMin) + for _, m := range s.Msgs { + w, _ := c.MeasureText(0, st.label(m.Label)) + lo, hi := min(m.From, m.To), max(m.From, m.To) + if lo == hi { + // A message to itself: the loop and its label at the right of the lane. + need := loopW + 6 + w + labelGap/2 + if lo == last { + right = max(right, need) + } else { + room[lo] = max(room[lo], need) + } + continue + } + per := (w + labelGap) / float32(hi-lo) + for k := lo; k < hi; k++ { + room[k] = max(room[k], per) + } + } + laneX := make([]float32, len(s.Actors)) + x := left + for i := range s.Actors { + laneX[i] = x + if i < last { + x += room[i] + } + } + totalW := x + right + if totalW > maxW { + return false + } + totalH := actorH + 8 + float32(len(s.Msgs))*pitch + 6 + ui.Box(c).Label(LabelSeq).Width(totalW).Height(totalH).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + for i, a := range s.Actors { + sp := actor(a) + sp.Color = pal.Ink88 + w, h := p.MeasureText(0, sp) + lx := r.X + laneX[i] + p.RichText(lx-w/2, r.Y+(actorH-h)/2, 0, sp) + life := (&ui.Path{}).MoveTo(lx, r.Y+actorH+2).LineTo(lx, r.Y+totalH).Cap(ui.ButtCap).Dash(0, 3, 4) + p.StrokePath(life, 1, pal.Ink30) + } + for k, m := range s.Msgs { + // The arrow sits low in its row, the label above it. + y := r.Y + actorH + 8 + float32(k)*pitch + pitch - 8 + x0, x1 := r.X+laneX[m.From], r.X+laneX[m.To] + sp := st.label(m.Label) + sp.Color = pal.Ink64 + w, h := p.MeasureText(0, sp) + if m.From == m.To { + loop := (&ui.Path{}).MoveTo(x0+3, y-12).LineTo(x0+loopW, y-12).LineTo(x0+loopW, y).LineTo(x0+4, y) + if m.Reply { + loop.Dash(0, 4, 3) + } + p.StrokePath(loop, 1.2, pal.Ink48) + p.StrokePath(head(x0+4, y, -1, 0), 1.2, pal.Ink48) + p.RichText(x0+loopW+6, y-6-h/2, 0, sp) + continue + } + dir := float32(1) + if x1 < x0 { + dir = -1 + } + line := (&ui.Path{}).MoveTo(x0+dir*3, y).LineTo(x1-dir*3, y).Cap(ui.ButtCap) + if m.Reply { + line.Dash(0, 4, 3) + } + p.StrokePath(line, 1.2, pal.Ink48) + p.StrokePath(head(x1-dir*3, y, dir, 0), 1.2, pal.Ink48) + p.RichText((x0+x1)/2-w/2, y-h-3, 0, sp) + } + }) + return true +} diff --git a/internal/app/diagram/seq_test.go b/internal/app/diagram/seq_test.go new file mode 100644 index 00000000..433c6bc9 --- /dev/null +++ b/internal/app/diagram/seq_test.go @@ -0,0 +1,97 @@ +package diagram + +import ( + "errors" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +const review = "owner -> lead: fix #696\nlead -> b-696: build step 2\nb-696 -> b-696: run check.sh\n" + + "b-696 --> lead: PR #740 open\nlead --> owner: ready to merge\n" + +func TestParseSeq(t *testing.T) { + s, err := ParseSeq(review) + if err != nil { + t.Fatal(err) + } + if strings.Join(s.Actors, ",") != "owner,lead,b-696" || len(s.Msgs) != 5 { + t.Fatalf("actors %v, messages %d", s.Actors, len(s.Msgs)) + } + if m := s.Msgs[0]; m.From != 0 || m.To != 1 || m.Label != "fix #696" || m.Reply { + t.Errorf("first message %+v", m) + } + if m := s.Msgs[2]; m.From != 2 || m.To != 2 { + t.Errorf("a message to itself %+v", m) + } + if m := s.Msgs[3]; !m.Reply || m.From != 2 || m.To != 1 { + t.Errorf("a reply %+v", m) + } + if s, err := ParseSeq("\n a -> b: x: y \n\n"); err != nil || s.Msgs[0].Label != "x: y" { + t.Errorf("a label with a colon, blank lines: %+v %v", s, err) + } +} + +func TestParseSeqErrors(t *testing.T) { + for _, src := range []string{"", "\n \n"} { + if _, err := ParseSeq(src); !errors.Is(err, ErrNoMsg) { + t.Errorf("%q: %v, want %v", src, err, ErrNoMsg) + } + } + for _, src := range []string{"a -> b", "just words", "a -> b: x\na -> b -> c: y", "a -> : x"} { + if _, err := ParseSeq(src); err == nil || !strings.Contains(err.Error(), "line") { + t.Errorf("%q: %v, want a line error", src, err) + } + } +} + +func TestSeqLimits(t *testing.T) { + var b strings.Builder + for i := range MaxActors - 1 { + b.WriteString(string(rune('a'+i)) + " -> " + string(rune('a'+i+1)) + ": x\n") + } + six, _ := ParseSeq(b.String()) + if ok, _ := six.Fits(); !ok { + t.Error("6 actors do not fit") + } + seven, _ := ParseSeq(b.String() + "f -> g: x\n") + if ok, _ := seven.Fits(); ok { + t.Error("7 actors fit") + } + many, _ := ParseSeq(strings.Repeat("a -> b: x\n", MaxMsgs)) + if ok, _ := many.Fits(); !ok { + t.Error("16 messages do not fit") + } + many, _ = ParseSeq(strings.Repeat("a -> b: x\n", MaxMsgs+1)) + if ok, _ := many.Fits(); ok { + t.Error("17 messages fit") + } + long, _ := ParseSeq("a -> b: " + strings.Repeat("x", MaxMsgLabel+1)) + if ok, _ := long.Fits(); ok { + t.Error("a long message fits") + } + name, _ := ParseSeq(strings.Repeat("x", MaxLabel+1) + " -> b: y") + if ok, _ := name.Fits(); ok { + t.Error("a long actor fits") + } +} + +// SeqView draws a sequence that fits, and builds nothing for one too large or wider than maxW. +func TestSeqView(t *testing.T) { + s, _ := ParseSeq(review) + big, _ := ParseSeq(strings.Repeat("a -> b: x\n", MaxMsgs+1)) + for _, tc := range []struct { + s Seq + maxW float32 + want bool + }{{s, 2000, true}, {s, 200, false}, {big, 2000, false}} { + var got bool + tt := ui.NewTester(func(c *ui.Context) { got = SeqView(c, tc.s, tc.maxW, DefaultStyle) }, 1200, 600) + tt.Frame() + _, drawn := tt.Access(LabelSeq) + if got != tc.want || drawn != tc.want { + t.Errorf("maxW %v: SeqView %v, element %v; want %v", tc.maxW, got, drawn, tc.want) + } + } +} diff --git a/kit/skills/chat-writing/SKILL.md b/kit/skills/chat-writing/SKILL.md index 9b6d680e..06967ec1 100644 --- a/kit/skills/chat-writing/SKILL.md +++ b/kit/skills/chat-writing/SKILL.md @@ -84,9 +84,30 @@ For a merge order, a `contr-queue` block, one line `#