diff --git a/cmd/codeaf/telemetry.go b/cmd/codeaf/telemetry.go index 8b76b0477..7a24618e2 100644 --- a/cmd/codeaf/telemetry.go +++ b/cmd/codeaf/telemetry.go @@ -28,6 +28,10 @@ func runTelemetry(args []string) error { return runTelemetryStatus(nil) } switch args[0] { + case "-h", "-help", "--help": + // ASKING IS NEVER A FAILURE. This door took the flag for a sixth + // verb and left with 1, the one door in the binary that did. + return commandHelp("telemetry") case "status": return runTelemetryStatus(args[1:]) case "info": @@ -42,11 +46,11 @@ func runTelemetry(args []string) error { } // telemetryFlags holds the one flag every verb accepts so a --help reader and -// the tests share one parser. +// the tests share one parser. It is the binary's own seam, named for the whole +// line a person typed, so `codeaf telemetry status --help` prints the usage and +// leaves with 0 like every other verb instead of `flag: help requested` and 1. func telemetryFlags(name string) *flag.FlagSet { - flags := flag.NewFlagSet(name, flag.ContinueOnError) - flags.SetOutput(os.Stderr) - return flags + return commandFlags("telemetry " + name) } // runTelemetryStatus prints the pipe's whole answer: on or off, why it is off, @@ -55,7 +59,7 @@ func telemetryFlags(name string) *flag.FlagSet { // recognise, far too little to be a person. func runTelemetryStatus(args []string) error { flags := telemetryFlags("status") - if err := flags.Parse(args); err != nil { + if err := parseCommandFlags(flags, args); err != nil { return err } // The config answer goes through the package's single door so the row @@ -110,7 +114,7 @@ func telemetryInstallPrefix() string { // here, each under a line naming where it goes or why it does not. func runTelemetryInfo(args []string) error { flags := telemetryFlags("info") - if err := flags.Parse(args); err != nil { + if err := parseCommandFlags(flags, args); err != nil { return err } profileDir := config.ProfileDir() @@ -127,7 +131,7 @@ func runTelemetryInfo(args []string) error { // it, not piping it; a pipe reads indented JSON just as well. func runTelemetryShow(args []string) error { flags := telemetryFlags("show") - if err := flags.Parse(args); err != nil { + if err := parseCommandFlags(flags, args); err != nil { return err } profileDir := config.ProfileDir() @@ -371,7 +375,7 @@ func poolRowsWaiting(poolDir string) []json.RawMessage { // nobody could audit. func runTelemetrySet(word string, args []string) error { flags := telemetryFlags(word) - if err := flags.Parse(args); err != nil { + if err := parseCommandFlags(flags, args); err != nil { return err } profileDir := config.ProfileDir() diff --git a/cmd/codeaf/usage_test.go b/cmd/codeaf/usage_test.go index f61a6f791..2fb1f8a9a 100644 --- a/cmd/codeaf/usage_test.go +++ b/cmd/codeaf/usage_test.go @@ -3,6 +3,8 @@ package main import ( "bytes" "errors" + "os" + "path/filepath" "strconv" "strings" "testing" @@ -67,6 +69,15 @@ func TestAskingForHelpIsNotAFailure(t *testing.T) { {"plan run", func(args []string) error { return runGraph("plan run", args) }}, {"services", runServices}, {"models", runModels}, + // The telemetry group and each of its verbs. The group took `--help` + // for a sixth verb and every verb answered `flag: help requested`, + // all with 1, while the manual says help on any verb exits 0. + {"telemetry", runTelemetry}, + {"telemetry status", telemetryVerb("status")}, + {"telemetry info", telemetryVerb("info")}, + {"telemetry show", telemetryVerb("show")}, + {"telemetry on", telemetryVerb("on")}, + {"telemetry off", telemetryVerb("off")}, // The two old top-level spellings. They still open, and asking one for // help says NOTHING on stderr: `--help` runs nothing, so there is no run // for the rename notice to be about, and a Makefile that probes the @@ -112,6 +123,29 @@ func TestAskingForHelpIsNotAFailure(t *testing.T) { } } +// telemetryVerb is `codeaf telemetry ` as the dispatch reaches it. +func telemetryVerb(verb string) func([]string) error { + return func(args []string) error { return runTelemetry(append([]string{verb}, args...)) } +} + +// ASKING `off` FOR HELP TURNS NOTHING OFF. `--help` runs nothing, so a person +// reading what `codeaf telemetry off` does has not yet chosen to do it. +func TestAskingTelemetryOffForHelpChangesNothing(t *testing.T) { + home := t.TempDir() + t.Setenv("CODEAF_HOME", home) + before, _ := os.ReadFile(filepath.Join(config.ProfileDir(), "config.json")) + captureUsage(t) + for _, verb := range []string{"off", "on"} { + if code := exitCodeOf(runTelemetry([]string{verb, "--help"})); code != 0 { + t.Fatalf("`codeaf telemetry %s --help` left with %d, want 0", verb, code) + } + } + after, _ := os.ReadFile(filepath.Join(config.ProfileDir(), "config.json")) + if string(before) != string(after) { + t.Fatalf("asking for help rewrote the setting:\nbefore %q\nafter %q", before, after) + } +} + // A REAL FLAG ERROR IS STILL AN ERROR, and it is one fact said once. // // It used to be said twice: the flag package printed `flag provided but not diff --git a/docs/changes/unreleased/1533-happy-path-fixes.md b/docs/changes/unreleased/1533-happy-path-fixes.md new file mode 100644 index 000000000..798227907 --- /dev/null +++ b/docs/changes/unreleased/1533-happy-path-fixes.md @@ -0,0 +1,10 @@ +--- +kind: fixed +title: telemetry --help exits 0, the manual names six --help groups, a wall tile shows its first age +pr: 1533 +surface: [chat, docs] +invalidates: + - "On c34a3a76d, `codeaf telemetry --help` answered `telemetry takes one of: status, info, show, on, off` and exited 1, and each telemetry verb's `--help` printed `flag: help requested` and exited 1. The group and every verb now print their usage and exit 0, and `off --help` changes nothing." + - "The manual said `codeaf --help` prints five groups. Since senior-dev (#1488) it prints six: Talk to it, Hand it work, Hand it a whole task, Look at what happened, Housekeeping, Plan work by hand. A build that carries no program prints five." + - "On c34a3a76d, the wall drew an idle conversation's tile with a blank activity line on its first read after launch. The tile now says `updated … ago` from the transcript's last write." +--- diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 6cfa44988..d4964a96b 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -253,10 +253,13 @@ through the same code path the tool on the belt runs, so the two cannot drift: fetch on a keyed provider, and every `image` call the model and are billed like any other call. -## What codeaf --help prints — the five groups, and where the environment table went +## What codeaf --help prints — the six groups, and where the environment table went -`codeaf help`, `--help` and `-h` all print the same thing: every command under those five -headings, in that order, then five worked examples. +`codeaf help`, `--help` and `-h` all print the same thing: every command under six +headings, in this order — **Talk to it**, **Hand it work**, **Hand it a whole task**, +**Look at what happened**, **Housekeeping** and **Plan work by hand** — then five worked +examples. **Hand it a whole task** lists the programs this build carries, such as +`codeaf senior-dev`; a build that carries none prints the other five. **The environment table is not on that page**: it is `codeaf help env`, because it is a reference somebody consults and it used to be more than half of what `--help` printed. diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go index b61151c44..053aabd51 100644 --- a/internal/tui3/wall.go +++ b/internal/tui3/wall.go @@ -208,7 +208,7 @@ func (a *app) wallFrame(width, height int) []string { tiles[i].rows = a.wallMiniRows(tail, wallInnerW(tileW)) if tail != nil { tiles[i].doing = wallDoing(tail.recent, tiles[i].signal) - tiles[i].moved = tail.freshAt + tiles[i].moved = tail.moved } else { tiles[i].doing = wallDoing(nil, tiles[i].signal) } diff --git a/internal/tui3/wallactivity_test.go b/internal/tui3/wallactivity_test.go new file mode 100644 index 000000000..53a223d0c --- /dev/null +++ b/internal/tui3/wallactivity_test.go @@ -0,0 +1,39 @@ +package tui3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// A finished conversation's first wall reading uses the conversation's own +// last write, so opening the wall does not claim that old work happened now. +func TestWallFirstReadShowsConversationFileActivity(t *testing.T) { + now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) + file := filepath.Join(t.TempDir(), "transcript.jsonl") + if err := os.WriteFile(file, []byte("saved conversation\n"), 0600); err != nil { + t.Fatal(err) + } + last := now.Add(-5 * time.Minute) + if err := os.Chtimes(file, last, last); err != nil { + t.Fatal(err) + } + agent := &fakeAgent{model: "m", past: []session.DisplayEntry{{Role: "user", Text: "hello"}, {Role: "assistant", Text: "done"}}} + a := newTestApp(agent) + a.file, a.workspace, a.title = file, filepath.Dir(file), "A finished conversation" + a.width, a.height = 120, 40 + a.clock = func() time.Time { return now } + _ = a.openWall() + drive(t, a, runCmd(a.wallReadCmd(a.frontTabKey()))...) + if got := wallTileRowsFor(t, a, a.frontTabKey()); !strings.Contains(got, "updated 5m ago") { + t.Fatalf("the first idle tile did not show its last activity:\n%s", got) + } + now = now.Add(2 * time.Minute) + if got := wallTileRowsFor(t, a, a.frontTabKey()); !strings.Contains(got, "updated 7m ago") { + t.Fatalf("the idle tile did not age with the wall clock:\n%s", got) + } +} diff --git a/internal/tui3/wallcontract.go b/internal/tui3/wallcontract.go index d58116536..aea387bba 100644 --- a/internal/tui3/wallcontract.go +++ b/internal/tui3/wallcontract.go @@ -448,6 +448,9 @@ type wallTail struct { seen time.Time fresh int freshAt time.Time + // moved is the conversation's own last activity, including its file time + // when the first reading has no new lines to measure. + moved time.Time // spark is a ring of per-second activity, newest at sparkAt. spark [wallSparkLen]uint8 sparkAt time.Time diff --git a/internal/tui3/walltail.go b/internal/tui3/walltail.go index 8c13604ac..6d33f95f4 100644 --- a/internal/tui3/walltail.go +++ b/internal/tui3/walltail.go @@ -1,6 +1,7 @@ package tui3 import ( + "os" "strconv" "strings" "time" @@ -60,6 +61,9 @@ type wallReadMsg struct { // books is what the conversation's books said on the same trip // ([Agent.Usage]), which takes the same lock the transcript does. books float64 + // modified is the transcript file's last write, read beside its contents + // off the loop so the first idle tile has a real activity time. + modified time.Time } // wallTickMsg is the wall's clock. @@ -351,7 +355,12 @@ func (a *app) wallReadCmd(keys ...string) tea.Cmd { key := key cmds = append(cmds, func() tea.Msg { at := time.Now() - return wallReadMsg{key: key, entries: agent.Transcript(), at: at, live: live, books: agent.Usage().CostUSD} + entries := agent.Transcript() + var modified time.Time + if info, err := os.Stat(key); err == nil { + modified = info.ModTime() + } + return wallReadMsg{key: key, entries: entries, at: at, live: live, books: agent.Usage().CostUSD, modified: modified} }) } switch len(cmds) { @@ -390,6 +399,11 @@ func (a *app) wallTakeRead(msg wallReadMsg) { } tail.books = max(tail.books, msg.books) tail.take(msg.entries, msg.at, msg.live) + if !tail.freshAt.IsZero() { + tail.moved = tail.freshAt + } else if tail.moved.IsZero() { + tail.moved = msg.modified + } } // wallStir is a held conversation's stir, as the wall hears it: a read of that @@ -504,8 +518,8 @@ func (a *app) wallTiles(now time.Time) []wallTile { tile.lines = tail.lines tile.fresh = tail.fresh tile.freshAt = tail.freshAt - if !tail.freshAt.IsZero() { - tile.age = wallAge(now.Sub(tail.freshAt)) + if !tail.moved.IsZero() { + tile.age = wallAge(now.Sub(tail.moved)) } if live { tile.spark = tail.sparkline(now)