From 9d01cf9c5de10177ce3b01c42758c10e5bd68fc9 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 14:42:29 -0300 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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