Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions cmd/contr/e2e_chatpaths_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 18 additions & 0 deletions cmd/contr/e2e_see_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "tool call Bash", "wait:call detail Bash"},
},
"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,
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,5 +253,6 @@ 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. |
| 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". |
18 changes: 18 additions & 0 deletions docs/spec/ChatView.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,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
Expand Down
69 changes: 69 additions & 0 deletions internal/app/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ package app
// chatview.

import (
"context"
"errors"
"log"
"os"
"path/filepath"
"strings"
"time"

"github.com/egoist/mygo/ui"

Expand All @@ -15,6 +19,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"
)

Expand All @@ -41,6 +46,69 @@ 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 {
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})
}
}
if !seen[f] { // git lists a nested repository as "dir/"
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
Expand All @@ -65,6 +133,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
}
Expand Down
81 changes: 81 additions & 0 deletions internal/app/chatpaths_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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)
}
}
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", "nest/", "new/", "new/n.go"}
if !reflect.DeepEqual(got, want) {
t.Errorf("paths = %q, want %q (tracked and untracked; not ignored, not deleted; a nested repo once)", 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)
}
}
3 changes: 3 additions & 0 deletions internal/app/chatview/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions internal/app/chatview/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading