Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
816270b
feat: Show whether a folder has been read, and when
nfebe Sep 26, 2026
ec86f00
feat: Keep tuning out of the way on the Settings screen
nfebe Sep 26, 2026
229ee3e
feat: Choose which skills a review reads, from the list
nfebe Sep 26, 2026
8dafaed
feat: Find things on a long screen, and group folders by owner
nfebe Sep 26, 2026
49e8ad7
feat: Offer the models a key can use, and say whether it works
nfebe Sep 26, 2026
fc95c8a
fix: Reach the running agent from the view's dev server
nfebe Sep 26, 2026
372e5d8
feat: Find a folder by name when adding one
nfebe Sep 26, 2026
8824b5c
ui: Answer for a skill in one word on the skills list
nfebe Sep 26, 2026
b63df24
feat: Read the skills as a table, and find anything from the keyboard
nfebe Sep 26, 2026
c114c32
fix: Narrow a graph where the graph is, not where it is drawn
nfebe Sep 26, 2026
1cb390b
refactor: Offer the one use a skill can have, and name the columns pl…
nfebe Sep 26, 2026
e32cd15
feat: Offer every use a skill can have, and stop calling automatic a use
nfebe Sep 26, 2026
06ec0d9
ui: Say it in labels
nfebe Sep 26, 2026
1a368e2
ui: Read a skill on a page that cannot change it
nfebe Sep 26, 2026
d72ec9b
fix: Offer all repositories everywhere it means something, and review…
nfebe Sep 26, 2026
f734c9a
ui: Add a skill with a name, a line and the skill
nfebe Sep 26, 2026
96e9c8f
feat: Make the overview say what to do about what it counts
nfebe Sep 26, 2026
f4d6e01
ui: Say what the panel holds, drop the all-clear, and list reviews as…
nfebe Sep 26, 2026
a0225ec
ui: Say what the panel lists, and edit a path list as a list
nfebe Sep 26, 2026
ab48136
fix: Lock the design package to the commit the view is built against
nfebe Sep 26, 2026
99f8d60
fix: Opening a review no longer narrows the list to it
nfebe Sep 27, 2026
b9b9ab8
docs(release): Prepare 1.0.0-beta.4
nfebe Sep 27, 2026
da946e9
refactor: Use GlobalSearch, the name the design package gives it
nfebe Sep 27, 2026
9103685
refactor: Answer for every use on every skill
nfebe Sep 27, 2026
1f1b84a
ui: Call the skill verdicts skill checks
nfebe Sep 27, 2026
495cf6d
ui: Call the key improvements recommended changes
nfebe Sep 27, 2026
eb42978
fix: Leave the choice to judge a review to the core
nfebe Sep 27, 2026
aa5c164
refactor: Cut comments back to their first sentence
nfebe Sep 28, 2026
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.0-beta.4] - 2026-09-27

### Added

- Ctrl or Cmd K finds a skill, a decision, a file or a repository by name from
any screen, asking the core for the code because a graph is too large to hold
on a screen
- The Overview leads with the one thing worth doing, and carries the last few
reviews and the files that change most and are depended on most
- Folders sit under the owner their name carries, say when each was last read,
and show a read as it happens
- Adding a folder searches for one by name rather than walking the tree
- Which uses a skill has is answered from the list, for every use and every
skill, including the folders a coding agent syncs and nothing here may edit
- Reading a skill and changing one are two screens
- The model is chosen from what the core can reach, and saving a key says
whether the provider accepts the pair

### Fixed

- A query for a drawing reaches the core, which narrows it, rather than being
applied to whatever had already been sent
- Opening a review no longer narrows the listing to that review's repository
- Choosing all repositories survives a screen that can only show one
- The view is built against the design package it is locked to

## [1.0.0-beta.3]

### Added
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ It never parses code. The grammars and the graph shape live in the Python core,

Starts the core on a free port and waits for it to answer. Restarts it when it dies, backing off as failures repeat. Serves its own HTTP surface on `127.0.0.1:8930`, where `/health` reports whether the core is up and how many times it has been started, and `/api/repositories` and `/api/graph` read the index.

It also serves the local view at `/`: Overview, Graph, Knowledge, Reviews, Skills, Repositories, Settings. The assets are embedded in the binary, so the view works with no network and cannot drift from the agent serving it.
It also serves the local view at `/`: Overview, Reviews, Skills, Knowledge, Graphs, Repositories, Settings. The assets are embedded in the binary, so the view works with no network and cannot drift from the agent serving it.

Ctrl or Cmd K finds a skill, a decision, a file or a repository by name from any screen. The smaller kinds are held and narrowed here; the code is narrowed by the core, because a graph is thousands of nodes.

The view is Vue, built by Vite from `ui/`: Tailwind for the design tokens, lucide for icons, force-graph in 2D and 3d-force-graph for Tree, Radial, Layered and Force.

Expand All @@ -20,14 +22,16 @@ Loopback is the default because the agent reads a working tree. The machine it r

## Running it

Building needs Go and npm: `make build` builds the view, then the binary that embeds it. `make build-go` skips the view when nothing about it changed, and `make ui-dev` runs it against an agent already running.
Building needs Go and npm: `make build` builds the view, then the binary that embeds it. `make build-go` skips the view when nothing about it changed.

To work on the view, leave an agent running and start `make ui-dev` beside it. The dev server hands `/api` and `/health` to the agent on `127.0.0.1:8930`, or wherever `SOURCEANT_AGENT_URL` points, and reloads as the sources change. A change only reaches the binary through `make build`, since what it serves is the view embedded when it was built.

```bash
make build
./sourceant-agent
```

It starts whatever `sourceant install` put on this machine, reading `~/.sourceant/config.json`: a container, or the core as a program. A container binds every interface inside and is published to loopback outside, because one binding the container's own loopback could be reached by nothing.
It starts whatever `sourceant setup` put on this machine, reading `~/.sourceant/config.json`: a container, or the core as a program. A container binds every interface inside and is published to loopback outside, because one binding the container's own loopback could be reached by nothing.

With nothing installed it looks for `sourceant` on `PATH`, which is what somebody working on the core itself already has. See [sourceant/cli](https://github.com/sourceant/cli) to install one, and [sourceant/sourceant](https://github.com/sourceant/sourceant) for the core.

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0-beta.3
1.0.0-beta.4
13 changes: 12 additions & 1 deletion internal/api/review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,22 @@ func TestAskingForAReviewAnswersWithWhereToFindIt(t *testing.T) {
if started.ID == "" {
t.Error("answered without a name, so nobody could come back for it")
}
if reader.asked.Against != "dev" || !reader.asked.UseModel {
if reader.asked.Against != "dev" || reader.asked.UseModel == nil || !*reader.asked.UseModel {
t.Errorf("asked %+v, want what was sent", reader.asked)
}
}

func TestAReviewWithNoModelFlagLeavesTheChoiceToTheCore(t *testing.T) {
reader := &stubReader{}
server := New(reader, stubSupervisor{}, "dev", "")

body(t, server, http.MethodPost, "/api/reviews", `{"repository":"acme/billing"}`)

if reader.asked.UseModel != nil {
t.Errorf("sent %v, want nothing said", *reader.asked.UseModel)
}
}

func TestOneReviewComesBackByName(t *testing.T) {
reader := &stubReader{reviewed: core.Review{
Ready: false,
Expand Down
90 changes: 90 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net"
"net/http"
"strconv"
"strings"
"time"

"github.com/sourceant/agent/internal/browse"
Expand All @@ -25,6 +26,7 @@ type Reader interface {
Healthy(ctx context.Context) bool
Repositories(ctx context.Context) ([]core.Repository, error)
Graph(ctx context.Context, repository string, opts core.GraphOptions) (core.Graph, error)
Nodes(ctx context.Context, repository string, opts core.NodeOptions) (core.NodePage, error)
Attention(ctx context.Context, repository string) (core.Attention, error)
Register(ctx context.Context, path, name string) (core.Repository, error)
Forget(ctx context.Context, path string) error
Expand All @@ -41,6 +43,9 @@ type Reader interface {
Reviewed(ctx context.Context, id string) (core.Reading, error)
Reviews(ctx context.Context, repository string) ([]core.Reading, error)
Settings(ctx context.Context) ([]core.Setting, error)
Uses(ctx context.Context) ([]core.Use, error)
Models(ctx context.Context) ([]core.Offering, error)
CheckModel(ctx context.Context, model, key, baseURL string) (core.Usable, error)
SetSetting(ctx context.Context, key string, value any) (core.Setting, error)
ResetSetting(ctx context.Context, key string) (core.Setting, error)
}
Expand Down Expand Up @@ -120,6 +125,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/settings", s.settings)
mux.HandleFunc("PUT /api/settings", s.setSetting)
mux.HandleFunc("DELETE /api/settings", s.resetSetting)
mux.HandleFunc("GET /api/nodes", s.nodes)
mux.HandleFunc("GET /api/skills/uses", s.uses)
mux.HandleFunc("GET /api/models", s.models)
mux.HandleFunc("POST /api/models/check", s.checkModel)
mux.HandleFunc("GET /api/skills", s.skills)
mux.HandleFunc("GET /api/skills/{id...}", s.skill)
mux.HandleFunc("PUT /api/skills", s.recordSkill)
Expand Down Expand Up @@ -178,6 +187,7 @@ func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
PathPrefix: r.URL.Query().Get("path_prefix"),
IncludeTests: r.URL.Query().Get("include_tests") == "true",
NodeLimit: limit,
Query: r.URL.Query().Get("q"),
})
if err != nil {
fail(w, err)
Expand All @@ -186,6 +196,32 @@ func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
write(w, http.StatusOK, graph)
}

// A page of nodes, for a screen that wants a count rather than a drawing.
func (s *Server) nodes(w http.ResponseWriter, r *http.Request) {
repository := r.URL.Query().Get("repository")
if repository == "" {
write(w, http.StatusBadRequest, problem{Error: "name a repository"})
return
}
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil {
limit = 0
}
page, err := s.reader.Nodes(r.Context(), repository, core.NodeOptions{
Labels: r.URL.Query()["labels"],
FilePath: r.URL.Query().Get("file_path"),
Limit: limit,
})
if err != nil {
fail(w, err)
return
}
if page.Nodes == nil {
page.Nodes = []core.Node{}
}
write(w, http.StatusOK, page)
}

func (s *Server) attention(w http.ResponseWriter, r *http.Request) {
repository := r.URL.Query().Get("repository")
if repository == "" {
Expand Down Expand Up @@ -429,6 +465,48 @@ func (s *Server) setSetting(w http.ResponseWriter, r *http.Request) {
write(w, http.StatusOK, setting)
}

func (s *Server) uses(w http.ResponseWriter, r *http.Request) {
offered, err := s.reader.Uses(r.Context())
if err != nil {
fail(w, err)
return
}
if offered == nil {
offered = []core.Use{}
}
write(w, http.StatusOK, offered)
}

func (s *Server) models(w http.ResponseWriter, r *http.Request) {
offered, err := s.reader.Models(r.Context())
if err != nil {
fail(w, err)
return
}
// None is an empty list, never a null, so a screen can draw it.
if offered == nil {
offered = []core.Offering{}
}
write(w, http.StatusOK, offered)
}

func (s *Server) checkModel(w http.ResponseWriter, r *http.Request) {
var body struct {
Model string `json:"model"`
APIKey string `json:"api_key"`
BaseURL string `json:"base_url"`
}
if !readBody(w, r, &body) {
return
}
answer, err := s.reader.CheckModel(r.Context(), body.Model, body.APIKey, body.BaseURL)
if err != nil {
fail(w, err)
return
}
write(w, http.StatusOK, answer)
}

func (s *Server) resetSetting(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if key == "" {
Expand All @@ -444,6 +522,18 @@ func (s *Server) resetSetting(w http.ResponseWriter, r *http.Request) {
}

func (s *Server) browse(w http.ResponseWriter, r *http.Request) {
// A name to search for answers with what matches it, anywhere under home,
// rather than with one directory's contents.
if term := r.URL.Query().Get("q"); strings.TrimSpace(term) != "" {
found, err := browse.Find(term)
if err != nil {
write(w, http.StatusNotFound, problem{Error: err.Error()})
return
}
write(w, http.StatusOK, browse.Listing{Path: browse.Home(), Entries: found})
return
}

listing, err := browse.At(r.URL.Query().Get("path"))
if err != nil {
write(w, http.StatusNotFound, problem{Error: err.Error()})
Expand Down
74 changes: 72 additions & 2 deletions internal/api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/sourceant/agent/internal/browse"
"github.com/sourceant/agent/internal/core"
)

Expand All @@ -21,6 +24,7 @@ type stubReader struct {
err error
askedFor string
askedOptions core.GraphOptions
askedLabels []string
askedEverything bool
askedUpdate bool
askedDryRun bool
Expand All @@ -33,6 +37,10 @@ type stubReader struct {
asked core.Ask
stated core.Stated
settings []core.Setting
offered []core.Offering
usable core.Usable
askedModel string
askedKey string
setKey string
setValue any
registered core.Repository
Expand Down Expand Up @@ -145,6 +153,25 @@ func (s *stubReader) Settings(context.Context) ([]core.Setting, error) {
return s.settings, s.err
}

func (s *stubReader) Nodes(_ context.Context, repository string, opts core.NodeOptions) (core.NodePage, error) {
s.askedFor = repository
s.askedLabels = opts.Labels
return core.NodePage{Total: 1204}, s.err
}

func (s *stubReader) Uses(context.Context) ([]core.Use, error) {
return []core.Use{{ID: "review", Label: "Code review"}}, s.err
}

func (s *stubReader) Models(context.Context) ([]core.Offering, error) {
return s.offered, s.err
}

func (s *stubReader) CheckModel(_ context.Context, model, key, _ string) (core.Usable, error) {
s.askedModel, s.askedKey = model, key
return s.usable, s.err
}

func (s *stubReader) SetSetting(_ context.Context, key string, value any) (core.Setting, error) {
s.setKey, s.setValue = key, value
return core.Setting{Key: key}, s.err
Expand Down Expand Up @@ -209,12 +236,17 @@ func TestGraphPassesOnWhatNarrowsADrawing(t *testing.T) {
reader := &stubReader{}
server := New(reader, stubSupervisor{}, "dev", "")

call(t, server, "/api/graph?repository=acme/billing&path_prefix=app/&include_tests=true&node_limit=200")
call(t, server, "/api/graph?repository=acme/billing&path_prefix=app/&include_tests=true&node_limit=200&q=charge")

if reader.askedFor != "acme/billing" {
t.Errorf("asked for %q, want acme/billing", reader.askedFor)
}
want := core.GraphOptions{PathPrefix: "app/", IncludeTests: true, NodeLimit: 200}
want := core.GraphOptions{
PathPrefix: "app/",
IncludeTests: true,
NodeLimit: 200,
Query: "charge",
}
if reader.askedOptions != want {
t.Errorf("asked with %+v, want %+v", reader.askedOptions, want)
}
Expand Down Expand Up @@ -265,3 +297,41 @@ func decode(t *testing.T, response *httptest.ResponseRecorder, into any) {
t.Fatalf("decoding %q: %v", response.Body.String(), err)
}
}

func TestBrowsingBySearchAnswersWithWhatMatches(t *testing.T) {
root := t.TempDir()
for _, dir := range []string{"work/billing/.git", "work/notes"} {
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
t.Fatalf("building the tree: %v", err)
}
}
t.Setenv("HOME", root)
server := New(&stubReader{up: true}, stubSupervisor{}, "1.2.3", "http://127.0.0.1:8931")

response := call(t, server, "/api/browse?q=bill")

var listing browse.Listing
decode(t, response, &listing)
if len(listing.Entries) != 1 || listing.Entries[0].Name != "billing" {
t.Fatalf("got %v, want the billing directory", listing.Entries)
}
if !listing.Entries[0].Repository {
t.Error("a working tree was not marked as one")
}
}

func TestBrowsingWithNoSearchStillListsOneDirectory(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "work"), 0o755); err != nil {
t.Fatalf("building the tree: %v", err)
}
server := New(&stubReader{up: true}, stubSupervisor{}, "1.2.3", "http://127.0.0.1:8931")

response := call(t, server, "/api/browse?path="+root)

var listing browse.Listing
decode(t, response, &listing)
if listing.Path != root || len(listing.Entries) != 1 {
t.Fatalf("got %v at %s, want the one directory", listing.Entries, listing.Path)
}
}
Loading
Loading