From 9d01cf9c5de10177ce3b01c42758c10e5bd68fc9 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 14:42:29 -0300 Subject: [PATCH 01/14] Chat: @ in the composer completes file paths (#708, D279) --- internal/app/chat.go | 63 ++++++++ internal/app/chatpaths_test.go | 73 +++++++++ internal/app/chatview/chat.go | 3 + internal/app/chatview/composer.go | 1 + internal/app/chatview/pathsuggest_test.go | 119 +++++++++++++++ internal/app/chatview/suggest.go | 176 +++++++++++++++++----- internal/review/tree.go | 20 +++ 7 files changed, 416 insertions(+), 39 deletions(-) create mode 100644 internal/app/chatpaths_test.go create mode 100644 internal/app/chatview/pathsuggest_test.go diff --git a/internal/app/chat.go b/internal/app/chat.go index f27736cd..46f4a7f2 100644 --- a/internal/app/chat.go +++ b/internal/app/chat.go @@ -4,9 +4,12 @@ package app // chatview. import ( + "context" + "errors" "log" "os" "strings" + "time" "github.com/egoist/mygo/ui" @@ -15,6 +18,7 @@ import ( "github.com/contursofran/contr/internal/app/chatview" "github.com/contursofran/contr/internal/app/questions" "github.com/contursofran/contr/internal/app/view" + "github.com/contursofran/contr/internal/review" "github.com/contursofran/contr/internal/slash" ) @@ -41,6 +45,64 @@ func (s slashSource) Suggestions(addr string) func() ([]chatview.Suggestion, err } } +// pathSource gives the "@" popup the files and folders of the working folder of the session at +// an address (D279): what git lists there, or a plain read of the typed folder outside git. +type pathSource struct { + worker func(addr string) (agents.Worker, bool) +} + +// Paths finds the folder on the UI thread; the read runs off it. +func (s pathSource) Paths(addr, dir string) func() ([]chatview.Suggestion, error) { + var root string + if w, ok := s.worker(addr); ok { + root = w.Worktree + } + return func() ([]chatview.Suggestion, error) { + if root == "" { + return nil, errors.New("no working folder") + } + ctx, cancel := context.WithTimeout(context.Background(), pathTimeout) + defer cancel() + return listPaths(ctx, root, dir) + } +} + +// pathTimeout bounds the read of the paths for "@". +const pathTimeout = 10 * time.Second + +// listPaths lists the files of root that git knows or does not ignore, each folder that holds +// them once, as "dir/", before its files. Outside git it reads the folder dir of root alone. +func listPaths(ctx context.Context, root, dir string) ([]chatview.Suggestion, error) { + repo := review.Repo{Dir: root} + files, ok, err := repo.KnownFiles(ctx) + if err != nil { + return nil, err + } + var out []chatview.Suggestion + if !ok { + es, err := repo.Entries(ctx, strings.TrimSuffix(dir, "/")) + for _, e := range es { + name := dir + e.Name + if e.Dir { + name += "/" + } + out = append(out, chatview.Suggestion{Name: name}) + } + return out, err + } + seen := map[string]bool{} + for _, f := range files { + for i := range len(f) { + if d := f[:i+1]; f[i] == '/' && !seen[d] { + seen[d] = true + out = append(out, chatview.Suggestion{Name: d}) + } + } + out = append(out, chatview.Suggestion{Name: f}) + } + return out, nil +} + // attachChat makes the chat view, which reads the session files under the state dir of the // session panel. Its composer sends and interrupts through the agent source, as the orchestrate // pill and the questions card do (D252); without a source it sends nothing. The stop ends its @@ -65,6 +127,7 @@ func (f *Frame) attachChat(env view.Env, src agentstate.AgentSource) (stop func( } else { log.Printf("chat: no home folder, no slash popup: %v", err) } + f.Chat.Path = pathSource{worker: f.agentAt} if f.Outline != nil { f.Outline.Chat = f.chatOutline } diff --git a/internal/app/chatpaths_test.go b/internal/app/chatpaths_test.go new file mode 100644 index 00000000..74ad16e7 --- /dev/null +++ b/internal/app/chatpaths_test.go @@ -0,0 +1,73 @@ +package app + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" +) + +func names(t *testing.T, root, dir string) []string { + t.Helper() + list, err := listPaths(context.Background(), root, dir) + if err != nil { + t.Fatal(err) + } + var out []string + for _, it := range list { + out = append(out, it.Name) + } + return out +} + +func write(t *testing.T, root string, files ...string) { + t.Helper() + for _, f := range files { + p := filepath.Join(root, filepath.FromSlash(f)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestListPathsInGit(t *testing.T) { + root := t.TempDir() + write(t, root, ".gitignore", "a/b.go", "c.md", "out/x.log", "new/n.go") + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("out/\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"init", "-q"}, {"add", ".gitignore", "a", "c.md"}} { + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v %s", args, err, out) + } + } + got := names(t, root, "") + want := []string{".gitignore", "a/", "a/b.go", "c.md", "new/", "new/n.go"} + if !reflect.DeepEqual(got, want) { + t.Errorf("paths = %q, want %q (tracked and untracked, not ignored)", got, want) + } +} + +func TestListPathsOutsideGitReadsTheTypedFolder(t *testing.T) { + root := t.TempDir() + write(t, root, "a/b.go", "a/sub/c.go", "d.md") + if got, want := names(t, root, ""), []string{"a/", "d.md"}; !reflect.DeepEqual(got, want) { + t.Errorf("root = %q, want %q", got, want) + } + if got, want := names(t, root, "a/"), []string{"a/sub/", "a/b.go"}; !reflect.DeepEqual(got, want) { + t.Errorf("a/ = %q, want %q", got, want) + } +} + +func TestListPathsOfAnEmptyFolder(t *testing.T) { + if got := names(t, t.TempDir(), ""); len(got) != 0 { + t.Errorf("paths = %q, want none", got) + } +} diff --git a/internal/app/chatview/chat.go b/internal/app/chatview/chat.go index 0d5db2c5..f1984796 100644 --- a/internal/app/chatview/chat.go +++ b/internal/app/chatview/chat.go @@ -54,6 +54,9 @@ type Chat struct { // Slash gives the slash commands and skills of the session at addr for the composer's popup // (D273). Nil: "/" opens no popup. Slash SuggestSource + // Path gives the files and folders of the session at addr for the "@" popup (D279); nil + // shows none. + Path PathSource // sg is the state of the composer's suggestion popup. sg suggest diff --git a/internal/app/chatview/composer.go b/internal/app/chatview/composer.go index de1a3edf..52361d5e 100644 --- a/internal/app/chatview/composer.go +++ b/internal/app/chatview/composer.go @@ -148,6 +148,7 @@ func (ch *Chat) composer(c *ui.Context, id string, st liveState) { in.SetTextSelection(n, n) ch.sg.caret = 0 } + _, ch.sg.at = in.TextSelection() if in.Changed() { delete(ch.errs, id) } diff --git a/internal/app/chatview/pathsuggest_test.go b/internal/app/chatview/pathsuggest_test.go new file mode 100644 index 00000000..38ab73d0 --- /dev/null +++ b/internal/app/chatview/pathsuggest_test.go @@ -0,0 +1,119 @@ +package chatview + +import ( + "reflect" + "strconv" + "testing" + + "github.com/egoist/mygo/ui" +) + +// fakePaths is a PathSource with a fixed list; dirs holds the folders read. +type fakePaths struct { + items []Suggestion + dirs []string +} + +func (f *fakePaths) Paths(addr, dir string) func() ([]Suggestion, error) { + return func() ([]Suggestion, error) { f.dirs = append(f.dirs, dir); return f.items, nil } +} + +func pathSession(t *testing.T) (*sess, *fakePaths) { + s := newSession(t, "idle") + f := &fakePaths{items: []Suggestion{{Name: "cmd/"}, {Name: "cmd/main.go"}, {Name: "docs/"}, + {Name: "docs/spec/"}, {Name: "docs/spec/ChatView.md"}, {Name: "README.md"}}} + s.ch.Path = f + return s, f +} + +func TestQueryAtCaret(t *testing.T) { + for _, c := range []struct { + text string + caret int + ok bool + q Query + }{ + {"@", 1, true, Query{Mark: '@', Text: "", Start: 0, Caret: 1}}, + {"see @do", 7, true, Query{Mark: '@', Text: "do", Start: 4, Caret: 7}}, + {"see @docs/sp and x", 12, true, Query{Mark: '@', Text: "docs/sp", Start: 4, Caret: 12}}, + {"see @do x", 9, false, Query{}}, + {"a@b", 3, false, Query{}}, + {"plain", 5, false, Query{}}, + {"/co", 3, true, Query{Mark: '/', Text: "co", Caret: 3}}, + {"/co", 1, false, Query{}}, + } { + q, ok := QueryAt(c.text, c.caret) + if ok != c.ok || q != c.q { + t.Errorf("QueryAt(%q, %d) = %+v, %v; want %+v, %v", c.text, c.caret, q, ok, c.q, c.ok) + } + } +} + +func TestFilterPathsOrderAndCap(t *testing.T) { + items := []Suggestion{{Name: "internal/review/tree.go"}, {Name: "docs/spec/ChatView.md"}, {Name: "spec.md"}, {Name: "x/inspect.go"}} + var names []string + for _, it := range FilterPaths(items, "spec") { + names = append(names, it.Name) + } + if want := []string{"docs/spec/ChatView.md", "spec.md", "x/inspect.go"}; !reflect.DeepEqual(names, want) { + t.Errorf("FilterPaths = %v, want %v", names, want) + } + var many []Suggestion + for i := range maxPaths + 10 { + many = append(many, Suggestion{Name: "f" + strconv.Itoa(i)}) + } + if n := len(FilterPaths(many, "")); n != maxPaths { + t.Errorf("FilterPaths kept %d, want %d", n, maxPaths) + } +} + +func TestAtInsertsAFile(t *testing.T) { + s, _ := pathSession(t) + s.typeIn("see @main") + if !s.has(LabelSuggest) || !s.has("@cmd/main.go") || s.has("@README.md") { + t.Fatal("the popup does not show the match of @main") + } + s.key(0, ui.KeyTab) + if s.draft() != "see @cmd/main.go " || s.has(LabelSuggest) { + t.Fatalf("after Tab: draft %q, popup %v", s.draft(), s.has(LabelSuggest)) + } +} + +func TestAtInsertsAFolderAndStaysOpen(t *testing.T) { + s, f := pathSession(t) + s.typeIn("@sp") + s.key(0, ui.KeyEnter) + if s.draft() != "@docs/spec/" || len(s.sent) != 0 { + t.Fatalf("after Enter: draft %q, sent %q", s.draft(), s.sent) + } + if !s.has(LabelSuggest) || !s.has("@docs/spec/ChatView.md") { + t.Fatal("the popup does not stay open for the folder") + } + if want := []string{"", "docs/spec/"}; !reflect.DeepEqual(f.dirs, want) { + t.Errorf("folders read = %q, want %q", f.dirs, want) + } + s.key(0, ui.KeyEscape) + if s.has(LabelSuggest) { + t.Error("Esc does not close the popup") + } +} + +func TestInsertKeepsTheTextAfterTheCaret(t *testing.T) { + ch := &Chat{} + text := "look at @do then" + q, ok := QueryAt(text, 11) + if !ok { + t.Fatal("no query") + } + if got := ch.insert(text, q, Suggestion{Name: "docs/a.md"}); got != "look at @docs/a.md then" || ch.sg.caret != 19 { + t.Errorf("insert = %q, caret %d", got, ch.sg.caret) + } +} + +func TestAtWithNoMatchSaysSo(t *testing.T) { + s, _ := pathSession(t) + s.typeIn("@zzz") + if !s.has(LabelSuggest) || !s.has(TextNoPath) { + t.Error("the popup does not say that no file matches") + } +} diff --git a/internal/app/chatview/suggest.go b/internal/app/chatview/suggest.go index f747b3b7..f1ef8b5f 100644 --- a/internal/app/chatview/suggest.go +++ b/internal/app/chatview/suggest.go @@ -1,9 +1,10 @@ package chatview -// The suggestion popup of the composer (D273): "/" at the start of the field opens the session's -// slash commands and skills above the card, filtered as the owner types. Up and Down move, Enter -// or Tab puts "/name " in the field, Esc closes. The list comes from a SuggestSource, so the -// path popup of "@" can use the same popup with another source. +// The suggestion popup of the composer (D273, D279): "/" at the start of the field opens the +// session's slash commands and skills above the card, and "@" at the caret (at the start or after +// a space) opens the files and folders of its working folder, filtered as the owner types. Up and +// Down move, Enter or Tab puts "/name " or "@path " in the field, Esc closes. A folder goes in as +// "@dir/" and the popup stays open for its content. import ( "strings" @@ -21,6 +22,8 @@ const ( LabelSuggest = "suggestions" // TextNoMatch shows when no item matches what was typed. TextNoMatch = "No commands match" + // TextNoPath shows when no file or folder matches what was typed after "@". + TextNoPath = "No files match" // TextLoading shows while the list is read. TextLoading = "Loading…" ) @@ -31,6 +34,8 @@ const ( suggestRows = 8 // the list scrolls past this many rows suggestPadY = 12 // the room above and below the texts of a row suggestGap = theme.Space6 + // maxPaths is the most paths the popup shows for "@". + maxPaths = 50 ) // Suggestion is an item of the popup: the name it inserts and a one-line description. @@ -44,6 +49,64 @@ type SuggestSource interface { Suggestions(addr string) func() ([]Suggestion, error) } +// PathSource gives the files and folders of the working folder of the session at addr for "@". +// Names are slash-separated and relative to that folder; a folder ends in "/". dir is the folder +// typed so far ("" or ending in "/"), for a source that reads one folder at a time. Paths runs on +// the UI thread and returns the read, which runs off it. +type PathSource interface { + Paths(addr, dir string) func() ([]Suggestion, error) +} + +// Query is what is typed at the caret for the popup: Mark is '/' or '@', Text is what follows it +// up to the caret, and Start and Caret are rune offsets of the mark and the caret. +type Query struct { + Mark rune + Text string + Start, Caret int +} + +// QueryAt finds the query at caret, a rune offset in text: a "/" command (see SlashQuery) with the +// caret at its end, or an "@" at the start or after a space with no space between it and the +// caret. ok is false when no query is typed there. +func QueryAt(text string, caret int) (Query, bool) { + r := []rune(text) + caret = min(max(caret, 0), len(r)) + if q, ok := SlashQuery(text); ok && caret == len(r) { + return Query{Mark: '/', Text: q, Caret: caret}, true + } + i := caret + for i > 0 && !strings.ContainsRune(" \t\n", r[i-1]) { + i-- + } + if i == caret || r[i] != '@' { + return Query{}, false + } + return Query{Mark: '@', Text: string(r[i+1 : caret]), Start: i, Caret: caret}, true +} + +// FilterPaths gives at most maxPaths items whose names hold query, without regard to case: the +// paths with a part that starts with it first, each group in the order of items. +func FilterPaths(items []Suggestion, query string) []Suggestion { + q := strings.ToLower(query) + var starts, holds []Suggestion + for _, it := range items { + n := strings.ToLower(it.Name) + switch { + case strings.HasPrefix(n, q) || strings.Contains(n, "/"+q): + starts = append(starts, it) + case strings.Contains(n, q): + holds = append(holds, it) + } + } + out := append(starts, holds...) + return out[:min(len(out), maxPaths)] +} + +// pathDir is the folder part of a path query: "" or the text up to its last "/". +func pathDir(q string) string { + return q[:strings.LastIndex(q, "/")+1] +} + // suggest is the popup's state for the composer. type suggest struct { // items and errs hold the read list and its failure for each terminal; loading is set while a @@ -56,8 +119,9 @@ type suggest struct { closed string open bool list ui.ListState - // caret is where the field's caret goes as it builds after an insert, 0 for no move. - caret int + // caret is where the field's caret goes as it builds after an insert, 0 for no move; at is + // where the caret was as the field last built. + caret, at int } // SlashQuery is the text after a "/" that starts text, with ok false when text is not a command @@ -86,44 +150,64 @@ func Filter(items []Suggestion, query string) []Suggestion { return append(starts, holds...) } -// suggestOpen tells whether the popup shows for text in terminal id: a command is typed, a source -// is set and Esc did not close it on this text. -func (ch *Chat) suggestOpen(text string) bool { - _, ok := SlashQuery(text) - return ok && ch.Slash != nil && ch.sg.closed != text +// query is the query at the caret of text, with ok false when the popup does not show for it: +// nothing is typed, its source is not set or Esc closed it on this text. +func (ch *Chat) query(text string) (Query, bool) { + q, ok := QueryAt(text, ch.sg.at) + if !ok || ch.sg.closed == text || (q.Mark == '/' && ch.Slash == nil) || (q.Mark == '@' && ch.Path == nil) { + return Query{}, false + } + return q, true } -// suggestions are the items of terminal id that match text. The first call reads the list off -// the UI thread. -func (ch *Chat) suggestions(id, text string) []Suggestion { - q, _ := SlashQuery(text) - items, ok := ch.sg.items[id] - if !ok && !ch.sg.loading[id] && ch.Slash != nil { - ch.sg.loading[id] = true - read := ch.Slash.Suggestions(id) +// listKey is the key of the list of q in the popup's maps: one for "/", one for each folder of "@". +func listKey(id string, q Query) string { + if q.Mark == '@' { + return "@" + id + "\x00" + pathDir(q.Text) + } + return "/" + id +} + +// suggestions are the items of terminal id that match q. The first call for a list reads it off +// the UI thread: once for "/", once for each folder typed after "@". +func (ch *Chat) suggestions(id string, q Query) []Suggestion { + key := listKey(id, q) + items, ok := ch.sg.items[key] + if !ok && !ch.sg.loading[key] { + ch.sg.loading[key] = true + var read func() ([]Suggestion, error) + if q.Mark == '@' { + read = ch.Path.Paths(id, pathDir(q.Text)) + } else { + read = ch.Slash.Suggestions(id) + } ch.Env.Do(func() func() { items, err := read() return func() { - delete(ch.sg.loading, id) - ch.sg.items[id] = items + delete(ch.sg.loading, key) + ch.sg.items[key] = items if err != nil { - ch.sg.errs[id] = err.Error() + ch.sg.errs[key] = err.Error() } ch.Env.Redraw() } }) } - return Filter(items, q) + if q.Mark == '@' { + return FilterPaths(items, q.Text) + } + return Filter(items, q.Text) } // suggestKey takes a key of the field while the popup shows: Up and Down move, Enter and Tab put // the selected item in the field, Esc closes. It returns false for other keys. func (ch *Chat) suggestKey(id string, k ui.TextKey) bool { text := k.Text() - if k.Mods != 0 || !ch.suggestOpen(text) { + q, ok := ch.query(text) + if k.Mods != 0 || !ok { return false } - list := ch.suggestions(id, text) + list := ch.suggestions(id, q) switch k.Key { case ui.KeyUp, ui.KeyDown: if len(list) > 0 { @@ -139,7 +223,7 @@ func (ch *Chat) suggestKey(id string, k ui.TextKey) bool { if ch.sg.sel >= len(list) { return k.Key == ui.KeyTab // Enter with no match sends the text as typed } - k.SetText(ch.insert(list[ch.sg.sel])) + k.SetText(ch.insert(text, q, list[ch.sg.sel])) return true case ui.KeyEscape: ch.sg.closed = text @@ -148,11 +232,19 @@ func (ch *Chat) suggestKey(id string, k ui.TextKey) bool { return false } -// insert is the field's text that picks it, "/name ", with the caret set to go after it. -func (ch *Chat) insert(it Suggestion) string { - text := "/" + it.Name + " " - ch.sg.sel, ch.sg.caret = 0, utf8.RuneCountInString(text) - return text +// insert is text with the query q replaced by the mark and the name of it, "/name " or "@path ", +// with the caret set to go after it. A folder, "@dir/", gets no space, so the popup stays open +// for its content. +func (ch *Chat) insert(text string, q Query, it Suggestion) string { + r := []rune(text) + put := string(q.Mark) + it.Name + if !strings.HasSuffix(it.Name, "/") { + put += " " + } + head := string(r[:q.Start]) + put + ch.sg.sel, ch.sg.caret = 0, utf8.RuneCountInString(head) + ch.sg.at = ch.sg.caret + return head + string(r[q.Caret:]) } // suggestPopup builds the popup above anchor, the card, while it shows for the draft of terminal @@ -161,12 +253,14 @@ func (ch *Chat) insert(it Suggestion) string { // size (D272). func (ch *Chat) suggestPopup(c *ui.Context, id string, anchor ui.Element) { text := *ch.draft(id) - ch.sg.open = ch.suggestOpen(text) + q, open := ch.query(text) + ch.sg.open = open if !ch.sg.open { ch.sg.sel = 0 return } - list := ch.suggestions(id, text) + list := ch.suggestions(id, q) + key := listKey(id, q) if ch.sg.sel >= len(list) { ch.sg.sel = 0 } @@ -183,11 +277,14 @@ func (ch *Chat) suggestPopup(c *ui.Context, id string, anchor ui.Element) { panel.Children(func() { if len(list) == 0 { empty := TextNoMatch + if q.Mark == '@' { + empty = TextNoPath + } switch { - case ch.sg.loading[id]: + case ch.sg.loading[key]: empty = TextLoading - case ch.sg.errs[id] != "": - empty = ch.sg.errs[id] + case ch.sg.errs[key] != "": + empty = ch.sg.errs[key] } kit.Styled(ui.Text(c, empty), detail).SingleLine().Padding(theme.Space6, theme.Space8).TextColor(pal.Ink48) return @@ -195,17 +292,18 @@ func (ch *Chat) suggestPopup(c *ui.Context, id string, anchor ui.Element) { h := float32(min(len(list), suggestRows)) * rowH ui.List(c, &ch.sg.list, len(list), func(i int) { it := list[i] - row := ui.ButtonBase(c).Label("/" + it.Name).Row().FillWidth().Height(rowH).AlignItems(ui.Center).Justify(ui.Start). + shown := string(q.Mark) + it.Name + row := ui.ButtonBase(c).Label(shown).Row().FillWidth().Height(rowH).AlignItems(ui.Center).Justify(ui.Start). Gap(theme.Space8).PaddingX(theme.Space8).Radius(theme.Radius8).FocusRing(false). OnClick(func() { - *ch.draft(id) = ch.insert(it) + *ch.draft(id) = ch.insert(text, q, it) ch.field.Focus() }) if i == ch.sg.sel { row = row.Background(pal.HoverStrong) } row.Children(func() { - kit.Styled(ui.Text(c, "/"+it.Name), name).NoWrap().Shrink(0).TextColor(pal.Ink100) + kit.Styled(ui.Text(c, shown), name).NoWrap().Shrink(0).TextColor(pal.Ink100) kit.Styled(ui.Text(c, it.Detail), detail).SingleLine().Ellipsis("…").MinWidth(0).TextColor(pal.Ink48) }) }).FillWidth().Height(h) diff --git a/internal/review/tree.go b/internal/review/tree.go index c1ed72a2..14e593c1 100644 --- a/internal/review/tree.go +++ b/internal/review/tree.go @@ -173,3 +173,23 @@ func joinRel(rel, name string) string { } return rel + "/" + name } + +// KnownFiles lists the files that git knows under Dir and the new ones that it does not ignore, +// slash-separated and relative to Dir, sorted. ok is false when Dir is not a git work +// tree. +func (r Repo) KnownFiles(ctx context.Context) (files []string, ok bool, err error) { + if !r.IsRepo(ctx) { + return nil, false, nil + } + out, err := r.git(ctx, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--deduplicate") + if err != nil { + return nil, true, err + } + for _, p := range strings.Split(out, "\x00") { + if p != "" { + files = append(files, p) + } + } + sort.Strings(files) + return files, true, nil +} From 9804760daa9586279c2af397a2c71386b4bd2010 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 14:45:48 -0300 Subject: [PATCH 02/14] Chat: @ paths leave out deleted files and list a nested repository once --- internal/app/chat.go | 8 +++++++- internal/app/chatpaths_test.go | 12 ++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/app/chat.go b/internal/app/chat.go index 46f4a7f2..8a16ba91 100644 --- a/internal/app/chat.go +++ b/internal/app/chat.go @@ -8,6 +8,7 @@ import ( "errors" "log" "os" + "path/filepath" "strings" "time" @@ -92,13 +93,18 @@ func listPaths(ctx context.Context, root, dir string) ([]chatview.Suggestion, er } seen := map[string]bool{} for _, f := range files { + if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(f))); err != nil { + continue // deleted in the work tree, not yet in git + } for i := range len(f) { if d := f[:i+1]; f[i] == '/' && !seen[d] { seen[d] = true out = append(out, chatview.Suggestion{Name: d}) } } - out = append(out, chatview.Suggestion{Name: f}) + if !seen[f] { // git lists a nested repository as "dir/" + out = append(out, chatview.Suggestion{Name: f}) + } } return out, nil } diff --git a/internal/app/chatpaths_test.go b/internal/app/chatpaths_test.go index 74ad16e7..005dc261 100644 --- a/internal/app/chatpaths_test.go +++ b/internal/app/chatpaths_test.go @@ -48,10 +48,18 @@ func TestListPathsInGit(t *testing.T) { t.Fatalf("git %v: %v %s", args, err, out) } } + if err := os.Remove(filepath.Join(root, "c.md")); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "nest") + write(t, nested, "m.go") + if out, err := exec.Command("git", "-C", nested, "init", "-q").CombinedOutput(); err != nil { + t.Fatalf("git init: %v %s", err, out) + } got := names(t, root, "") - want := []string{".gitignore", "a/", "a/b.go", "c.md", "new/", "new/n.go"} + want := []string{".gitignore", "a/", "a/b.go", "nest/", "new/", "new/n.go"} if !reflect.DeepEqual(got, want) { - t.Errorf("paths = %q, want %q (tracked and untracked, not ignored)", got, want) + t.Errorf("paths = %q, want %q (tracked and untracked; not ignored, not deleted; a nested repo once)", got, want) } } From b4c40f398d1aa9cd8ca12cd1199945edffb98106 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 14:49:30 -0300 Subject: [PATCH 03/14] Chat: see fixture and e2e test of the @ path popup --- cmd/contr/e2e_chatpaths_test.go | 43 +++++++++++++++++++++++ cmd/contr/e2e_see_test.go | 18 ++++++++++ internal/app/chatview/pathsuggest_test.go | 8 +++-- 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 cmd/contr/e2e_chatpaths_test.go diff --git a/cmd/contr/e2e_chatpaths_test.go b/cmd/contr/e2e_chatpaths_test.go new file mode 100644 index 00000000..36c3f394 --- /dev/null +++ b/cmd/contr/e2e_chatpaths_test.go @@ -0,0 +1,43 @@ +//go:build e2e + +package main + +import ( + "testing" + "time" +) + +// TestChatPathPopup is #708 (D279): "@" in the composer lists the paths of lead's repo, Tab on a +// folder puts it in and keeps the popup open on its content, Tab on a file closes it. +func TestChatPathPopup(t *testing.T) { + h := newHarness(t) + seeChatPaths(t, h) + c := h.launch(t) + c.waitLabel(t, "lead, ") + c.press(t, "Chat") + c.text(t, "Look at @app") + c.waitLabel(t, "@internal/app/") + c.waitLabel(t, "@wrapper.go") + if c.hasPrefix(t, "@internal/hc/") || c.hasPrefix(t, "@HANDOFF.md") { + t.Fatal("the popup shows a path without app, or a deleted file") + } + c.key(t, "Tab") + gone(t, c, "@wrapper.go", "the popup does not show the folder's content after Tab on it") + c.waitLabel(t, "@internal/app/tabs.go") + c.text(t, "tab") + gone(t, c, "@internal/app/panel.go", "the popup does not filter in the folder") + c.key(t, "Tab") + gone(t, c, "suggestions", "the popup stays open after Tab on a file") +} + +// gone waits until no element has label, and fails with msg after 10 s. +func gone(t *testing.T, c *client, label, msg string) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for c.labels(t)[label] { + if time.Now().After(deadline) { + t.Fatal(msg) + } + time.Sleep(100 * time.Millisecond) + } +} diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index 2c026324..b25f3e2b 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:work group 10 tool calls", "work group 10 tool calls", "wait:tool call Grep"}, }, + "chat-paths": { + about: "the chat fixture with \"Look at @app\" typed in the composer (D279): the popup above the card with the folder and files of the repo whose paths hold app, the paths with a part that starts with it first", + setup: seeChatPaths, + ready: "lead, ", + press: []string{"Chat", "type:Look at @app", "wait:@internal/app/"}, + }, "session": { about: "the orchestrator lead with the todos and artifacts of canvas board 11 (D197)", setup: seeSession, @@ -359,6 +365,18 @@ func seeChatAs(t *testing.T, h *harness, profile string) { } } +// seeChatPaths is seeChat with the files of seeChanges in lead's repo and an untracked one whose +// name holds "app" in the middle. +func seeChatPaths(t *testing.T, h *harness) { + t.Helper() + seeChat(t, h) + repo := filepath.Join(h.dir, "demo") + seeChanges(t, repo) + if err := os.WriteFile(filepath.Join(repo, "wrapper.go"), []byte("package demo\n"), 0o644); err != nil { + t.Fatal(err) + } +} + // seeChatLong is seeChat with two more turns, so the transcript is longer than the window. func seeChatLong(t *testing.T, h *harness) { t.Helper() diff --git a/internal/app/chatview/pathsuggest_test.go b/internal/app/chatview/pathsuggest_test.go index 38ab73d0..3806c02c 100644 --- a/internal/app/chatview/pathsuggest_test.go +++ b/internal/app/chatview/pathsuggest_test.go @@ -81,9 +81,9 @@ func TestAtInsertsAFile(t *testing.T) { func TestAtInsertsAFolderAndStaysOpen(t *testing.T) { s, f := pathSession(t) - s.typeIn("@sp") + s.typeIn("see @sp") s.key(0, ui.KeyEnter) - if s.draft() != "@docs/spec/" || len(s.sent) != 0 { + if s.draft() != "see @docs/spec/" || len(s.sent) != 0 { t.Fatalf("after Enter: draft %q, sent %q", s.draft(), s.sent) } if !s.has(LabelSuggest) || !s.has("@docs/spec/ChatView.md") { @@ -92,6 +92,10 @@ func TestAtInsertsAFolderAndStaysOpen(t *testing.T) { if want := []string{"", "docs/spec/"}; !reflect.DeepEqual(f.dirs, want) { t.Errorf("folders read = %q, want %q", f.dirs, want) } + s.typeIn("Ch") + if s.draft() != "see @docs/spec/Ch" { + t.Fatalf("typed after the folder: draft %q, want the caret at its end", s.draft()) + } s.key(0, ui.KeyEscape) if s.has(LabelSuggest) { t.Error("Esc does not close the popup") From 10bb4a0e0403dbc1ec516fb161d23a5f291841e7 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 14:49:31 -0300 Subject: [PATCH 04/14] Chat: the composer shows Claude Code's suggested next prompt (#727, D281) --- internal/app/chat.go | 1 + internal/app/chatview/chat.go | 5 ++ internal/app/chatview/composer.go | 20 ++++- internal/app/chatview/composer_test.go | 67 +++++++++++++++ internal/app/frame.go | 11 ++- internal/app/ghostty/conn.go | 9 +- internal/app/ghostty/conn_test.go | 18 ++++ internal/app/nextprompt.go | 52 ++++++++++++ internal/prompt/suggestion.go | 111 +++++++++++++++++++++++++ internal/prompt/suggestion_test.go | 30 +++++++ 10 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 internal/app/nextprompt.go create mode 100644 internal/prompt/suggestion.go create mode 100644 internal/prompt/suggestion_test.go diff --git a/internal/app/chat.go b/internal/app/chat.go index f27736cd..ae0d2b45 100644 --- a/internal/app/chat.go +++ b/internal/app/chat.go @@ -49,6 +49,7 @@ func (f *Frame) attachChat(env view.Env, src agentstate.AgentSource) (stop func( f.Chat = chatview.New(env, f.Session.StateDir, nil) f.Chat.Worker = f.agentAt f.Chat.Meta = f.chatMeta + f.Chat.NextPrompt = f.nextPrompt f.Chat.Team = f.chatTeam f.Chat.OpenNeeds = f.openNeeds // The notch is made after the chat: it is read when the pinned prompt builds. diff --git a/internal/app/chatview/chat.go b/internal/app/chatview/chat.go index 0d5db2c5..d5eb928a 100644 --- a/internal/app/chatview/chat.go +++ b/internal/app/chatview/chat.go @@ -54,6 +54,11 @@ type Chat struct { // Slash gives the slash commands and skills of the session at addr for the composer's popup // (D273). Nil: "/" opens no popup. Slash SuggestSource + // NextPrompt is Claude Code's suggested next prompt in the input line of the terminal at addr, + // "" for none (D281). It runs on the UI thread. Nil: the placeholder shows. + NextPrompt func(addr string) string + // next is the suggestion the composer shows in its empty field, for Tab. + next string // sg is the state of the composer's suggestion popup. sg suggest diff --git a/internal/app/chatview/composer.go b/internal/app/chatview/composer.go index de1a3edf..71b428f1 100644 --- a/internal/app/chatview/composer.go +++ b/internal/app/chatview/composer.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/egoist/mygo/transfer" "github.com/egoist/mygo/ui" @@ -138,7 +139,14 @@ func (ch *Chat) composer(c *ui.Context, id string, st liveState) { field = field.Margin(fieldGap, 0, 0, 0) } field.Children(func() { - in := ui.TextAreaBase(c.Key("composer:"+id), draft).Label(LabelComposer).Placeholder(Placeholder(ch.name(id))). + hint := Placeholder(ch.name(id)) + if ch.next = ""; *draft == "" && ch.NextPrompt != nil { + // Claude Code's suggested next prompt takes the place of the placeholder (D281). + if ch.next = ch.NextPrompt(id); ch.next != "" { + hint = ch.next + } + } + in := ui.TextAreaBase(c.Key("composer:"+id), draft).Label(LabelComposer).Placeholder(hint). PlaceholderColor(pal.Ink48).TextColor(pal.Ink100).FontSize(ct.Size).LineHeight(ct.Line/ct.Size). Lines(1, composerLines).Grow(1).MinWidth(0).Padding((actionSize-ct.Line)/2, 0, (actionSize-ct.Line)/2, theme.Space6).AutoFocus().Bind(&ch.field). OnTextKey(func(k ui.TextKey) bool { return ch.textKey(id, st, k) }). @@ -260,13 +268,21 @@ func (ch *Chat) action(c *ui.Context, id string, st liveState, a Action) { }) } -// textKey is a key of the field that types no text. Enter sends; ⇧Enter goes on to the field, +// textKey is a key of the field that types no text. Tab or Right in an empty field takes the +// suggested next prompt (D281). Enter sends; ⇧Enter goes on to the field, // which breaks the line. While the suggestion popup shows, it takes its keys first (D273). Esc interrupts the agent while it works and does nothing else: it closes // and switches nothing. func (ch *Chat) textKey(id string, st liveState, k ui.TextKey) bool { if ch.suggestKey(id, k) { return true } + if (k.Key == ui.KeyTab || k.Key == ui.KeyRight) && k.Mods == 0 && ch.next != "" && k.Text() == "" { + // Tab or Right takes the suggested next prompt into the empty field (D281). + k.SetText(ch.next) + ch.sg.caret = utf8.RuneCountInString(ch.next) + ch.next = "" + return true + } switch { case k.Key == ui.KeyEnter && k.Mods == 0: ch.submit(id, st, k.Text(), func() { k.SetText("") }) diff --git a/internal/app/chatview/composer_test.go b/internal/app/chatview/composer_test.go index 69e179de..13fdb5bb 100644 --- a/internal/app/chatview/composer_test.go +++ b/internal/app/chatview/composer_test.go @@ -51,6 +51,16 @@ func newSession(t *testing.T, status string) *sess { return s } +// hint is the composer field's placeholder. +func (s *sess) hint() string { + s.t.Helper() + a, ok := s.tt.Access(LabelComposer) + if !ok { + s.t.Fatal("no composer field") + } + return a.Placeholder +} + // typeIn types text into the composer, which has the focus. func (s *sess) typeIn(text string) { s.t.Helper() @@ -446,3 +456,60 @@ func (r *rig) footerWorks() bool { } return false } + +func TestComposerShowsTheNextPrompt(t *testing.T) { + s := newSession(t, "idle") + next := "commit this" + s.ch.NextPrompt = func(addr string) string { + if addr != term { + return "" + } + return next + } + s.tt.Frame() + if h := s.hint(); h != "commit this" { + t.Fatalf("the field shows %q, want the suggestion", h) + } + // Enter with an empty field sends nothing, as before. + s.key(0, ui.KeyEnter) + if len(s.sent) != 0 { + t.Errorf("Enter sent %v", s.sent) + } + // Tab takes it into the field. + s.key(0, ui.KeyTab) + if s.draft() != "commit this" { + t.Fatalf("Tab gave the draft %q", s.draft()) + } + s.key(0, ui.KeyEnter) + if len(s.sent) != 1 || s.sent[0] != "commit this" { + t.Errorf("sent %v", s.sent) + } +} + +func TestComposerTypingHidesTheNextPrompt(t *testing.T) { + s := newSession(t, "idle") + s.ch.NextPrompt = func(string) string { return "commit this" } + s.tt.Frame() + s.typeIn("x") + if h := s.hint(); h == "commit this" { + t.Errorf("the suggestion stays with text in the field") + } + // Tab with text in the field does not replace it. + s.key(0, ui.KeyTab) + if s.draft() != "x" { + t.Errorf("Tab changed the draft to %q", s.draft()) + } +} + +func TestComposerWithoutNextPromptShowsThePlaceholder(t *testing.T) { + s := newSession(t, "idle") + s.ch.NextPrompt = func(string) string { return "" } + s.tt.Frame() + if h := s.hint(); h != "Message lead…" { + t.Errorf("the field shows %q, want the placeholder", h) + } + s.key(0, ui.KeyTab) + if s.draft() != "" { + t.Errorf("Tab with no suggestion gave %q", s.draft()) + } +} diff --git a/internal/app/frame.go b/internal/app/frame.go index cc1e70ac..764be808 100644 --- a/internal/app/frame.go +++ b/internal/app/frame.go @@ -12,6 +12,7 @@ import ( "os/exec" "slices" "sync" + "sync/atomic" "time" "github.com/egoist/mygo" @@ -205,6 +206,10 @@ type Frame struct { termSet bool termState ghostty.State termErr string + // next is the suggested next prompt read from the terminal at + // termAddr (D281); nextCheck is set while a read of it waits. + next string + nextCheck atomic.Bool // find is the find bar over the terminal (termfind.go). find termFind // hints tells when the key chips show: ⌘ held alone (docs/spec/KeyHints.md). @@ -1118,6 +1123,7 @@ func (f *Frame) syncTerminal() { } f.closeTerminal() f.resetFind() + f.next = "" f.termAddr, f.termSet = addr, true f.syncOutlineFocus() f.termState, f.termErr = ghostty.State{Kind: ghostty.Connecting}, "" @@ -1127,7 +1133,9 @@ func (f *Frame) syncTerminal() { } post, invalidate := f.post, f.invalidate var conn *ghostty.Conn - conn = ghostty.NewConn(f.deps.TerminalSource(addr), ghostty.Options{Watch: true, OnState: func(s ghostty.State) { + var screen atomic.Pointer[gterm.Terminal] + onOutput := func() { f.checkNext(&screen, func() bool { return f.conn == conn }) } + conn = ghostty.NewConn(f.deps.TerminalSource(addr), ghostty.Options{Watch: true, OnOutput: onOutput, OnState: func(s ghostty.State) { post(func() { if f.conn == conn { f.termState = s @@ -1148,6 +1156,7 @@ func (f *Frame) syncTerminal() { return } conn.SetScreen(t) + screen.Store(t) f.term, f.conn = t, conn f.autoFocus = !f.keyNav f.setTerminalFocus(f.winFocused) diff --git a/internal/app/ghostty/conn.go b/internal/app/ghostty/conn.go index 317c2a1b..71c986e0 100644 --- a/internal/app/ghostty/conn.go +++ b/internal/app/ghostty/conn.go @@ -86,6 +86,9 @@ type Options struct { // OnState gets each change of the state, on any goroutine. It must not // wait for the UI thread. OnState func(State) + // OnOutput is called on the plugin's read goroutine as it takes output + // from the program. It must not wait for the UI thread. May be nil. + OnOutput func() // Cols and Rows are the size of the first attach: 80 by 24 when zero. // The view's size replaces it as soon as the view tells it. Cols, Rows uint16 @@ -117,6 +120,7 @@ type chunk struct { type Conn struct { src Source on func(State) + onOut func() // Options.OnOutput ctx context.Context end context.CancelFunc wg sync.WaitGroup @@ -163,7 +167,7 @@ type Conn struct { // firstWait. Close ends it. func NewConn(src Source, o Options) *Conn { ctx, end := context.WithCancel(context.Background()) - c := &Conn{src: src, on: o.OnState, ctx: ctx, end: end, out: make(chan chunk), + c := &Conn{src: src, on: o.OnState, onOut: o.OnOutput, ctx: ctx, end: end, out: make(chan chunk), visible: true, cols: o.Cols, rows: o.Rows, state: State{Kind: Connecting}, watching: o.Watch, wake: make(chan struct{}, 1)} if c.cols == 0 || c.rows == 0 { @@ -232,6 +236,9 @@ func (c *Conn) Read(p []byte) (int, error) { continue } c.buf = ch.data + if c.onOut != nil && len(ch.data) > 0 { + c.onOut() + } case <-c.ctx.Done(): return 0, io.EOF } diff --git a/internal/app/ghostty/conn_test.go b/internal/app/ghostty/conn_test.go index 9d0ad743..ce60060a 100644 --- a/internal/app/ghostty/conn_test.go +++ b/internal/app/ghostty/conn_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "sync" + "sync/atomic" "testing" "time" @@ -1314,3 +1315,20 @@ func TestWriteDropsSchemeReports(t *testing.T) { } waitAttaches(t, src, 2) } + +func TestOnOutputIsCalledForEachTakenOutput(t *testing.T) { + fast(t) + src := newFakeSource() + var n atomic.Int32 + c := NewConn(src, Options{OnOutput: func() { n.Add(1) }}) + t.Cleanup(func() { _ = c.Close() }) + s := src.stream(t, 0) + s.data("hello ") + s.data("world") + if got := readN(t, c, 11); got != "hello world" { + t.Errorf("read %q", got) + } + if got := n.Load(); got != 2 { + t.Errorf("OnOutput called %d times, want 2", got) + } +} diff --git a/internal/app/nextprompt.go b/internal/app/nextprompt.go new file mode 100644 index 00000000..58e352be --- /dev/null +++ b/internal/app/nextprompt.go @@ -0,0 +1,52 @@ +package app + +import ( + "time" + + gterm "github.com/egoist/mygo/plugins/terminal" + + "github.com/contursofran/contr/internal/prompt" +) + +// nextDelay is how long after output the terminal's screen is read for the +// suggested next prompt: the plugin applies the output first, and a burst of +// output gives one read. +const nextDelay = 300 * time.Millisecond + +// nextTail is how much of the end of the screen's escape sequences is read: +// the input line is near the bottom. +const nextTail = 256 << 10 + +// checkNext reads Claude Code's suggested next prompt (D281) from the +// terminal in screen a little after output came, off the UI thread, and +// gives a change to the frame for the composer. current tells, on the UI +// thread, that the terminal is still the one shown. Any goroutine. +func (f *Frame) checkNext(screen interface{ Load() *gterm.Terminal }, current func() bool) { + if !f.nextCheck.CompareAndSwap(false, true) { + return + } + time.AfterFunc(nextDelay, func() { + f.nextCheck.Store(false) + t := screen.Load() + if t == nil { + return + } + snap := t.Snapshot() + next := prompt.Suggestion(snap[max(0, len(snap)-nextTail):]) + f.post(func() { + if current() && f.next != next { + f.next = next + f.invalidate() + } + }) + }) +} + +// nextPrompt is the suggested next prompt of the terminal at addr for the +// composer, "" when that terminal is not the one shown. UI thread. +func (f *Frame) nextPrompt(addr string) string { + if addr != f.termAddr { + return "" + } + return f.next +} diff --git a/internal/prompt/suggestion.go b/internal/prompt/suggestion.go new file mode 100644 index 00000000..140c9ace --- /dev/null +++ b/internal/prompt/suggestion.go @@ -0,0 +1,111 @@ +package prompt + +import ( + "bytes" + "strconv" + "strings" +) + +// promptMark is the mark at the start of Claude Code's input line. +const promptMark = "❯" + +// Suggestion is the next prompt that Claude Code suggests in its input line +// when a turn ends (D281), read from the terminal's screen as escape +// sequences (the MyGo terminal's Snapshot). Claude Code writes the +// suggestion as faint text (SGR 2) after the prompt mark. Only the last line +// with the mark counts. It is "" when that line has no text after the mark, +// or when any of that text is not faint: typed text, a sent prompt or a menu +// choice. +func Suggestion(screen []byte) string { + lines := bytes.Split(screen, []byte("\n")) + for i := len(lines) - 1; i >= 0; i-- { + if bytes.Contains(lines[i], []byte(promptMark)) { + return faintAfterMark(string(lines[i])) + } + } + return "" +} + +// faintAfterMark reads line after its first prompt mark: the text if all +// of it is faint, else "". +func faintAfterMark(line string) string { + faint, seen := false, false + var b strings.Builder + for i := 0; i < len(line); { + if line[i] == 0x1b { + n, f, ok := csi(line[i:], faint) + if !ok { + return "" + } + faint, i = f, i+n + continue + } + if !seen { + if strings.HasPrefix(line[i:], promptMark) { + seen = true + i += len(promptMark) + } else { + i++ + } + continue + } + c := line[i] + i++ + if c == '\r' || c < 0x20 { + continue + } + if c != ' ' && !faint { + return "" + } + b.WriteByte(c) + } + return strings.TrimSpace(b.String()) +} + +// csi reads the escape sequence at the start of s: its length and the faint +// state after it. Only SGR changes the state. ok is false for an escape that +// is not a CSI sequence. +func csi(s string, faint bool) (n int, after bool, ok bool) { + if len(s) < 2 || s[1] != '[' { + return 0, faint, false + } + end := 2 + for end < len(s) && (s[end] < 0x40 || s[end] > 0x7e) { + end++ + } + if end == len(s) { + return 0, faint, false + } + if s[end] != 'm' { + return end + 1, faint, true + } + params := strings.Split(s[2:end], ";") + for j := 0; j < len(params); j++ { + p := params[j] + if k := strings.IndexByte(p, ':'); k >= 0 { + p = p[:k] // a colon form (38:2:r:g:b) is one parameter + } + v, err := strconv.Atoi(p) + if p == "" { + v, err = 0, nil + } + if err != nil { + continue + } + switch { + case v == 0 || v == 22: + faint = false + case v == 2: + faint = true + case (v == 38 || v == 48 || v == 58) && !strings.Contains(params[j], ":") && j+1 < len(params): + // Skip the color's own numbers: 5;n or 2;r;g;b. + switch params[j+1] { + case "5": + j += 2 + case "2": + j += 4 + } + } + } + return end + 1, faint, true +} diff --git a/internal/prompt/suggestion_test.go b/internal/prompt/suggestion_test.go new file mode 100644 index 00000000..0ad3c428 --- /dev/null +++ b/internal/prompt/suggestion_test.go @@ -0,0 +1,30 @@ +package prompt + +import "testing" + +func TestSuggestion(t *testing.T) { + const rule = "\x1b[0m\x1b[38;2;136;136;136m────\x1b[0m\r\n" + // history is a sent prompt above the input line, as Claude Code draws it. + const history = "❯ \x1b[0m\x1b[38;2;255;255;255m\x1b[48;2;55;55;55mfix the test\x1b[0m\r\n" + cases := []struct { + name, screen, want string + }{ + // From a real session: the suggestion after a turn ended. + {"suggestion", history + rule + "❯ \x1b[0m\x1b[2mcommit this\x1b[0m\r\n" + rule, "commit this"}, + {"faint with a color", rule + "❯ \x1b[2;38;5;244mrun the tests\x1b[0m\r\n", "run the tests"}, + {"none", history + rule + "❯ \x1b[0m\r\n" + rule, ""}, + {"no mark", "plain output\r\n", ""}, + {"typed text", rule + "❯ \x1b[0mcommit this\r\n", ""}, + {"faint ends", rule + "❯ \x1b[2mcommit\x1b[22m this\r\n", ""}, + // A new prompt went in: the input line is empty, the old suggestion is gone and the + // sent prompt sits above it. + {"stale after a new prompt", "❯ \x1b[2mcommit this\x1b[0m\r\n" + history + rule + "❯ \x1b[0m\r\n", ""}, + {"menu choice", " ❯ 1. Yes\r\n 2. No\r\n", ""}, + {"bad escape", "❯ \x1b]0;title\x07\x1b[2mx\r\n", ""}, + } + for _, c := range cases { + if got := Suggestion([]byte(c.screen)); got != c.want { + t.Errorf("%s: got %q, want %q", c.name, got, c.want) + } + } +} From 8748c704e6b8475c58c2732bd871138e8b1988c8 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 15:03:16 -0300 Subject: [PATCH 05/14] Docs: Path popup in the ChatView spec, D279 --- docs/DECISIONS.md | 1 + docs/spec/ChatView.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e15f4b19..269d354c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -253,3 +253,4 @@ replaces it. | D276 | 2026-11-11 | The composer gets layout B of board Y5 (#711, owner pick, tracker #634; docs/spec/ChatView.md, Composer). The card holds the field (14px on 22px lines) and one 30px round button, with `space-8` padding: Send (`ink-100` fill, arrow up) while the field has text, Stop (1px ring, 9px square) while the agent works and the field is empty, and no button when idle and empty (was Send dimmed, D271). The Working line inside the card goes away. Under the card, outside it, one footer line 18px high, 16px side inset, mono `meta`, `ink-48`: at the left the working glyph and "Working 7s · Bash · gh pr diff 633" while the agent works (what it does clips with `…`; the model text never clips), else "⏎ send · ⇧⏎ new line"; at the right model, effort and context. The team row moves out of the card: it sits directly above it, 16px inset, no fill or border, and has no live text, so working shows once. Replaces the top line and foot parts of D271. | | D277 | 2026-11-11 | Settings → Updates updates hc (#712). The hc row gets an `Update hc` button that runs `hc update --json` off the UI thread after a confirm (`Update hc?`, `hc downloads the newest version and restarts the core. Running terminals keep running.`, `Cancel` and `Update`; Cancel and Escape run nothing) (5 minute limit) and shows `updating hc…`, the new version, `hc is up to date` or hc's error. hc has no check or dry-run flag, so contr shows no "newer hc" before the update and uses no release URL of its own. `hc update` replaces the hc program and restarts the core by itself; the terminals keep running (tried on a scratch core). So the result line says the core restarted on the new version; contr has no restart button of its own and never runs the update without a click (docs/spec/SettingsUpdates.md). | Owner in #712. | | D278 | 2026-11-11 | Settings → Setup: a read-only setup checklist (#713, S1). `Setup` is the first Settings tab: `Setup`, `General`, `Agents`, `Updates` (changes the tab list of D186). Package `internal/setup` checks hc (`hc --version`), the hc core (the frame's live state), the contr plugin (`claude plugin list --json`), the Claude meter (data under 24 h old), gh (`gh auth status`), and the optional codex and `contr` command. Each command has a 5 s limit and runs off the UI thread when Settings opens, on Recheck, and on activation while the tab shows; nothing runs while Settings is closed. No check reads files in `~/.claude` or `~/.codex`. A step shows a line with a Copy button; contr runs no command here (S3 adds a confirmed plugin install, D65) and never edits Claude Code's settings (the meter stays copy only, D66). The Setup tab and the sidebar Settings button get the D146 dot while a required check is not ready. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing in one place. | +| D279 | 2026-11-11 | The composer completes file paths with "@" (#708, tracker #634; docs/spec/ChatView.md, Path popup). "@" at the caret, at the start or after a space, opens the popup of D273 with the paths of the session's working folder: `git ls-files --cached --others --exclude-standard` (no ignored files, no deleted ones) with their folders, or a plain read of the typed folder outside git. Parts that start with the text first, then paths that hold it, at most 50. A folder goes in as "@dir/" and keeps the popup open. The popup logic takes a query at the caret (`QueryAt`) for both "/" and "@", so there is one popup with two sources. | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index 13d6e66b..ca1a5aeb 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -198,6 +198,24 @@ Chat text size (D272). With no match, one line "No commands match" (or same. Esc closes the popup until the text changes and does not stop the agent. With no match, Enter sends the text as typed. +### Path popup + +"@" at the caret, at the start of the field or after a space, opens the same +popup (D279) for the files and folders of the session's working folder; the +text from "@" to the caret, with no space in it, filters it. Rows show +"@path" with no description; a folder ends in "/". With no match, the line is +"No files match". + +- The list is the files that git knows there and the untracked files that + git does not ignore, with each folder that holds them; files deleted in the + work tree are left out. Outside git, it is a plain read of the typed folder. + The read runs off the UI thread, once per folder typed. +- Paths with a part that starts with the typed text come first, then paths + that hold it, without regard to case; at most 50 show. +- Enter or Tab replace "@text" with "@path " and put the caret after it. A + folder goes in as "@dir/" with no space, and the popup stays open on its + content. Esc closes it as for "/". + ## Live line The working status of the agent (D276): the left of the composer's footer From 9f274286e6d15c7ec961f197ef440c6f959e4434 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 15:04:35 -0300 Subject: [PATCH 06/14] Chat: next prompt docs (D281), spec sheet part and chat-next fixture (#727) --- cmd/contr/e2e_harness_test.go | 1 + cmd/contr/e2e_see_test.go | 6 ++++++ cmd/contr/e2e_titlebar_test.go | 14 ++++++++++++-- docs/DECISIONS.md | 1 + docs/spec/ChatView.md | 9 +++++++++ internal/app/chatview/sheet_test.go | 19 ++++++++++++------- internal/app/ghostty/conn.go | 12 ++++++------ 7 files changed, 47 insertions(+), 15 deletions(-) diff --git a/cmd/contr/e2e_harness_test.go b/cmd/contr/e2e_harness_test.go index 41106512..421da21c 100644 --- a/cmd/contr/e2e_harness_test.go +++ b/cmd/contr/e2e_harness_test.go @@ -115,6 +115,7 @@ func newHarness(t *testing.T) *harness { cfg.Profiles["sizer"] = agents.Profile{Argv: []string{"sh", "-c", `draw() { printf "\033[2J\033[H"; set -- $(stty size); echo "grid $2x$1"; i=0; while [ $i -lt $2 ]; do printf %d $((i % 10)); i=$((i+1)); done; echo; }; trap draw WINCH; draw; while :; do sleep 1; done`}} cfg.Profiles["claude-idle"] = agents.Profile{Argv: []string{claude, "idle"}} + cfg.Profiles["claude-next"] = agents.Profile{Argv: []string{claude, "idle:next=commit%20this%20and%20open%20a%20PR"}} cfg.Profiles["claude-working"] = agents.Profile{Argv: []string{claude, "working"}} cfg.Profiles["claude-blocked"] = agents.Profile{Argv: []string{claude, "blocked:kind=permission:msg=Allow%20rm"}} cfg.Profiles["claude-error"] = agents.Profile{Argv: []string{claude, "error"}} diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index 2c026324..28e23b68 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -197,6 +197,12 @@ var seeFixtures = map[string]seeFixture{ ready: "lead, ", press: []string{"Chat"}, }, + "chat-next": { + about: "the chat fixture with lead idle and a suggested next prompt in its terminal's input line (D281): the composer shows it in place of the placeholder", + setup: func(t *testing.T, h *harness) { seeChatAs(t, h, "claude-next") }, + ready: "lead, ", + press: []string{"Chat"}, + }, "chat-blocks": { about: "the orchestrator lead in Chat mode with an older status board (frozen: the agent's three columns, its answer's time), the live board (state, PR and checks from a fake gh: CI running 2 of 3, CI passed 3 of 3, building, waiting, and a worker that is not found) and a merge queue (merged, CI running, blocked: 1 check failed, a note) (D267)", setup: seeChatBlocks, diff --git a/cmd/contr/e2e_titlebar_test.go b/cmd/contr/e2e_titlebar_test.go index d9de931b..8fdb8c42 100644 --- a/cmd/contr/e2e_titlebar_test.go +++ b/cmd/contr/e2e_titlebar_test.go @@ -23,7 +23,8 @@ import ( // fakeClaude is a program that looks like Claude Code in a terminal: the welcome box, the // conversation, a status line and the prompt box. It tells hc its state with the OSC the hook -// sends (last argument: working, idle, error or blocked:kind=...:msg=..., which also prints a permission menu). +// sends (last argument: working, idle, error or blocked:kind=...:msg=..., which also prints a permission menu; +// idle:next=... prints a suggested next prompt, faint, in the input line). const fakeClaude = `package main import ( @@ -57,8 +58,17 @@ func main() { fmt.Printf("\x1b]7501;state=%s\x07", state) time.Sleep(time.Hour) } + next := "" + if i := strings.Index(state, ":next="); i >= 0 { + // The suggested next prompt, faint after the mark as Claude Code draws it (D281). + state, next = state[:i], strings.ReplaceAll(state[i+len(":next="):], "%20", " ") + } fmt.Print("╭───────────────────────────────────────────────────╮\n") - fmt.Print("│ > │\n") + if next != "" { + fmt.Printf("❯ \x1b[2m%s\x1b[0m\n", next) + } else { + fmt.Print("│ > │\n") + } fmt.Print("╰───────────────────────────────────────────────────╯\n") fmt.Print(" ? for shortcuts\n") fmt.Printf("\x1b]7501;state=%s\x07", state) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e15f4b19..62eef294 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -253,3 +253,4 @@ replaces it. | D276 | 2026-11-11 | The composer gets layout B of board Y5 (#711, owner pick, tracker #634; docs/spec/ChatView.md, Composer). The card holds the field (14px on 22px lines) and one 30px round button, with `space-8` padding: Send (`ink-100` fill, arrow up) while the field has text, Stop (1px ring, 9px square) while the agent works and the field is empty, and no button when idle and empty (was Send dimmed, D271). The Working line inside the card goes away. Under the card, outside it, one footer line 18px high, 16px side inset, mono `meta`, `ink-48`: at the left the working glyph and "Working 7s · Bash · gh pr diff 633" while the agent works (what it does clips with `…`; the model text never clips), else "⏎ send · ⇧⏎ new line"; at the right model, effort and context. The team row moves out of the card: it sits directly above it, 16px inset, no fill or border, and has no live text, so working shows once. Replaces the top line and foot parts of D271. | | D277 | 2026-11-11 | Settings → Updates updates hc (#712). The hc row gets an `Update hc` button that runs `hc update --json` off the UI thread after a confirm (`Update hc?`, `hc downloads the newest version and restarts the core. Running terminals keep running.`, `Cancel` and `Update`; Cancel and Escape run nothing) (5 minute limit) and shows `updating hc…`, the new version, `hc is up to date` or hc's error. hc has no check or dry-run flag, so contr shows no "newer hc" before the update and uses no release URL of its own. `hc update` replaces the hc program and restarts the core by itself; the terminals keep running (tried on a scratch core). So the result line says the core restarted on the new version; contr has no restart button of its own and never runs the update without a click (docs/spec/SettingsUpdates.md). | Owner in #712. | | D278 | 2026-11-11 | Settings → Setup: a read-only setup checklist (#713, S1). `Setup` is the first Settings tab: `Setup`, `General`, `Agents`, `Updates` (changes the tab list of D186). Package `internal/setup` checks hc (`hc --version`), the hc core (the frame's live state), the contr plugin (`claude plugin list --json`), the Claude meter (data under 24 h old), gh (`gh auth status`), and the optional codex and `contr` command. Each command has a 5 s limit and runs off the UI thread when Settings opens, on Recheck, and on activation while the tab shows; nothing runs while Settings is closed. No check reads files in `~/.claude` or `~/.codex`. A step shows a line with a Copy button; contr runs no command here (S3 adds a confirmed plugin install, D65) and never edits Claude Code's settings (the meter stays copy only, D66). The Setup tab and the sidebar Settings button get the D146 dot while a required check is not ready. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing in one place. | +| 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. | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index 13d6e66b..d1ef0cac 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -142,6 +142,15 @@ the circle. - The field takes the focus as it appears: when the chat shows (the switch, ⌘E). It keeps the focus after a send, and takes it back after a click on Send or Stop. +- Suggested next prompt (D281): when a turn ends, Claude Code shows a + suggested next prompt as faint text after `❯` in its input line. contr + reads it from the selected terminal's screen (the faint text, SGR 2, after + the last `❯`), 300 ms after output, off the UI thread. While the field is + empty, the suggestion takes the place of the placeholder, in the same + `ink-48`. Tab or Right puts it into the field (the slash popup takes Tab + first while it shows). Enter with an empty field sends nothing. Typed text + hides it; a new turn clears the input line, so it goes. No suggestion: the + placeholder. - Enter sends, and so does a click on Send; ⇧Enter breaks the line. A blank field sends nothing. Send types the text into the selected session's own terminal and presses Enter through the agent source's `Send`, as the orchestrate pill does. The field diff --git a/internal/app/chatview/sheet_test.go b/internal/app/chatview/sheet_test.go index e85272ed..ac9a33fc 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -172,18 +172,20 @@ type composerState struct { team *Team text string h float32 + next string // Claude Code's suggested next prompt (D281) } var composerStates = []composerState{ - {"Idle, empty: no button, the keys in the footer", "idle", &Team{Agents: 2, Idle: 2}, "", 150}, - {"Idle, text: Send", "idle", &Team{Agents: 2, Idle: 2}, "Merge #611 first, then tell b-632 to merge main.", 150}, - {"Working, empty: Stop", "working", &Team{Agents: 2, NeedsYou: 1, Working: 1}, "", 150}, - {"Working, text, no team: Working in the footer, Send (the message queues)", "working", nil, "And run the review on its exact head.", 150}, - {"Many lines: the field grows to 8 lines, then scrolls", "idle", &Team{Agents: 2, Working: 2}, strings.TrimSuffix(strings.Repeat("A line of the message\n", 11), "\n"), 330}, - {"No team, idle: the field alone", "idle", nil, "", 130}, + {"Idle, empty: no button, the keys in the footer", "idle", &Team{Agents: 2, Idle: 2}, "", 150, ""}, + {"Idle, text: Send", "idle", &Team{Agents: 2, Idle: 2}, "Merge #611 first, then tell b-632 to merge main.", 150, ""}, + {"Working, empty: Stop", "working", &Team{Agents: 2, NeedsYou: 1, Working: 1}, "", 150, ""}, + {"Working, text, no team: Working in the footer, Send (the message queues)", "working", nil, "And run the review on its exact head.", 150, ""}, + {"Many lines: the field grows to 8 lines, then scrolls", "idle", &Team{Agents: 2, Working: 2}, strings.TrimSuffix(strings.Repeat("A line of the message\n", 11), "\n"), 330, ""}, + {"No team, idle: the field alone", "idle", nil, "", 130, ""}, + {"Idle, empty, a suggested next prompt: it takes the placeholder's place (D281)", "idle", nil, "", 130, "commit this and open a PR"}, } -const composerSheetH = 1230 +const composerSheetH = 1380 // sheetComposer stacks the composer card in each of composerStates, each under its caption, at the // bottom of a short chat. @@ -195,6 +197,9 @@ func sheetComposer(t *testing.T) func(c *ui.Context) { teamOf(v, *st.team) } *v.s.ch.draft(term) = st.text + if next := st.next; next != "" { + v.s.ch.NextPrompt = func(string) string { return next } + } views[i] = v } return func(c *ui.Context) { diff --git a/internal/app/ghostty/conn.go b/internal/app/ghostty/conn.go index 71c986e0..65af932e 100644 --- a/internal/app/ghostty/conn.go +++ b/internal/app/ghostty/conn.go @@ -118,13 +118,13 @@ type chunk struct { // again with backoff and with Resumed, and holds what is typed meanwhile. Its // Read ends only when Close is called, so the plugin keeps its screen. type Conn struct { - src Source - on func(State) + src Source + on func(State) onOut func() // Options.OnOutput - ctx context.Context - end context.CancelFunc - wg sync.WaitGroup - out chan chunk // output and sizes from the pump to Read + ctx context.Context + end context.CancelFunc + wg sync.WaitGroup + out chan chunk // output and sizes from the pump to Read rmu sync.Mutex // Read's leftover buf []byte From 76df9b18e079b3715ab3e80525b704f7ec564d8b Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 15:13:44 -0300 Subject: [PATCH 07/14] Chat: brand marks of known servers on call lines (#709 S4, D274) A connector, plugin or other MCP call of a known server shows the monochrome Simple Icons mark of the server in ink-48 in place of the kind icon. One table in chatview/tools.go maps the server name. --- NOTICE | 5 ++ assets/icons.go | 5 +- assets/icons/brands/atlassian.svg | 1 + assets/icons/brands/datadog.svg | 1 + assets/icons/brands/figma.svg | 1 + assets/icons/brands/github.svg | 1 + assets/icons/brands/gmail.svg | 1 + assets/icons/brands/googlecalendar.svg | 1 + assets/icons/brands/googledrive.svg | 1 + assets/icons/brands/intercom.svg | 1 + assets/icons/brands/jira.svg | 1 + assets/icons/brands/linear.svg | 1 + assets/icons/brands/mixpanel.svg | 1 + assets/icons/brands/notion.svg | 1 + assets/icons/brands/slack.svg | 1 + assets/icons/brands/spotify.svg | 1 + cmd/contr/e2e_chat_call_test.go | 4 +- cmd/contr/e2e_see_test.go | 6 ++- docs/DECISIONS.md | 2 +- docs/spec/ChatView.md | 2 +- internal/app/chatview/sheet_test.go | 12 ++++- internal/app/chatview/tools.go | 64 +++++++++++++++++++++++++- internal/app/chatview/tools_test.go | 58 ++++++++++++++++++++++- internal/app/chatview/view.go | 5 ++ internal/app/icons/icons.go | 51 ++++++++++++-------- internal/app/icons/line.go | 5 ++ 26 files changed, 202 insertions(+), 31 deletions(-) create mode 100644 assets/icons/brands/atlassian.svg create mode 100644 assets/icons/brands/datadog.svg create mode 100644 assets/icons/brands/figma.svg create mode 100644 assets/icons/brands/github.svg create mode 100644 assets/icons/brands/gmail.svg create mode 100644 assets/icons/brands/googlecalendar.svg create mode 100644 assets/icons/brands/googledrive.svg create mode 100644 assets/icons/brands/intercom.svg create mode 100644 assets/icons/brands/jira.svg create mode 100644 assets/icons/brands/linear.svg create mode 100644 assets/icons/brands/mixpanel.svg create mode 100644 assets/icons/brands/notion.svg create mode 100644 assets/icons/brands/slack.svg create mode 100644 assets/icons/brands/spotify.svg diff --git a/NOTICE b/NOTICE index ab769164..a249abb3 100644 --- a/NOTICE +++ b/NOTICE @@ -21,6 +21,11 @@ Copyright 2026 Fabien Penso, licensed under the Apache License, Version 2.0. Each file with adapted code has a header comment that says so and names the changes. +The brand marks in assets/icons/brands/ are icons of Simple Icons +(https://github.com/simple-icons/simple-icons), v16.37.0, released under +CC0 1.0 Universal; they are used without changes. The marks belong to their +owners; contr uses them only to show which service a tool call goes to. + The Claude Code mascot (assets/icons/claude-mascot.svg) is the "Claude Code" icon of LobeHub icons (https://github.com/lobehub/lobe-icons), licensed under the MIT License; changes: the viewBox is cropped to the shape, and it has one diff --git a/assets/icons.go b/assets/icons.go index 1d199220..874543f5 100644 --- a/assets/icons.go +++ b/assets/icons.go @@ -6,9 +6,10 @@ package assets import "embed" // Icons holds the status icons and the line icons of docs/spec/README.md as -// `icons/.svg`. +// `icons/.svg`, and the brand marks of servers (Simple Icons, CC0) as +// `icons/brands/.svg`. // -//go:embed icons/*.svg +//go:embed icons/*.svg icons/brands/*.svg var Icons embed.FS // Fonts holds the faces of JetBrains Mono and Inter as `fonts/.ttf`. diff --git a/assets/icons/brands/atlassian.svg b/assets/icons/brands/atlassian.svg new file mode 100644 index 00000000..87521386 --- /dev/null +++ b/assets/icons/brands/atlassian.svg @@ -0,0 +1 @@ +Atlassian \ No newline at end of file diff --git a/assets/icons/brands/datadog.svg b/assets/icons/brands/datadog.svg new file mode 100644 index 00000000..fafc6d5c --- /dev/null +++ b/assets/icons/brands/datadog.svg @@ -0,0 +1 @@ +Datadog \ No newline at end of file diff --git a/assets/icons/brands/figma.svg b/assets/icons/brands/figma.svg new file mode 100644 index 00000000..6a06daa6 --- /dev/null +++ b/assets/icons/brands/figma.svg @@ -0,0 +1 @@ +Figma \ No newline at end of file diff --git a/assets/icons/brands/github.svg b/assets/icons/brands/github.svg new file mode 100644 index 00000000..538ec5bf --- /dev/null +++ b/assets/icons/brands/github.svg @@ -0,0 +1 @@ +GitHub \ No newline at end of file diff --git a/assets/icons/brands/gmail.svg b/assets/icons/brands/gmail.svg new file mode 100644 index 00000000..9ee779ad --- /dev/null +++ b/assets/icons/brands/gmail.svg @@ -0,0 +1 @@ +Gmail \ No newline at end of file diff --git a/assets/icons/brands/googlecalendar.svg b/assets/icons/brands/googlecalendar.svg new file mode 100644 index 00000000..6049d100 --- /dev/null +++ b/assets/icons/brands/googlecalendar.svg @@ -0,0 +1 @@ +Google Calendar \ No newline at end of file diff --git a/assets/icons/brands/googledrive.svg b/assets/icons/brands/googledrive.svg new file mode 100644 index 00000000..9f44a507 --- /dev/null +++ b/assets/icons/brands/googledrive.svg @@ -0,0 +1 @@ +Google Drive \ No newline at end of file diff --git a/assets/icons/brands/intercom.svg b/assets/icons/brands/intercom.svg new file mode 100644 index 00000000..3a0ec690 --- /dev/null +++ b/assets/icons/brands/intercom.svg @@ -0,0 +1 @@ +Intercom \ No newline at end of file diff --git a/assets/icons/brands/jira.svg b/assets/icons/brands/jira.svg new file mode 100644 index 00000000..417c09c5 --- /dev/null +++ b/assets/icons/brands/jira.svg @@ -0,0 +1 @@ +Jira \ No newline at end of file diff --git a/assets/icons/brands/linear.svg b/assets/icons/brands/linear.svg new file mode 100644 index 00000000..f3770d65 --- /dev/null +++ b/assets/icons/brands/linear.svg @@ -0,0 +1 @@ +Linear \ No newline at end of file diff --git a/assets/icons/brands/mixpanel.svg b/assets/icons/brands/mixpanel.svg new file mode 100644 index 00000000..4ce12626 --- /dev/null +++ b/assets/icons/brands/mixpanel.svg @@ -0,0 +1 @@ +Mixpanel \ No newline at end of file diff --git a/assets/icons/brands/notion.svg b/assets/icons/brands/notion.svg new file mode 100644 index 00000000..2917f42e --- /dev/null +++ b/assets/icons/brands/notion.svg @@ -0,0 +1 @@ +Notion \ No newline at end of file diff --git a/assets/icons/brands/slack.svg b/assets/icons/brands/slack.svg new file mode 100644 index 00000000..004e2663 --- /dev/null +++ b/assets/icons/brands/slack.svg @@ -0,0 +1 @@ +Slack \ No newline at end of file diff --git a/assets/icons/brands/spotify.svg b/assets/icons/brands/spotify.svg new file mode 100644 index 00000000..8d4d095f --- /dev/null +++ b/assets/icons/brands/spotify.svg @@ -0,0 +1 @@ +Spotify \ No newline at end of file diff --git a/cmd/contr/e2e_chat_call_test.go b/cmd/contr/e2e_chat_call_test.go index 67fbe9c3..a4853976 100644 --- a/cmd/contr/e2e_chat_call_test.go +++ b/cmd/contr/e2e_chat_call_test.go @@ -18,8 +18,8 @@ func TestChatCallDetail(t *testing.T) { c.waitLabel(t, "lead, ") c.settle(t) c.press(t, "Chat") - c.waitLabel(t, "work group 10 tool calls") - c.press(t, "work group 10 tool calls") + c.waitLabel(t, "work group 12 tool calls") + c.press(t, "work group 12 tool calls") c.waitLabel(t, "tool call Bash") c.settle(t) c.press(t, "tool call Bash") diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index 380aba4a..672296dd 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -223,10 +223,10 @@ var seeFixtures = map[string]seeFixture{ press: []string{"Chat", "wait:image 2"}, }, "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, other MCP, 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", + 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, ready: "lead, ", - press: []string{"Chat", "wait:work group 10 tool calls", "work group 10 tool calls", "wait:tool call Grep", "tool call Bash", "wait:call detail Bash"}, + press: []string{"Chat", "wait:work group 12 tool calls", "work group 12 tool calls", "wait:tool call Grep", "tool call Bash", "wait:call detail Bash"}, }, "session": { about: "the orchestrator lead with the todos and artifacts of canvas board 11 (D197)", @@ -444,6 +444,8 @@ func seeChatTools(t *testing.T, h *harness) { {"mcp__claude_ai_Slack__slack_send_message", map[string]any{"channel": "#dev", "text": "the call line has icons"}}, {"mcp__plugin_contr_contr__send", map[string]any{"to": "b-709", "body": "merge main"}}, {"mcp__linear__list_issues", map[string]any{"query": "chat rows"}}, + {"mcp__claude_ai_Notion__notion_search", map[string]any{"query": "chat spec"}}, + {"mcp__acme__list_things", map[string]any{"query": "rows"}}, {"Task", map[string]any{"subagent_type": "Explore", "description": "Find the call line"}}, {"WebFetch", map[string]any{"url": "https://example.com/docs/chat"}}, {"Bash", map[string]any{"command": "go test ./internal/app/chatview", "description": "Run the chat tests"}}, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cd413432..ae133572 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -248,7 +248,7 @@ replaces it. | D271 | 2026-11-10 | The chat's composer is one card (#682; owner's pick "one card"; docs/spec/ChatView.md, Composer). One surface (`hover` fill, 1px `input-line` border, `radius-12`) holds, from top to bottom: the team row or the live line as its top line, with no box of its own (none: no top line); room for attachment chips; the field, which grows from 1 to 8 lines of `prose` (21px lines) and then scrolls; at its right, in line with its last line, one round 28px button; and a foot with the model and context in mono `meta`, `ink-48`, under the field and outside it. The button is Send with text (an `ink-100` fill with a `ground` arrow, the one strong thing in the card), also while the agent works, as the message queues (D265); Stop (a quiet outline with a square) while the agent works and the field is empty; else Send dimmed, which takes no click. Enter, ⇧Enter and Esc do what they did (D252). At rest with the team row the card is 77px high; the team row, its gap and the old field box were 76px. | The owner found the old input weak: Stop and the model took the field's width, the team row and the field read as two strips, and no clear send action showed. One card ties them, keeps the dock low (questions and to-dos stay in the notch), and adds no hue. | | D272 | 2026-11-11 | Chat text size (#706, tracker #634). Settings → General gets a Chat text row under Appearance: Small, Default, Large, Larger on `SegmentedBase`, stored as `chatText` in `settings.json` (`internal/prefs`; missing or unknown is Default). The scales are 0.9, 1, 1.15 and 1.3. The chat applies it through one helper (`Chat.ts`) that multiplies the size and line height of each type style it uses, and through the Markdown theme's font size (its other sizes are in em). The terminal and the chrome keep their size. MyGo's `TextScale` is for the whole window, so it does not fit. A pick shows at once. | Owner in #706. | | D273 | 2026-11-11 | The composer completes slash commands and skills (#707, tracker #634; docs/spec/ChatView.md, Slash popup). "/" at the start of the field, with no space after it, opens a popup above the card with the session's commands and skills (name and one-line description), names that start with the typed text first, then names that hold it. Up and Down move, Enter or Tab put "/name " in the field and send nothing, a click does the same, Esc closes it until the text changes; with no match Enter sends as typed. Claude Code gives the list only inside its own process (the stream-json init message), not to a terminal session that contr watches, so package `slash` reads the folders Claude Code reads: the built-in commands (a fixed list), `commands/**/*.md` and `skills/*/SKILL.md` of the project's and the user's `.claude`, and of each plugin in `installed_plugins.json` (prefixed "plugin:"); the frontmatter gives name and description. The read runs off the UI thread and is kept per session folder for the life of the app. The popup takes its list from a `SuggestSource`, so the "@" path popup (#708) can use it with another source. | -| D274 | 2026-11-11 | Tool calls in the chat get a kind, a label and a key argument (#709, S1). `transcript.Describe` reads the tool name and input: Skill ("Skill · chat-writing", a plugin prefix is the server, key argument the first line of `args`), Connector (`mcp__claude_ai___`: "Google Calendar · list events"), Plugin (`mcp__plugin____`: "contr · send"), other MCP ("slack · x"), Agent (`Task`, `Agent`: "Agent · ", "general" when none; key argument the description), Web ("Fetch" with host and path, "Search" with the query), Shell (Bash: the description, else the command), File (the base name of the path), Search (Grep, Glob: the pattern), else Other (the name and the old summary). An MCP key argument is the first of `channel`, `query`, `title`, `url`, `id`, `text`, `message`, `prompt` (a plugin tool also `to`, `worker`). A tool word's `_` becomes a space. `Call` keeps the kind, the label, the key argument as `Summary`, and at most 6 input pairs (values cut to 200 runes) for a later detail. The call line and the live line show the label. Icons and the click detail come later in #709. | Owner in #709: skills, connectors and MCP tools showed raw names and no target. S2: a call line starts with a 14px slot: the working glyph while the call runs, else the line icon of its kind in `ink-48` (own drawings `skill`, `connector`, `plugin`, `agent`, `globe`; `terminal`, `file`, `search` reused; none for other tools; no brand logos). A tool name that starts with its server's name does not repeat it: "Slack · send message", "Notion · search". The folded group row text does not change. S3: a click on a call line opens its detail under it (`CollapsibleBase`, open state per call id): the input pairs (key `ink-48`, value `ink-64`), then the first 4 lines of the result excerpt, an error one in `ink-64`, no red. | +| D274 | 2026-11-11 | Tool calls in the chat get a kind, a label and a key argument (#709, S1). `transcript.Describe` reads the tool name and input: Skill ("Skill · chat-writing", a plugin prefix is the server, key argument the first line of `args`), Connector (`mcp__claude_ai___`: "Google Calendar · list events"), Plugin (`mcp__plugin____`: "contr · send"), other MCP ("slack · x"), Agent (`Task`, `Agent`: "Agent · ", "general" when none; key argument the description), Web ("Fetch" with host and path, "Search" with the query), Shell (Bash: the description, else the command), File (the base name of the path), Search (Grep, Glob: the pattern), else Other (the name and the old summary). An MCP key argument is the first of `channel`, `query`, `title`, `url`, `id`, `text`, `message`, `prompt` (a plugin tool also `to`, `worker`). A tool word's `_` becomes a space. `Call` keeps the kind, the label, the key argument as `Summary`, and at most 6 input pairs (values cut to 200 runes) for a later detail. The call line and the live line show the label. Icons and the click detail come later in #709. | Owner in #709: skills, connectors and MCP tools showed raw names and no target. S2: a call line starts with a 14px slot: the working glyph while the call runs, else the line icon of its kind in `ink-48` (own drawings `skill`, `connector`, `plugin`, `agent`, `globe`; `terminal`, `file`, `search` reused; none for other tools; no brand logos). A tool name that starts with its server's name does not repeat it: "Slack · send message", "Notion · search". The folded group row text does not change. S3: a click on a call line opens its detail under it (`CollapsibleBase`, open state per call id): the input pairs (key `ink-48`, value `ink-64`), then the first 4 lines of the result excerpt, an error one in `ink-64`, no red. S4 (owner picked option C): a connector, plugin or other MCP call of a known server shows the server's brand mark in place of its kind icon, same 14px slot and `ink-48`, no brand colors (D119, D261). The marks are the monochrome SVGs of Simple Icons (CC0) in `assets/icons/brands/`: Slack, Notion, Gmail, Google Calendar, Google Drive, GitHub, Linear, Figma, Mixpanel, Spotify, Jira, Atlassian, Datadog, Intercom. One table in `chatview/tools.go` maps the server name to a mark, without regard to case, spaces, `_`, `-` and the prefixes `claude.ai `, `claude_ai_` and `plugin__`; a server with no mark keeps the kind icon. | | D275 | 2026-11-11 | Links in the chat look clickable (#710, tracker #634). A sixth content color, `content-link` (Dark `#8cb8ff` 9.6:1, Light `#1a5fd0` 5.8:1 on the stage), is the link color of the chat's Markdown theme, so Markdown links, bare URLs, file links and chips all take it in one place; MyGo's `ui.Link` underlines a link while the pointer is over it. It differs from `content-running`, so a link does not read as a state. Plain text never uses it (docs/spec/tokens.md, docs/spec/ChatView.md). | | D276 | 2026-11-11 | The composer gets layout B of board Y5 (#711, owner pick, tracker #634; docs/spec/ChatView.md, Composer). The card holds the field (14px on 22px lines) and one 30px round button, with `space-8` padding: Send (`ink-100` fill, arrow up) while the field has text, Stop (1px ring, 9px square) while the agent works and the field is empty, and no button when idle and empty (was Send dimmed, D271). The Working line inside the card goes away. Under the card, outside it, one footer line 18px high, 16px side inset, mono `meta`, `ink-48`: at the left the working glyph and "Working 7s · Bash · gh pr diff 633" while the agent works (what it does clips with `…`; the model text never clips), else "⏎ send · ⇧⏎ new line"; at the right model, effort and context. The team row moves out of the card: it sits directly above it, 16px inset, no fill or border, and has no live text, so working shows once. Replaces the top line and foot parts of D271. | | D277 | 2026-11-11 | Settings → Updates updates hc (#712). The hc row gets an `Update hc` button that runs `hc update --json` off the UI thread after a confirm (`Update hc?`, `hc downloads the newest version and restarts the core. Running terminals keep running.`, `Cancel` and `Update`; Cancel and Escape run nothing) (5 minute limit) and shows `updating hc…`, the new version, `hc is up to date` or hc's error. hc has no check or dry-run flag, so contr shows no "newer hc" before the update and uses no release URL of its own. `hc update` replaces the hc program and restarts the core by itself; the terminals keep running (tried on a scratch core). So the result line says the core restarted on the new version; contr has no restart button of its own and never runs the update without a click (docs/spec/SettingsUpdates.md). | Owner in #712. | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index eb3e0007..04bafc21 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -52,7 +52,7 @@ The sizes in this file are the Default chat text size. The Chat text setting (D2 | Work group, open | `⌄`; under it a card, a wide block: at least 584px (or the content width when less), as wide as its longest line (note, task and call lines alike), up to the content width, `space-6` below the row, padding `8px 12px`, radius `radius-9`, line `hairline`: the note lines, a `hairline` rule, the call lines | | Note line | 20px, gap `space-8`, mono `meta`: the sender's dot, the name (500, `ink-88`), the text on one line (`ink-48`). An owner answer or done mark has the name "owner". A message of another Claude session or a teammate (D254) has the sender as its name ("session" when it has none) and the first line of the message; a subagent's hand-back is "subagent" and "reported". | | Task line | a background task that ended (D254), as a note line without a dot: "Background task" (500, `ink-88`), its status (`ink-48`), then "· " and its summary on one line (`ink-48`), without a leading "Background command " | -| Call line | 20px, gap `space-8`, mono `meta`: a 14px slot with a working StatusGlyph while it runs, else the line icon of the kind in `ink-48` (`skill`, `connector` for connectors and other MCP tools, `plugin`, `agent`, `globe` for web, `terminal` for shell, `file`, `search`; empty for other tools, D274), the label (`ink-64`, at least 48px: "Bash", "Skill · chat-writing", "Slack · send message", "contr · send", "Agent · Explore", "Fetch"; D274), the key argument on one line (`ink-48`: a file's base name, Bash's description or command, a pattern, a URL's host and path, an MCP tool's channel, query or target), "error" in `blocked` when the result is an error, the duration (`ink-48`): "0.4s", "12s", "2m 5s", from the timestamps of the call and its result, none when one is missing | +| Call line | 20px, gap `space-8`, mono `meta`: a 14px slot with a working StatusGlyph while it runs, else the line icon of the kind in `ink-48` (`skill`, `connector` for connectors and other MCP tools, `plugin`, `agent`, `globe` for web, `terminal` for shell, `file`, `search`; empty for other tools, D274); a connector or MCP call of a known server shows the server's brand mark instead (Simple Icons, monochrome, same size and `ink-48`, no brand colors: Slack, Notion, Gmail, Google Calendar, Google Drive, GitHub, Linear, Figma, Mixpanel, Spotify, Jira, Atlassian, Datadog, Intercom; case and the prefixes `claude.ai `, `claude_ai_`, `plugin__` do not count; D274), the label (`ink-64`, at least 48px: "Bash", "Skill · chat-writing", "Slack · send message", "contr · send", "Agent · Explore", "Fetch"; D274), the key argument on one line (`ink-48`: a file's base name, Bash's description or command, a pattern, a URL's host and path, an MCP tool's channel, query or target), "error" in `blocked` when the result is an error, the duration (`ink-48`): "0.4s", "12s", "2m 5s", from the timestamps of the call and its result, none when one is missing | | Call detail | a click on a call line (or Enter or Space while it has the focus; MyGo's `CollapsibleBase`) opens a detail under it, and a second click closes it; open state per call id. Indented past the icon slot, mono `meta` (the chat text size, D272): each input pair as "key:" in `ink-48` and the value in `ink-64` (at most 6, values cut to 200 runes), then `space-4` and the start of the result: its first 4 lines with "…" when it has more (at most 200 runes), in `ink-48`; an error result in `ink-64`, no red (only the line's "error" is `blocked`). A call with no input shows only the result; a running call only its input (D274). | | Failed task | a task line whose status is "failed" or "killed" stays outside the fold: no dot, "Background task" and the status in `ink-100`, the summary in `ink-88`; no red (D259). Every other note folds, a worker's message too ("ended its turn without a report", "NAME: blocked, …"): the team row and the notch tell what needs the owner. | | Dot | 6px, round: `ink-100` for a blocked worker (no red: the team row's "N needs you" is the chat's one red for it, D259), `ink-88` for a working one, else `ink-30` | diff --git a/internal/app/chatview/sheet_test.go b/internal/app/chatview/sheet_test.go index 8a37f2db..d443053f 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -53,6 +53,15 @@ func TestSpecSheet(t *testing.T) { images.s.ch.attached[term] = 2 composer := sheetComposer(t) calls := sheetChat(t, "working", append(mixedCalls[:len(mixedCalls)-1], callOf("k11", "Grep", `{"pattern":"kindIcon"}`))...) + brandCalls := []string{lPrompt} + for i, n := range []string{"mcp__claude_ai_Slack__slack_send_message", "mcp__claude_ai_Notion__notion_search", + "mcp__claude_ai_Gmail__search_threads", "mcp__claude_ai_Google_Calendar__list_events", "mcp__github__get_issue", + "mcp__plugin_design_figma__get_design", "mcp__plugin_engineering_datadog__search_logs", "mcp__claude_ai_Atlassian__get_issue", + "mcp__acme__list_things"} { + id := "b" + strconv.Itoa(i) + brandCalls = append(brandCalls, callOf(id, n, `{"query":"chat rows"}`), resultOf(id)) + } + brandsView := sheetChat(t, "idle", append(brandCalls, said("s1", "Done."))...) detail := sheetChat(t, "idle", lPrompt, callOf("k1", "Bash", `{"command":"go test ./internal/app/chatview","description":"Run the chat tests"}`), `{"type":"user","uuid":"rk1","timestamp":"2026-11-10T10:00:02.4Z","message":{"content":[{"type":"tool_result","tool_use_id":"k1","content":"ok \tchatview\t2.1s\nok \ttranscript\t0.4s"}]}}`, callOf("k2", "Read", `{"file_path":"/repo/a.go"}`), errOf("k2", "File does not exist."), said("s1", "Done.")) @@ -73,8 +82,9 @@ func TestSpecSheet(t *testing.T) { {Name: "ChatViewComposer", W: sheetW, H: composerSheetH, View: composer}, {Name: "ChatViewImages", W: sheetW, H: 420, View: images.view}, {Name: "CallLines", W: sheetW, H: 420, View: calls.view}, + {Name: "CallLinesBrands", W: sheetW, H: 420, View: brandsView.view}, {Name: "CallDetail", W: sheetW, H: 360, View: detail.view}, - }, []string{"ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallDetail"}) + }, []string{"ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallLinesBrands", "CallDetail"}) } // contentAnswer has each kind of content: headings, lists, a quote, the five remarks, a table diff --git a/internal/app/chatview/tools.go b/internal/app/chatview/tools.go index 57080683..799fa714 100644 --- a/internal/app/chatview/tools.go +++ b/internal/app/chatview/tools.go @@ -1,6 +1,10 @@ package chatview -import "github.com/contursofran/contr/internal/transcript" +import ( + "strings" + + "github.com/contursofran/contr/internal/transcript" +) // kindIcons are the line icons of the tool kinds (D274). KindOther has none. var kindIcons = map[transcript.ToolKind]string{ @@ -17,3 +21,61 @@ var kindIcons = map[transcript.ToolKind]string{ // kindIcon gives the line icon of a tool kind, "" when it has none. func kindIcon(k transcript.ToolKind) string { return kindIcons[k] } + +// brands maps a server name, in lower case with no spaces, "_" or "-", to its brand mark in +// assets/icons/brands (D274). A server that is not here keeps the kind icon. +var brands = map[string]string{ + "slack": "slack", + "notion": "notion", + "gmail": "gmail", + "googlecalendar": "googlecalendar", + "googledrive": "googledrive", + "github": "github", + "linear": "linear", + "figma": "figma", + "mixpanel": "mixpanel", + "spotify": "spotify", + "jira": "jira", + "atlassian": "atlassian", + "datadog": "datadog", + "intercom": "intercom", +} + +// brandOf gives the brand mark of a call's server, "" when it has none. Only connector, plugin +// and other MCP calls have a server. Case and the prefixes "claude.ai ", "claude_ai_" and +// "plugin__" do not count: "mcp__plugin_design_slack__x" and "claude.ai Slack" are both +// Slack. +func brandOf(call *transcript.Call) string { + rest, ok := strings.CutPrefix(call.Name, "mcp__") + if !ok { + return "" + } + srv, _, _ := strings.Cut(rest, "__") + return brand(srv) +} + +// brand gives the brand mark of a server name, "" when it has none. +func brand(server string) string { + s := strings.ToLower(strings.TrimSpace(server)) + for _, p := range []string{"claude.ai ", "claude_ai_"} { + s = strings.TrimPrefix(s, p) + } + if rest, ok := strings.CutPrefix(s, "plugin_"); ok { + // "plugin__": the plugin name and the server name can both have "_", so + // try each tail, longest first. + for { + _, tail, more := strings.Cut(rest, "_") + if !more { + return "" + } + if b := brands[squash(tail)]; b != "" { + return b + } + rest = tail + } + } + return brands[squash(s)] +} + +// squash drops the spaces, "_" and "-" of a name. +func squash(s string) string { return strings.NewReplacer(" ", "", "_", "", "-", "").Replace(s) } diff --git a/internal/app/chatview/tools_test.go b/internal/app/chatview/tools_test.go index f06e43b7..b7377220 100644 --- a/internal/app/chatview/tools_test.go +++ b/internal/app/chatview/tools_test.go @@ -9,6 +9,7 @@ import ( "github.com/egoist/mygo/ui" "github.com/contursofran/contr/internal/app/apptest" + "github.com/contursofran/contr/internal/app/icons" "github.com/contursofran/contr/internal/app/theme" "github.com/contursofran/contr/internal/transcript" ) @@ -47,8 +48,8 @@ func TestCallLineIcon(t *testing.T) { tt := ui.NewTester(v.view, sheetW, 700) tt.Frame() for label, icon := range map[string]string{ - "Skill · chat-writing": "skill", "Slack · send message": "connector", "contr · send": "plugin", - "linear · list issues": "connector", "Agent · Explore": "agent", "Fetch": "globe", + "Skill · chat-writing": "skill", "Slack · send message": LabelBrand + " slack", "contr · send": "plugin", + "linear · list issues": LabelBrand + " linear", "Agent · Explore": "agent", "Fetch": "globe", "Bash": "terminal", "Read": "file", "Grep": "search", } { if apptest.Absent(tt, LabelCall+" "+label) { @@ -168,3 +169,56 @@ func TestExcerptText(t *testing.T) { t.Errorf("long: %q", got) } } + +// TestBrand: a server name maps to its mark without regard to case and to the "claude.ai ", +// "claude_ai_" and "plugin__" prefixes; an unknown server has none (D274). +func TestBrand(t *testing.T) { + for in, want := range map[string]string{ + "Slack": "slack", "claude.ai Slack": "slack", "claude_ai_Slack": "slack", "SLACK": "slack", + "claude_ai_Google_Calendar": "googlecalendar", "Google Drive": "googledrive", + "plugin_design_figma": "figma", "plugin_engineering_datadog": "datadog", + "plugin_design_google_calendar": "googlecalendar", "linear": "linear", "github": "github", + "Atlassian": "atlassian", "jira": "jira", "contr": "", "plugin_contr_contr": "", "": "", + "acme": "", "plugin_design": "", + } { + if got := brand(in); got != want { + t.Errorf("brand(%q) = %q, want %q", in, got, want) + } + } + for name, want := range map[string]string{ + "mcp__claude_ai_Notion__search": "notion", "mcp__plugin_design_slack__x": "slack", + "mcp__acme__x": "", "Bash": "", "Skill": "", + } { + if got := brandOf(&transcript.Call{Name: name}); got != want { + t.Errorf("brandOf(%q) = %q, want %q", name, got, want) + } + } +} + +// TestCallLineBrand: a call of a known server shows its brand mark; an unknown server keeps the +// generic connector icon (D274). +func TestCallLineBrand(t *testing.T) { + v := sheetChat(t, "idle", lPrompt, + callOf("k1", "mcp__claude_ai_Notion__notion_search", `{"query":"specs"}`), resultOf("k1"), + callOf("k2", "mcp__acme__list_things", `{"query":"x"}`), resultOf("k2"), said("s1", "Done.")) + tt := ui.NewTester(v.view, sheetW, 400) + tt.Frame() + if apptest.Absent(tt, LabelCallIcon+" "+LabelBrand+" notion") { + t.Error("a Notion call shows no Notion mark") + } + if apptest.Absent(tt, LabelCallIcon+" connector") { + t.Error("an unknown server does not keep the connector icon") + } + if !apptest.Absent(tt, LabelCallIcon+" "+LabelBrand+" acme") { + t.Error("an unknown server shows a mark") + } +} + +// TestBrandMarks: each mark of the table is embedded and parses. +func TestBrandMarks(t *testing.T) { + for _, b := range brands { + if icons.Brand(b) == nil { + t.Errorf("no mark %q", b) + } + } +} diff --git a/internal/app/chatview/view.go b/internal/app/chatview/view.go index e356e9a8..3f0aa773 100644 --- a/internal/app/chatview/view.go +++ b/internal/app/chatview/view.go @@ -35,6 +35,9 @@ const ( LabelCall = "tool call" // LabelCallIcon names the kind icon of a call line, followed by the icon's name. LabelCallIcon = "tool icon" + // LabelBrand follows LabelCallIcon when the icon is the brand mark of the call's server, + // followed by the mark's name (D274). + LabelBrand = "brand" // LabelCallDetail names the detail under an open call line, followed by the call's name. LabelCallDetail = "call detail" // LabelFade names the gradient over the bottom of the transcript, above the dock. @@ -373,6 +376,8 @@ func (ch *Chat) callHead(c *ui.Context, t ui.Element, e transcript.Entry, name s slot.Children(func() { if !call.Done { kit.StatusGlyph(c, kit.GlyphOpts{State: kit.GlyphWorking, Small: true}) + } else if b := brandOf(call); b != "" && icons.Brand(b) != nil { + ui.Icon(c, icons.Brand(b)).Size(callIconSize, callIconSize).Label(LabelCallIcon + " " + LabelBrand + " " + b) } else if icon := kindIcon(call.Kind); icon != "" { if s := icons.Line(icon); s != nil { ui.Icon(c, s).Size(callIconSize, callIconSize).Label(LabelCallIcon + " " + icon) diff --git a/internal/app/icons/icons.go b/internal/app/icons/icons.go index 6f7b2aad..bcd35d75 100644 --- a/internal/app/icons/icons.go +++ b/internal/app/icons/icons.go @@ -6,6 +6,7 @@ package icons import ( "log" + "strings" "sync" "time" @@ -90,25 +91,8 @@ var ( func svg(name string) *ui.SVG { loadOnce.Do(func() { parsed = make(map[string]*ui.SVG) - files, err := assets.Icons.ReadDir("icons") - if err != nil { - log.Printf("icons: %v", err) - return - } - for _, f := range files { - base := f.Name()[:len(f.Name())-len(".svg")] - data, err := assets.Icons.ReadFile("icons/" + f.Name()) - if err != nil { - log.Printf("icons: %v", err) - continue - } - s, err := ui.ParseSVG(data) - if err != nil { - log.Printf("icons: %s: %v", f.Name(), err) - continue - } - parsed[base] = s - } + load("icons", "") + load("icons/brands", brandPrefix) }) return parsed[name] } @@ -163,3 +147,32 @@ func Monitor(c *ui.Context, size float32) ui.Element { } return ui.Icon(c, svg("monitor")).Size(size, size).Label("monitor") } + +// brandPrefix starts the key of a brand mark in parsed. +const brandPrefix = "brand/" + +// load parses the SVG files of folder dir into parsed, each under prefix and its base name. +func load(dir, prefix string) { + files, err := assets.Icons.ReadDir(dir) + if err != nil { + log.Printf("icons: %v", err) + return + } + for _, f := range files { + if f.IsDir() { + continue + } + base := strings.TrimSuffix(f.Name(), ".svg") + data, err := assets.Icons.ReadFile(dir + "/" + f.Name()) + if err != nil { + log.Printf("icons: %v", err) + continue + } + s, err := ui.ParseSVG(data) + if err != nil { + log.Printf("icons: %s: %v", f.Name(), err) + continue + } + parsed[prefix+base] = s + } +} diff --git a/internal/app/icons/line.go b/internal/app/icons/line.go index 02d7a1f0..4def616e 100644 --- a/internal/app/icons/line.go +++ b/internal/app/icons/line.go @@ -16,3 +16,8 @@ var LineNames = [...]string{ // Line returns the line icon with this name, or nil when the file is missing // or does not parse (logged once). func Line(name string) *ui.SVG { return svg(name) } + +// Brand returns the brand mark with this name (`icons/brands/.svg`, from Simple Icons), or +// nil when there is none. A mark has one fill, so it takes the color of the text like a line +// icon (D274). +func Brand(name string) *ui.SVG { return svg(brandPrefix + name) } From c01ed19f2111630ed97f109f6999e4cbdd4f0f47 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 15:28:25 -0300 Subject: [PATCH 08/14] Chat: a contr-flow block draws as a flow diagram (#696, D284) --- internal/app/chatview/content.go | 18 +- internal/app/chatview/content_test.go | 28 +++ internal/app/chatview/sheet_test.go | 10 +- internal/app/diagram/diagram_test.go | 116 +++++++++++ internal/app/diagram/layout.go | 148 +++++++++++++++ internal/app/diagram/parse.go | 86 +++++++++ internal/app/diagram/view.go | 264 ++++++++++++++++++++++++++ 7 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 internal/app/diagram/diagram_test.go create mode 100644 internal/app/diagram/layout.go create mode 100644 internal/app/diagram/parse.go create mode 100644 internal/app/diagram/view.go 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 8a37f2db..2b70772c 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -60,6 +60,7 @@ func TestSpecSheet(t *testing.T) { open := true detail.s.ch.calls[id] = &open } + flow := sheetChat(t, "idle", lPrompt, said("f1", flowAnswer)) apptest.SpecSheet(t, []apptest.SheetPart{ {Name: "ChatView", W: sheetW, H: sheetH, View: working.view}, {Name: "ChatViewWide", W: 1500, H: 420, View: wide.view}, @@ -74,7 +75,8 @@ func TestSpecSheet(t *testing.T) { {Name: "ChatViewImages", W: sheetW, H: 420, View: images.view}, {Name: "CallLines", W: sheetW, H: 420, View: calls.view}, {Name: "CallDetail", W: sheetW, H: 360, View: detail.view}, - }, []string{"ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallDetail"}) + {Name: "ChatFlow", W: sheetW, H: 620, View: flow.view}, + }, []string{"ChatFlow", "ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallDetail"}) } // contentAnswer has each kind of content: headings, lists, a quote, the five remarks, a table @@ -98,6 +100,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. " + 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 +} From ec7ffd49cbce30f16dd6c96ce7e624b2487212dd Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 15:31:42 -0300 Subject: [PATCH 09/14] Chat: see fixture chat-flow, D284 and the Diagrams spec (#696) --- cmd/contr/e2e_see_flow_test.go | 55 ++++++++++++++++++++++++++++++++++ cmd/contr/e2e_see_test.go | 6 ++++ docs/DECISIONS.md | 1 + docs/spec/ChatView.md | 12 +++++++- 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 cmd/contr/e2e_see_flow_test.go diff --git a/cmd/contr/e2e_see_flow_test.go b/cmd/contr/e2e_see_flow_test.go new file mode 100644 index 00000000..3c801c9e --- /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 380aba4a..b10f418e 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -222,6 +222,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, other MCP, 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 a14052c6..7fc6af77 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -255,3 +255,4 @@ replaces it. | D278 | 2026-11-11 | Settings → Setup: a read-only setup checklist (#713, S1). `Setup` is the first Settings tab: `Setup`, `General`, `Agents`, `Updates` (changes the tab list of D186). Package `internal/setup` checks hc (`hc --version`), the hc core (the frame's live state), the contr plugin (`claude plugin list --json`), the Claude meter (data under 24 h old), gh (`gh auth status`), and the optional codex and `contr` command. Each command has a 5 s limit and runs off the UI thread when Settings opens, on Recheck, and on activation while the tab shows; nothing runs while Settings is closed. No check reads files in `~/.claude` or `~/.codex`. A step shows a line with a Copy button; contr runs no command here (S3 adds a confirmed plugin install, D65) and never edits Claude Code's settings (the meter stays copy only, D66). The Setup tab and the sidebar Settings button get the D146 dot while a required check is not ready. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing in one place. | | D280 | 2026-11-11 | The first launch opens Settings → Setup (#713, S2). The setup checks of D278 run once at start, off the UI thread, after the window shows, so the D146 dot shows without opening Settings; then only when Settings opens, on Recheck and on activation while the tab shows; no timer. When the start pass finds a required check not ready (the hc core not counted: it can still be connecting) and `setupSeen` in the settings file is false, Settings opens on the Setup tab once and `setupSeen` is stored. It never opens by itself again. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing without a search. | | 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". | +| 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. | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index eb3e0007..d6cfbaf9 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -70,7 +70,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 | |---|---| @@ -275,6 +275,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 From eec430ad1916968b6be55f23dedd459cb499fa03 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 15:33:45 -0300 Subject: [PATCH 10/14] Chat: a worker shows the mark of its model family (#714, D285) A worker of the Claude, OpenAI or DeepSeek family shows its mark in place of its 6px dot, in the dot's ink, and hops while it works. Another program keeps the dot. Adds the OpenAI and DeepSeek marks of Simple Icons. --- NOTICE | 3 +- assets/icons/brands/deepseek.svg | 1 + assets/icons/brands/openai.svg | 1 + docs/DECISIONS.md | 1 + docs/spec/ChatView.md | 3 +- internal/app/chat.go | 1 + internal/app/chatview/chat.go | 3 + internal/app/chatview/family.go | 98 ++++++++++++++++++++++++++ internal/app/chatview/family_test.go | 101 +++++++++++++++++++++++++++ internal/app/chatview/sheet_test.go | 27 ++++++- internal/app/chatview/view.go | 26 +++++-- 11 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 assets/icons/brands/deepseek.svg create mode 100644 assets/icons/brands/openai.svg create mode 100644 internal/app/chatview/family.go create mode 100644 internal/app/chatview/family_test.go diff --git a/NOTICE b/NOTICE index a249abb3..52cb373e 100644 --- a/NOTICE +++ b/NOTICE @@ -24,7 +24,8 @@ changes. The brand marks in assets/icons/brands/ are icons of Simple Icons (https://github.com/simple-icons/simple-icons), v16.37.0, released under CC0 1.0 Universal; they are used without changes. The marks belong to their -owners; contr uses them only to show which service a tool call goes to. +owners; contr uses them only to show which service a tool call goes to, or +which model family a worker runs on. The Claude Code mascot (assets/icons/claude-mascot.svg) is the "Claude Code" icon of LobeHub icons (https://github.com/lobehub/lobe-icons), licensed under diff --git a/assets/icons/brands/deepseek.svg b/assets/icons/brands/deepseek.svg new file mode 100644 index 00000000..e19a4597 --- /dev/null +++ b/assets/icons/brands/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/assets/icons/brands/openai.svg b/assets/icons/brands/openai.svg new file mode 100644 index 00000000..ebbdab0e --- /dev/null +++ b/assets/icons/brands/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 69791718..6da3509e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -257,3 +257,4 @@ replaces it. | D280 | 2026-11-11 | The first launch opens Settings → Setup (#713, S2). The setup checks of D278 run once at start, off the UI thread, after the window shows, so the D146 dot shows without opening Settings; then only when Settings opens, on Recheck and on activation while the tab shows; no timer. When the start pass finds a required check not ready (the hc core not counted: it can still be connecting) and `setupSeen` in the settings file is false, Settings opens on the Setup tab once and `setupSeen` is stored. It never opens by itself again. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing without a search. | | 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". | +| 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). | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index 2023a38d..56a9fdea 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -48,7 +48,7 @@ The sizes in this file are the Default chat text size. The Chat text setting (D2 | Prompt images | the images of a prompt (D269), under the bubble, at the right, `space-6` above, gap `space-6`, wrapping: a `thumb` (64px) thumbnail for each image whose bytes are in the transcript, cropped to fill, `radius-12`, 1px `hairline`; while it is read, a `hover` box of its size. A click opens it larger: over the `scrim`, as large as it is and at most the chat less 40px at each side, `radius-12`; Escape or a click outside closes it. The images with no bytes, and those that do not read or decode, show as one quiet chip after the thumbnails: "1 image" / "2 images", mono `meta`, `ink-48`, padding `2px 8px`, `radius-6`, 1px `hairline`. Their "[Image #N]" marks do not show; a prompt of images alone has no bubble. A pending prompt with images shows the chip. | | Sent time | the time the prompt was sent, from its transcript timestamp: one line, mono `meta`, `ink-48` (the lowest text ink with 4.5:1), `space-4` above, under the owner prompt, right-aligned with the bubble. Once per turn: Claude's texts, groups and notes show no time (D266). In the Mac's time zone: "14:32" today, "Yesterday 14:32", else "9 Nov 14:32"; "today" is judged at each build, and nothing ticks (D55). No timestamp, no line; a pending prompt has none. | | Claude's text | `markdown.View`: `prose` size and line, sans; text `ink-88`, strong (500) `ink-88`; links (Markdown links, bare URLs, file links and chips) in `content-link`, underlined while the pointer is over them (D275), and nothing else in that color; muted `ink-48`; each block as [Content](#content) says | -| Work group, folded | the tool calls, kit notes and background tasks between two texts, as one row 20px high, mono `value`, `ink-48` (`ink-64` on hover): "2 worker notes · 1 background task · 6 tool calls" (a message of another session counts as a worker note; a part with no item is left out, "1 tool call"), a 6px dot for each worker that sent a note, then `›`. While a call has no result, a working StatusGlyph (14px) comes first. | +| Work group, folded | the tool calls, kit notes and background tasks between two texts, as one row 20px high, mono `value`, `ink-48` (`ink-64` on hover): "2 worker notes · 1 background task · 6 tool calls" (a message of another session counts as a worker note; a part with no item is left out, "1 tool call"), a [worker mark](#worker-mark) (or 6px dot) for each worker that sent a note, then `›`. While a call has no result, a working StatusGlyph (14px) comes first. | | Work group, open | `⌄`; under it a card, a wide block: at least 584px (or the content width when less), as wide as its longest line (note, task and call lines alike), up to the content width, `space-6` below the row, padding `8px 12px`, radius `radius-9`, line `hairline`: the note lines, a `hairline` rule, the call lines | | Note line | 20px, gap `space-8`, mono `meta`: the sender's dot, the name (500, `ink-88`), the text on one line (`ink-48`). An owner answer or done mark has the name "owner". A message of another Claude session or a teammate (D254) has the sender as its name ("session" when it has none) and the first line of the message; a subagent's hand-back is "subagent" and "reported". | | Task line | a background task that ended (D254), as a note line without a dot: "Background task" (500, `ink-88`), its status (`ink-48`), then "· " and its summary on one line (`ink-48`), without a leading "Background command " | @@ -56,6 +56,7 @@ The sizes in this file are the Default chat text size. The Chat text setting (D2 | Call detail | a click on a call line (or Enter or Space while it has the focus; MyGo's `CollapsibleBase`) opens a detail under it, and a second click closes it; open state per call id. Indented past the icon slot, mono `meta` (the chat text size, D272): each input pair as "key:" in `ink-48` and the value in `ink-64` (at most 6, values cut to 200 runes), then `space-4` and the start of the result: its first 4 lines with "…" when it has more (at most 200 runes), in `ink-48`; an error result in `ink-64`, no red (only the line's "error" is `blocked`). A call with no input shows only the result; a running call only its input (D274). | | Failed task | a task line whose status is "failed" or "killed" stays outside the fold: no dot, "Background task" and the status in `ink-100`, the summary in `ink-88`; no red (D259). Every other note folds, a worker's message too ("ended its turn without a report", "NAME: blocked, …"): the team row and the notch tell what needs the owner. | | Dot | 6px, round: `ink-100` for a blocked worker (no red: the team row's "N needs you" is the chat's one red for it, D259), `ink-88` for a working one, else `ink-30` | +| Worker mark | in place of the dot, for a worker of a known model family (D285): 12px, the Claude Code mascot (Claude: a model `opus`, `sonnet`, `haiku`, `claude-…` or the program `claude`), the OpenAI mark (`gpt-…`, `o3`, `o4`, the program `codex`) or the DeepSeek mark (`deepseek-…`), from Simple Icons, monochrome. The family comes from the worker's model, else its profile's model, else its program. In the dot's ink; while the worker works it hops 1.5px every 800ms as the working mascot does (still with Reduce Motion). No hue: the mascot is grey here too. Another program keeps the dot. | Hidden entries do not show: meta lines, system reminders, hook output (an attachment, a user line in a `<…-hook>` tag, or "Stop hook feedback:"), diff --git a/internal/app/chat.go b/internal/app/chat.go index b01f9372..702e4a9a 100644 --- a/internal/app/chat.go +++ b/internal/app/chat.go @@ -116,6 +116,7 @@ func listPaths(ctx context.Context, root, dir string) ([]chatview.Suggestion, er func (f *Frame) attachChat(env view.Env, src agentstate.AgentSource) (stop func()) { f.Chat = chatview.New(env, f.Session.StateDir, nil) f.Chat.Worker = f.agentAt + f.Chat.ModelOf = f.deps.ModelOf f.Chat.Meta = f.chatMeta f.Chat.NextPrompt = f.nextPrompt f.Chat.Team = f.chatTeam diff --git a/internal/app/chatview/chat.go b/internal/app/chatview/chat.go index 09cc6233..61d1da2a 100644 --- a/internal/app/chatview/chat.go +++ b/internal/app/chatview/chat.go @@ -25,6 +25,9 @@ type Chat struct { // Worker gives the agent at an hc address, for the state of the workers that sent notes. // May be nil. Worker func(addr string) (agents.Worker, bool) + // ModelOf names the model that an agent profile starts, for the mark of a worker (D285). May + // be nil. + ModelOf func(profile string) string // Hints shows the key chip of the switch while ⌘ is held (D245). Hints bool // Send types text into the terminal at addr and presses Enter. It blocks: it runs off the UI diff --git a/internal/app/chatview/family.go b/internal/app/chatview/family.go new file mode 100644 index 00000000..cc668e55 --- /dev/null +++ b/internal/app/chatview/family.go @@ -0,0 +1,98 @@ +package chatview + +// The mark of a worker (docs/spec/ChatView.md, D285): in the chat, a worker that sent a note shows +// the mark of the model family it runs on in place of its 6px dot. The state shows in the mark's +// ink and motion, as the dot had it: blocked ink-100, working ink-88 with the hop of the Claude +// mascot, else ink-30. No hue (D259, D261). A worker of another program keeps the dot. + +import ( + "path/filepath" + "strings" + + "github.com/egoist/mygo/ui" + + "github.com/contursofran/contr/internal/agents" + "github.com/contursofran/contr/internal/app/icons" + "github.com/contursofran/contr/internal/app/kit" +) + +// Family is the model family of a worker. +type Family string + +// The families with a mark. FamilyNone keeps the dot. +const ( + FamilyNone Family = "" + FamilyClaude Family = "claude" + FamilyOpenAI Family = "openai" + FamilyDeepSeek Family = "deepseek" +) + +// markSize is the side of a worker's mark: the small glyph's size. +const markSize = 12 + +// FamilyOf is the family of a model name or a program: the model first, then the profile's model, +// then the program. "opus", "claude-sonnet-5-5", "claude" are Claude; "gpt-6.1", "o4", "codex" +// are OpenAI; "deepseek-v4" is DeepSeek. Case does not count. Any other name: FamilyNone. +func FamilyOf(model, profileModel, command string) Family { + for _, s := range []string{model, profileModel, filepath.Base(command)} { + if f := familyOfName(strings.ToLower(strings.TrimSpace(s))); f != FamilyNone { + return f + } + } + return FamilyNone +} + +func familyOfName(s string) Family { + switch { + case s == "" || s == ".": + return FamilyNone + case strings.Contains(s, "deepseek"): + return FamilyDeepSeek + case strings.Contains(s, "claude"), strings.Contains(s, "opus"), strings.Contains(s, "sonnet"), + strings.Contains(s, "haiku"), strings.Contains(s, "anthropic"): + return FamilyClaude + case strings.Contains(s, "codex"), strings.Contains(s, "gpt"), strings.Contains(s, "openai"), + s == "o3" || s == "o4" || strings.HasPrefix(s, "o3-") || strings.HasPrefix(s, "o4-"): + return FamilyOpenAI + } + return FamilyNone +} + +// familyOf is the family of worker w, from what contr knows of it: its model label, the model of +// its profile and its program. No new read. +func (ch *Chat) familyOf(w agents.Worker) Family { + pm := "" + if ch.ModelOf != nil && w.Profile != "" { + pm = ch.ModelOf(w.Profile) + } + return FamilyOf(w.Model, pm, w.Command) +} + +// familyMark is the SVG of family f: the Claude mascot, or a brand mark of Simple Icons. Nil for +// FamilyNone or a missing file. +func familyMark(f Family) *ui.SVG { + switch f { + case FamilyClaude: + return kit.ClaudeMascot() + case FamilyOpenAI, FamilyDeepSeek: + return icons.Brand(string(f)) + } + return nil +} + +// mark draws mark m of a worker in color, labelled. While working it hops as the Claude mascot +// does (kit.ClaudeHopOffset); Reduce Motion keeps it still. +func (ch *Chat) mark(c *ui.Context, m *ui.SVG, label string, color ui.Color, working bool) { + still := c.Preferences().ReduceMotion + state := kit.GlyphIdle + if working { + state = kit.GlyphWorking + } + ui.Box(c).Label(label).Size(markSize, markSize).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + if working && !still { + p.AnimationFrameRate(30) + } + r.Y -= kit.ClaudeHopOffset(state, p.Now(), still) + p.Icon(m, r, color) + }) +} diff --git a/internal/app/chatview/family_test.go b/internal/app/chatview/family_test.go new file mode 100644 index 00000000..ed9a731b --- /dev/null +++ b/internal/app/chatview/family_test.go @@ -0,0 +1,101 @@ +package chatview + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "github.com/contursofran/contr/internal/agents" + "github.com/contursofran/contr/internal/app/apptest" + "github.com/contursofran/contr/internal/app/theme" +) + +// The family comes from the model, then the profile's model, then the program (D285). +func TestFamilyOf(t *testing.T) { + for _, tc := range []struct { + model, profile, command string + want Family + }{ + {"opus", "", "", FamilyClaude}, + {"claude-sonnet-5-5", "", "", FamilyClaude}, + {"", "", "/usr/local/bin/claude", FamilyClaude}, + {"", "haiku", "node", FamilyClaude}, + {"gpt-6.1-sol", "", "", FamilyOpenAI}, + {"", "", "codex", FamilyOpenAI}, + {"o4-mini", "", "", FamilyOpenAI}, + {"DeepSeek-V4.1-Flash", "", "opencode", FamilyDeepSeek}, + {"", "deepseek-chat", "opencode", FamilyDeepSeek}, + {"", "", "opencode", FamilyNone}, + {"", "", "zsh", FamilyNone}, + {"", "", "", FamilyNone}, + {"llama", "", "", FamilyNone}, + {"kimi", "", "claude", FamilyClaude}, + } { + if got := FamilyOf(tc.model, tc.profile, tc.command); got != tc.want { + t.Errorf("FamilyOf(%q, %q, %q) = %q, want %q", tc.model, tc.profile, tc.command, got, tc.want) + } + } +} + +// Each family has a mark; none has none. +func TestFamilyMark(t *testing.T) { + for _, f := range []Family{FamilyClaude, FamilyOpenAI, FamilyDeepSeek} { + if familyMark(f) == nil { + t.Errorf("no mark for %q", f) + } + } + if familyMark(FamilyNone) != nil { + t.Error("a mark for no family") + } +} + +// The ink of a worker tells its state: blocked ink-100, working ink-88, idle and done ink-30; no +// hue (D259, D285). +func TestWorkerInk(t *testing.T) { + pal := theme.Current() + for status, want := range map[string]ui.Color{"blocked": pal.Ink100, "working": pal.Ink88, "idle": pal.Ink30, "done": pal.Ink30, "": pal.Ink30} { + if got := WorkerInk(pal, status); got != want { + t.Errorf("WorkerInk(%q) = %v, want %v", status, got, want) + } + } +} + +// A worker of a known family shows its mark in place of the dot, in a box of its own, with no +// red (D285, D259). Another program keeps the dot. +func TestWorkerMarkByState(t *testing.T) { + for _, status := range []string{"working", "idle", "blocked", "done"} { + r := newRig(t) + r.ch.ModelOf = func(p string) string { + if p == "builder" { + return "sonnet" + } + return "" + } + r.ch.Worker = func(addr string) (agents.Worker, bool) { + return agents.Worker{Name: "b-631", Address: addr, Status: status, Profile: "builder"}, addr == "w2:t1" + } + r.savePath(term) + r.selectTerm(term) + r.chat() + r.write(lPrompt, lNote1, lSaid2) + box := apptest.Box(t, r.tt, "b-631 "+status) + if box.W != markSize || box.H != markSize { + t.Errorf("%s: mark is %vx%v, want %v", status, box.W, box.H, markSize) + } + if n := reddish(r.tt.Image(), box); n > 0 { + t.Errorf("%s: the mark has %d red pixels", status, n) + } + } + + r := newRig(t) + r.ch.Worker = func(addr string) (agents.Worker, bool) { + return agents.Worker{Name: "b-631", Address: addr, Status: "working", Command: "opencode"}, addr == "w2:t1" + } + r.savePath(term) + r.selectTerm(term) + r.chat() + r.write(lPrompt, lNote1, lSaid2) + if box := apptest.Box(t, r.tt, "b-631 working"); box.W != dotSize { + t.Errorf("an unknown program shows a %v box, want the %v dot", box.W, dotSize) + } +} diff --git a/internal/app/chatview/sheet_test.go b/internal/app/chatview/sheet_test.go index b1bc12d5..21141934 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -10,7 +10,8 @@ package chatview // older board (frozen), the live board and a queue, and ChatBlocksBlocked a queue with a blocked // step (D267). ChatViewComposer shows the composer card in each state (D271), ChatViewImages the // images of prompts and the composer's image chips (D269), CallLines an open work group with a -// call line of each kind and a running call (D274). +// call line of each kind and a running call (D274), ChatWorkerMarks the mark of each model family +// in each state (D285). import ( "strconv" @@ -20,6 +21,7 @@ import ( "github.com/egoist/mygo/ui" + "github.com/contursofran/contr/internal/agents" "github.com/contursofran/contr/internal/app/apptest" "github.com/contursofran/contr/internal/app/theme" "github.com/contursofran/contr/internal/github" @@ -69,6 +71,7 @@ func TestSpecSheet(t *testing.T) { open := true detail.s.ch.calls[id] = &open } + marks := sheetMarks(t) apptest.SpecSheet(t, []apptest.SheetPart{ {Name: "ChatView", W: sheetW, H: sheetH, View: working.view}, {Name: "ChatViewWide", W: 1500, H: 420, View: wide.view}, @@ -84,7 +87,27 @@ func TestSpecSheet(t *testing.T) { {Name: "CallLines", W: sheetW, H: 420, View: calls.view}, {Name: "CallLinesBrands", W: sheetW, H: 420, View: brandsView.view}, {Name: "CallDetail", W: sheetW, H: 360, View: detail.view}, - }, []string{"ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallLinesBrands", "CallDetail"}) + {Name: "ChatWorkerMarks", W: sheetW, H: 600, View: marks.view}, + }, []string{"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, +// DeepSeek) in each state (working, idle, blocked), and one of another program (D285). +func sheetMarks(t *testing.T) sheetView { + lines := []string{lPrompt} + workers := map[string]agents.Worker{} + n := 0 + for _, fam := range []struct{ name, model, command string }{{"claude", "opus", "claude"}, {"codex", "gpt-6.1", "codex"}, {"deepseek", "deepseek-v4.1-flash", "opencode"}, {"shell", "", "zsh"}} { + for _, status := range []string{"working", "idle", "blocked"} { + n++ + addr, name := "w"+strconv.Itoa(n)+":t1", fam.name+"-"+status + workers[addr] = agents.Worker{Name: name, Address: addr, Status: status, Model: fam.model, Command: fam.command} + lines = append(lines, `{"type":"user","uuid":"m`+strconv.Itoa(n)+`","message":{"content":"`+name+`: `+status+`, PR #633"}}`) + } + } + v := sheetChat(t, "idle", append(lines, said("s1", "Done."))...) + v.s.ch.Worker = func(addr string) (agents.Worker, bool) { w, ok := workers[addr]; return w, ok } + return v } // contentAnswer has each kind of content: headings, lists, a quote, the five remarks, a table diff --git a/internal/app/chatview/view.go b/internal/app/chatview/view.go index 3f0aa773..16f577d4 100644 --- a/internal/app/chatview/view.go +++ b/internal/app/chatview/view.go @@ -295,25 +295,37 @@ func (ch *Chat) groupRow(c *ui.Context, p ui.CollapsibleParts, r Row, open *bool // dot is the state of the worker at addr: blocked in ink-100, working in ink-88, else ink-30. A // blocked worker gets no red: the team row's "N needs you" is the chat's one red for it (D259). +// A worker of a known model family shows its mark in that ink in place of the dot (D285). func (ch *Chat) dot(c *ui.Context, addr string) { pal := theme.Current() color, state := pal.Ink30, "idle" if ch.Worker != nil { if w, ok := ch.Worker(addr); ok { - state = w.Status - switch w.Status { - case "blocked": - color = pal.Ink100 - case "working": - color = pal.Ink88 + state, color = w.Status, WorkerInk(pal, w.Status) + label := w.DisplayName() + " " + state + if m := familyMark(ch.familyOf(w)); m != nil { + ch.mark(c, m, label, color, w.Status == "working") + return } - ch.dotBox(c, w.DisplayName()+" "+state, color) + ch.dotBox(c, label, color) return } } ch.dotBox(c, addr+" "+state, color) } +// WorkerInk is the ink of a worker's dot or mark in status: blocked ink-100, working ink-88, else +// ink-30 (D259, D285). +func WorkerInk(pal *theme.Palette, status string) ui.Color { + switch status { + case "blocked": + return pal.Ink100 + case "working": + return pal.Ink88 + } + return pal.Ink30 +} + func (ch *Chat) dotBox(c *ui.Context, label string, color ui.Color) { ui.Box(c).Label(label).Size(dotSize, dotSize).Shrink(0).Radius(dotSize / 2).Background(color) } From 4c29504d76fcd734c588b838d9884150314d7435 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 15:34:56 -0300 Subject: [PATCH 11/14] Chat: Octicons for PR and issue chips and board PRs (#714, D286) PR chips and the board's PR cell show the pull request Octicon of their state (open, draft, merged, closed); issue chips show the issue Octicon (open, closed). The icons are drawn in ink-48. The read of a number adds isDraft. NOTICE credits Octicons (MIT). --- NOTICE | 27 ++++++ assets/icons.go | 2 +- assets/icons/github/git-merge.svg | 1 + .../icons/github/git-pull-request-closed.svg | 1 + .../icons/github/git-pull-request-draft.svg | 1 + assets/icons/github/git-pull-request.svg | 1 + assets/icons/github/issue-closed.svg | 1 + assets/icons/github/issue-opened.svg | 1 + docs/DECISIONS.md | 1 + docs/spec/ChatView.md | 15 ++-- internal/app/chatview/blocks.go | 18 ++-- internal/app/chatview/blocks_test.go | 67 +++++++++------ internal/app/chatview/chips.go | 77 +++++++++++++++-- internal/app/chatview/chips_test.go | 84 +++++++++++++++---- internal/app/chatview/sheet_test.go | 15 ++-- internal/app/icons/github.go | 75 +++++++++++++++++ internal/app/icons/github_test.go | 46 ++++++++++ internal/app/icons/icons.go | 1 + internal/github/pr.go | 6 +- internal/github/pr_test.go | 6 +- 20 files changed, 375 insertions(+), 71 deletions(-) create mode 100644 assets/icons/github/git-merge.svg create mode 100644 assets/icons/github/git-pull-request-closed.svg create mode 100644 assets/icons/github/git-pull-request-draft.svg create mode 100644 assets/icons/github/git-pull-request.svg create mode 100644 assets/icons/github/issue-closed.svg create mode 100644 assets/icons/github/issue-opened.svg create mode 100644 internal/app/icons/github.go create mode 100644 internal/app/icons/github_test.go diff --git a/NOTICE b/NOTICE index a249abb3..fcb60fd1 100644 --- a/NOTICE +++ b/NOTICE @@ -26,6 +26,33 @@ The brand marks in assets/icons/brands/ are icons of Simple Icons CC0 1.0 Universal; they are used without changes. The marks belong to their owners; contr uses them only to show which service a tool call goes to. +The pull request and issue icons in assets/icons/github/ are Octicons +(https://github.com/primer/octicons), v19.21.1, licensed under the MIT +License; they are used without changes. contr draws them in its own ink +colors to show the state of a pull request or an issue. + + MIT License + + Copyright (c) 2025 GitHub Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + The Claude Code mascot (assets/icons/claude-mascot.svg) is the "Claude Code" icon of LobeHub icons (https://github.com/lobehub/lobe-icons), licensed under the MIT License; changes: the viewBox is cropped to the shape, and it has one diff --git a/assets/icons.go b/assets/icons.go index 874543f5..5339714d 100644 --- a/assets/icons.go +++ b/assets/icons.go @@ -9,7 +9,7 @@ import "embed" // `icons/.svg`, and the brand marks of servers (Simple Icons, CC0) as // `icons/brands/.svg`. // -//go:embed icons/*.svg icons/brands/*.svg +//go:embed icons/*.svg icons/brands/*.svg icons/github/*.svg var Icons embed.FS // Fonts holds the faces of JetBrains Mono and Inter as `fonts/.ttf`. diff --git a/assets/icons/github/git-merge.svg b/assets/icons/github/git-merge.svg new file mode 100644 index 00000000..adeaca22 --- /dev/null +++ b/assets/icons/github/git-merge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/github/git-pull-request-closed.svg b/assets/icons/github/git-pull-request-closed.svg new file mode 100644 index 00000000..18db3978 --- /dev/null +++ b/assets/icons/github/git-pull-request-closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/github/git-pull-request-draft.svg b/assets/icons/github/git-pull-request-draft.svg new file mode 100644 index 00000000..efbec9a6 --- /dev/null +++ b/assets/icons/github/git-pull-request-draft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/github/git-pull-request.svg b/assets/icons/github/git-pull-request.svg new file mode 100644 index 00000000..a1cb1ef9 --- /dev/null +++ b/assets/icons/github/git-pull-request.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/github/issue-closed.svg b/assets/icons/github/issue-closed.svg new file mode 100644 index 00000000..befad602 --- /dev/null +++ b/assets/icons/github/issue-closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/github/issue-opened.svg b/assets/icons/github/issue-opened.svg new file mode 100644 index 00000000..914e0869 --- /dev/null +++ b/assets/icons/github/issue-opened.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 69791718..5fca8fb8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -257,3 +257,4 @@ replaces it. | D280 | 2026-11-11 | The first launch opens Settings → Setup (#713, S2). The setup checks of D278 run once at start, off the UI thread, after the window shows, so the D146 dot shows without opening Settings; then only when Settings opens, on Recheck and on activation while the tab shows; no timer. When the start pass finds a required check not ready (the hc core not counted: it can still be connecting) and `setupSeen` in the settings file is false, Settings opens on the Setup tab once and `setupSeen` is stored. It never opens by itself again. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing without a search. | | 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". | +| 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 2023a38d..213e6097 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -266,14 +266,15 @@ does not know stays plain text. | Chip | Recognized | Known when | Dot | Click | |---|---|---|---|---| -| PR | "#667" | gh says it is a pull request of the session's repository (read by number), or it is the PR of a worker's branch (D104, D145) | open `content-info`, checks running `content-running`, passed `content-done`, failed `content-failed`, merged `content-done`, closed `content-info`. A failed check wins over a pending one, a pending one over a passed one. | opens the PR as the agent rows do: in the browser (the PR pane is hidden, D147) | -| Issue | "#649" | gh says it is an issue | none | opens the issue in the browser | +| PR | "#667" | gh says it is a pull request of the session's repository (read by number), or it is the PR of a worker's branch (D104, D145) | GitHub's pull request Octicon of its state, 0.9 of the text size, `ink-48`: open, draft, merged, closed (D286). An open PR with checks adds a 6px dot after it: running `content-running`, passed `content-done`, failed `content-failed`. A failed check wins over a pending one, a pending one over a passed one. | opens the PR as the agent rows do: in the browser (the PR pane is hidden, D147) | +| Issue | "#649" | gh says it is an issue | GitHub's issue Octicon, open or closed, 0.9 of the text size, `ink-48` (D286) | opens the issue in the browser | | Worker | a word that is the name of a worker contr lists ("b-649"); a live one wins | always | needs you `content-failed`, working `content-running`, done (ended) `content-done`, waiting (idle) `content-waiting` | selects the worker in the sidebar | | File | a word with a slash or an extension, with an optional ":42" | it is a file in the session's folder | none: the `file` line icon, 0.9 of the text size, `ink-48` | opens the file in the Review view, as the files panel does (the line is not used) | - The dot is 6px, round, `space-4` before the text, its center near the - middle of the lowercase letters. The tip of a chip says what it is: - "PR #667 · checks passed", "Worker b-649 · needs you", "Open + middle of the lowercase letters. The Octicons take the ink of the + text around them, never GitHub's colors (D119, D261, D286). The tip of a + chip says what it is: "PR #667 · checks passed", "Draft PR #661", "Worker b-649 · needs you", "Open internal/app/x.go". - A number is read only while its text is on screen and only once: the answer holds for 5 minutes, 1 minute for a PR with checks running, and @@ -283,8 +284,8 @@ does not know stays plain text. - A chip shows the state of now: it follows the agent state and the answers of the reads. Nothing polls and no timer runs (D55); a text at rest asks for no frame. -- Not yet: the quiet fill behind a chip, the mono text, the PR and issue - icons and the "merged" word of the mockup (K2, KC1). The chip is a link +- Not yet: the quiet fill behind a chip, the mono text and the "merged" + word of the mockup (K2, KC1). The chip is a link of the markdown view, which styles all its links alike. ## Blocks @@ -293,7 +294,7 @@ A coordinator writes a status board or a merge queue as a fenced block (D267). T | Block | Syntax | Shows | |---|---|---| -| Status board | info string `contr-status`; a line ` \| # \| ` | six columns: Worker, Issue, Builds, State, PR, Checks; heads mono `meta`, `ink-48`; no box, a `hairline` above and below the grid, `space-16` between columns; the worker in `body`, `ink-88`, after its 6px dot (the chip's worker colors); the issue mono `meta` `ink-64`; what it builds `body` `ink-88`, one line with "…"; the state in mono `meta`, `ink-64` (grey: only the dots have content colors), "needs you" in `blocked-text`; the PR as a mono 12px number with its dot on `hover`, radius 5, a click opens it as a chip does; the checks "2 of 3" (passed of passed, failed and pending) mono `meta` `ink-64`; "—" for none | +| Status board | info string `contr-status`; a line ` \| # \| ` | six columns: Worker, Issue, Builds, State, PR, Checks; heads mono `meta`, `ink-48`; no box, a `hairline` above and below the grid, `space-16` between columns; the worker in `body`, `ink-88`, after its 6px dot (the chip's worker colors); the issue mono `meta` `ink-64`; what it builds `body` `ink-88`, one line with "…"; the state in mono `meta`, `ink-64` (grey: only the dots have content colors), "needs you" in `blocked-text`; the PR as its Octicon (12px, `ink-48`, D286), the dot of its checks when it has some, and a mono 12px number on `hover`, radius 5, a click opens it as a chip does; the checks "2 of 3" (passed of passed, failed and pending) mono `meta` `ink-64`; "—" for none | | Merge queue | info string `contr-queue`; a line `#