From 7c2d5347b37c686542d1feb5c77793b9fa2daa22 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:03:36 -0400 Subject: [PATCH 01/26] chat: onboarding answers the pointer and a second enter, no telemetry notice or command, and free models only on a low account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Santosh's first-run notes (2026-09-30), the non-Teams half. - The welcome screen's starting points took every enter: the first filled the box, the second did nothing — four of five new people read that as enter not working. With words in the box, enter is the composer's. - The Models and spending screen swallowed every press; its rows now act like the key they stand for (a press on the model row opens the list, on a model takes it, on Start leaves), the wheel scrolls the open list, and enter goes on after a model is taken or a review is shown, so enter alone walks the whole form. - The six-line usage-counts notice is gone from the first conversation's screen, from --once and from task commands, and the send gate on it with it; `codeaf telemetry` (status, info, show, on, off) is removed. The disclosure is README.md and docs/TELEMETRY.md; the switches are the settings sheet's telemetry row, CODEAF_TELEMETRY, DO_NOT_TRACK and the project file, unchanged. - While the OpenRouter account reads low, the setup's chat-model list is cut to free rows, says `free only`, and a line under the field says why. Co-Authored-By: Claude Fable 5.1 --- README.md | 26 +- cmd/codeaf/chatv3.go | 3 - cmd/codeaf/chatv3_surface.go | 5 - cmd/codeaf/exec_smoke_test.go | 10 +- cmd/codeaf/main.go | 11 +- cmd/codeaf/telemetry.go | 437 ----------------- cmd/codeaf/telemetry_lifecycle.go | 98 +--- cmd/codeaf/telemetry_lifecycle_test.go | 44 +- cmd/codeaf/telemetry_notice_surface_test.go | 87 ---- cmd/codeaf/telemetry_test.go | 454 ------------------ cmd/codeaf/usage_test.go | 34 -- docs/TELEMETRY.md | 108 ++--- internal/config/settings.go | 9 +- internal/manual/chat/getting-started.md | 18 +- internal/manual/chat/models-and-cost.md | 2 +- internal/manual/chat/openrouter-credits.md | 6 + .../manual/chat/running-from-the-terminal.md | 64 ++- internal/manual/chat_test.go | 6 + internal/telemetry/doc_test.go | 11 +- internal/telemetry/events.go | 8 +- internal/telemetry/notice.go | 35 -- internal/telemetry/spool.go | 35 +- internal/telemetry/spool_test.go | 36 -- internal/telemetry/telemetry_test.go | 44 -- internal/telemetry/usage_test.go | 3 +- internal/tui3/app.go | 37 +- internal/tui3/credits.go | 10 + internal/tui3/firstrun.go | 4 + internal/tui3/onboarding.go | 252 +++++++++- internal/tui3/onboarding_test.go | 233 ++++++++- internal/tui3/settings.go | 4 +- internal/tui3/telemetrynotice_test.go | 108 ----- internal/tui3/tui3.go | 13 - internal/tui3/view.go | 6 - internal/tui3/welcome.go | 77 +-- 35 files changed, 697 insertions(+), 1641 deletions(-) delete mode 100644 cmd/codeaf/telemetry.go delete mode 100644 cmd/codeaf/telemetry_notice_surface_test.go delete mode 100644 cmd/codeaf/telemetry_test.go delete mode 100644 internal/telemetry/notice.go delete mode 100644 internal/tui3/telemetrynotice_test.go diff --git a/README.md b/README.md index 4f94030804..98cb653bd6 100644 --- a/README.md +++ b/README.md @@ -294,21 +294,21 @@ Harness, method and every table: [docs/benchmarks/performance](docs/benchmarks/p - [Guide](docs/GUIDE.md): every flag, key, slash command and exit code. - [docs/](docs/README.md): architecture, headless, remote, limits. -
-Telemetry: anonymous usage counts. CODEAF_TELEMETRY=off turns them off. +## Telemetry -```text codeaf sends anonymous usage counts to AgentField. - Sent: version, OS, mode, session counts, errors, and total tokens used. - Never: anything about you or your work. No prompts, code, file names, - paths, repo names, keys, email, IP, or machine name. - What is collected: codeaf telemetry info - Turn off: CODEAF_TELEMETRY=off -``` -Counts and buckets only, never your work. [docs/TELEMETRY.md](docs/TELEMETRY.md) -lists every field and every way to turn it off. - -
+- **Sent:** version, OS, mode (chat or task), session counts, errors, and total + tokens used — as counts and bands, under a random per-install id. +- **Never:** anything about you or your work. No prompts, code, file names, + paths, repo names, keys, email, IP address, machine name, or model names. +- **Turn off:** the `telemetry` switch in the chat's `/settings`, + `CODEAF_TELEMETRY=off`, or `DO_NOT_TRACK=1`. Any one of them stops every + stream, the Model Pool's rows included. + +This page and [docs/TELEMETRY.md](docs/TELEMETRY.md) are the whole disclosure: +the product itself prints no notice and has no telemetry command. The document +lists every field, when it is sent, and every way to turn it off, and a test +holds it to the code. Built by the [AgentField](https://github.com/Agent-Field/agentfield) team. diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 7b4ff2a8e6..00d05f2034 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -347,9 +347,6 @@ func openChatV3(name string, args []string, pickSession bool) error { chosen, cfg := launch.Model, launch.Config if text := strings.TrimSpace(*once); text != "" { - // A --once chat draws no surface, so an owed usage notice is printed - // here, ahead of the answer it would otherwise never be seen beside. - payTelemetryNoticeOnStderr() // Nobody is watching a --once run, so nobody can answer a question. The // policy's "prompt" therefore refuses the call with a result the model // can act on (internal/session's consent.go), and a person who wants diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 53d29f440b..809f6d3f61 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -84,11 +84,6 @@ func runSurface(ctx context.Context, options tui3.Options) error { wire, closeWire := v3Wire() defer closeWire() options.Output = wire - // THE USAGE NOTICE IS THE SURFACE'S TO SHOW when this chat still owes it - // (telemetry_lifecycle.go), because every door that draws a surface comes - // through here and a notice printed before the alt screen is a notice - // nobody reads until they quit. - telemetryNoticeForSurface(&options) return withSurfaceLogger(options.ProfileDir, func() error { return runSurfaceProgram(ctx, options) }) diff --git a/cmd/codeaf/exec_smoke_test.go b/cmd/codeaf/exec_smoke_test.go index 90dfddb97a..210afb5281 100644 --- a/cmd/codeaf/exec_smoke_test.go +++ b/cmd/codeaf/exec_smoke_test.go @@ -282,7 +282,7 @@ func TestExecBinaryWithoutAKeyFailsCleanly(t *testing.T) { // A stamped smoke run is a real usage-count source unless the environment // says otherwise, so the closed environment above must hold: one task run -// leaves no telemetry directory behind and the status verb reports off. +// leaves no telemetry directory behind. func TestSmokeBinaryNeverSendsUsageCounts(t *testing.T) { binary := buildCodeafStamped(t, "v0.0.0-smoke") server := fakeOpenRouter(t) @@ -297,14 +297,6 @@ func TestSmokeBinaryNeverSendsUsageCounts(t *testing.T) { if _, err := os.Stat(filepath.Join(home, "telemetry")); !os.IsNotExist(err) { t.Fatalf("a telemetry directory was created by a smoke run") } - - stdout, stderr, code := runSmoke(t, binary, env, "", "telemetry", "status") - if code != 0 { - t.Fatalf("telemetry status exited %d\nstderr:\n%s", code, stderr) - } - if !strings.Contains(stdout, "telemetry off") { - t.Fatalf("status printed %q, want it to report telemetry off", stdout) - } } // THE KEPT BRANCH AND THE VERDICT SURVIVE THE DOOR. An exec run works in the diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index d5062b57ce..2a55c3cb7e 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -371,10 +371,6 @@ func run() error { return runRebuild(os.Args[2:]) case "why": return runWhy(os.Args[2:]) - case "telemetry": - // The person's door onto the anonymous-usage pipe: what it is doing, - // exactly what would leave, and the switch. It emits nothing itself. - return runTelemetry(os.Args[2:]) case "manual": // Everything codeaf knows about itself, read straight (manual.go). It // is the same corpus the chat's manual tool reads, printed as it is @@ -516,8 +512,6 @@ Look at what happened — read-only, no key, nothing spent show today's self-spend receipts codeaf why [--db path] what one piece of work did — its turns, tools, arguments, how it ended - codeaf telemetry - the anonymous usage counts: status, info, show, off, on codeaf logs [--tail 40] [--follow] [--path] [--json] [--run id] [--call id] [--tag t] [--model m] [--node n] [--body id] every model call codeaf made — what was asked, which lane answered, what @@ -695,8 +689,9 @@ than fighting your shell. your prompts included. Off by default, and for one run at a time. CODEAF_TELEMETRY on (default). off turns the anonymous usage counts off; - ` + "`codeaf telemetry`" + ` says what they are and what would - leave, DO_NOT_TRACK=1 does the same + docs/TELEMETRY.md in the repository says what they are, + DO_NOT_TRACK=1 does the same, and so does the telemetry + switch in the chat's /settings CODEAF_TELEMETRY_ENDPOINT where the usage counts go (default https://agentfield.ai/api/oss/codeaf/telemetry); diff --git a/cmd/codeaf/telemetry.go b/cmd/codeaf/telemetry.go deleted file mode 100644 index 2d65342a78..0000000000 --- a/cmd/codeaf/telemetry.go +++ /dev/null @@ -1,437 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "flag" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/Agent-Field/codeaf/internal/config" - "github.com/Agent-Field/codeaf/internal/pool/outbox" - "github.com/Agent-Field/codeaf/internal/pool/poolcfg" - "github.com/Agent-Field/codeaf/internal/pool/record" - "github.com/Agent-Field/codeaf/internal/telemetry" -) - -// The `telemetry` command: the person's door onto the anonymous-usage pipe. -// Five verbs, one per question a person arrives with — what is it doing, what -// is collected, what is waiting to leave right now, and the two ways of -// turning it off or back on. It EMITS NOTHING ITSELF: it is a command about -// telemetry, not a session, and the wiring in main.go's execute() looks at -// os.Args to make sure of it. -func runTelemetry(args []string) error { - if len(args) == 0 { - 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": - return runTelemetryInfo(args[1:]) - case "show": - return runTelemetryShow(args[1:]) - case "on", "off": - return runTelemetrySet(args[0], args[1:]) - default: - return fmt.Errorf("telemetry takes one of: status, info, show, on, off") - } -} - -// telemetryFlags holds the one flag every verb accepts so a --help reader and -// 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 { - return commandFlags("telemetry " + name) -} - -// runTelemetryStatus prints the pipe's whole answer: on or off, why it is off, -// where events would go, whether the notice has been shown, how many events -// are waiting, and the first 12 characters of the install id hash — enough to -// recognise, far too little to be a person. -func runTelemetryStatus(args []string) error { - flags := telemetryFlags("status") - if err := parseCommandFlags(flags, args); err != nil { - return err - } - // The config answer goes through the package's single door so the row - // this reads and the events the binary spools cannot disagree about - // whether telemetry is off. - profileDir := config.ProfileDir() - configuredOff := false - if cwd, err := os.Getwd(); err == nil { - configuredOff, _ = config.TelemetryOffReason(cwd, profileDir) - } - telemetry.Configure(configuredOff) - - reason := telemetry.OffReason() - state := "on" - if reason != "" { - state = "off" - } - notice := "not shown" - if telemetry.NoticeShown() { - notice = "shown" - } - fmt.Fprintf(usageOut, "telemetry %s\n", state) - if reason != "" { - fmt.Fprintf(usageOut, " reason: %s\n", reason) - } - fmt.Fprintf(usageOut, " endpoint: %s\n", telemetry.Endpoint()) - fmt.Fprintf(usageOut, " notice: %s\n", notice) - fmt.Fprintf(usageOut, " spooled events: %d\n", len(telemetry.SpoolContents())) - fmt.Fprintf(usageOut, " install: %s…\n", telemetryInstallPrefix()) - return nil -} - -// telemetryInstallPrefix is the first 12 characters of the install id hash — -// the identity the wire sees, truncated to something a person can compare -// between machines and nothing more. -func telemetryInstallPrefix() string { - hash := telemetry.InstallIDHash() - if len(hash) > 12 { - return hash[:12] - } - return hash -} - -// runTelemetryInfo prints what is collected — the shape of every row that can -// leave, in a person's words, for both streams. The notice promises "what is -// collected: codeaf telemetry info", and two streams leave: the anonymous -// usage counts this package spools, and the Model Pool's judged seat scores, -// which wait in the pool's own outbox under the profile and go to a different -// relay under a different switch. Until 2026-09-18 the only listing covered -// the first, so a person who read it and set CODEAF_TELEMETRY=off believed -// nothing more would leave while the pool went on sending. Both are described -// here, each under a line naming where it goes or why it does not. -func runTelemetryInfo(args []string) error { - flags := telemetryFlags("info") - if err := parseCommandFlags(flags, args); err != nil { - return err - } - profileDir := config.ProfileDir() - telemetry.Configure(telemetryConfiguredOff()) - fmt.Fprintln(usageOut, infoText(profileDir, os.LookupEnv)) - return nil -} - -// runTelemetryShow prints exactly what is waiting to leave the machine — ALL -// of it, from both streams — as one JSON object a person can read and a -// script can parse: a key per destination, and under each where it goes, why -// it is not sent when it is not, and the rows waiting in the bytes the relay -// would receive. Indented by two, because the person who runs this is reading -// it, not piping it; a pipe reads indented JSON just as well. -func runTelemetryShow(args []string) error { - flags := telemetryFlags("show") - if err := parseCommandFlags(flags, args); err != nil { - return err - } - profileDir := config.ProfileDir() - telemetry.Configure(telemetryConfiguredOff()) - report := waitingReport{ - Usage: waitingStream{ - Destination: telemetry.Endpoint(), - Off: telemetry.OffReason(), - Waiting: nonNil(telemetry.SpoolContents()), - }, - } - cfg := config.ModelPoolResolved(profileDir, os.LookupEnv) - report.ModelPool = waitingStream{ - Destination: cfg.SubmitURL, - Off: modelPoolOffReason(cfg), - Waiting: nonNil(poolRowsWaiting(config.ProfilePath(profileDir, "pool"))), - } - // The encoder, not json.MarshalIndent: a waiting row is written with - // HTML escaping off, as the outbox and the spool write it, so the bytes - // printed are the bytes a relay is sent. - enc := json.NewEncoder(usageOut) - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - return enc.Encode(report) -} - -// waitingReport is `codeaf telemetry show`'s whole answer: one entry per -// destination, keyed by the stream's short name, in the order the notice -// names them. -type waitingReport struct { - Usage waitingStream `json:"usage"` - ModelPool waitingStream `json:"model_pool"` -} - -// waitingStream is one destination: where its rows go, the reason nothing is -// sent there when that is so, and the rows waiting to leave, oldest first. -type waitingStream struct { - Destination string `json:"destination"` - Off string `json:"off,omitempty"` - Waiting []json.RawMessage `json:"waiting"` -} - -// nonNil renders an empty queue as `[]`, never `null`: a person reading the -// object should see an empty list where rows would be, not an absence. -func nonNil(rows []json.RawMessage) []json.RawMessage { - if rows == nil { - return []json.RawMessage{} - } - return rows -} - -// modelPoolOffReason names why the pool sends nothing, or "" when it sends: -// `read` uses the pool and sends nothing, `off` asks no judge at all, and a -// cap that came from the telemetry off switch says so, because the person who -// set that switch is the one reading this. -func modelPoolOffReason(cfg poolcfg.Config) string { - if cfg.CanSend() { - return "" - } - reason := fmt.Sprintf("model_pool %s", cfg.Mode) - if cfg.Source.Mode == "telemetry" { - reason += " (capped by the telemetry off switch)" - } - return reason -} - -// infoPreface is the first thing `codeaf telemetry info` says, before either -// stream: the one fact a person came to check. It is true of everything the -// binary sends to AgentField — the usage counts carry only the allowlisted -// fields below, and a Model Pool row carries model slugs, a score and a day. -// The judge that produces a score reads a clipped brief and deliverable, but -// that is a model call to your own provider, like any turn, and nothing it -// read rides in the row. -const infoPreface = `codeaf does NOT collect or share your chat. No prompts, replies, code, file names, -paths, repo names, keys, email, IP or machine name leave for AgentField. Only the -fields below do, as this machine would fill them.` - -// infoText composes the two streams, in the order the notice names them: the -// usage counts first, the Model Pool second. Each sits under a heading naming -// where it goes or why it does not, then WHAT A ROW LOOKS LIKE: the fields -// with this machine's own values where they are known before a run, and one -// example row per event where they are not, spelled from the contract's own -// constants. Only what is sent is listed; the never lists live in the notice -// and docs/TELEMETRY.md, because a person reading a shape wants the shape, -// not a second disclaimer. What is waiting right now is `show`'s answer. -func infoText(profileDir string, lookup func(string) (string, bool)) string { - var out strings.Builder - out.WriteString(infoPreface) - out.WriteString("\n\n") - out.WriteString(usageCountsHeading()) - out.WriteByte('\n') - writeUsageCountFields(&out) - out.WriteByte('\n') - cfg := config.ModelPoolResolved(profileDir, lookup) - out.WriteString(modelPoolHeading(cfg)) - out.WriteByte('\n') - writeModelPoolFields(&out) - return strings.TrimRight(out.String(), "\n") -} - -// showIndent is the two spaces every line under a stream heading starts with. -const showIndent = " " - -// showKeyWidth is the column the values start in: the widest key any row -// carries is model_calls_failed, eighteen characters, and two for air. -const showKeyWidth = 20 - -// writeUsageCountFields prints the usage-count row as this machine would fill -// it — the six every-event props with their live values and the four -// envelope fields — then one example row per event, then the stop reasons. -// The bands themselves are not listed: the example rows show one of each, -// and docs/TELEMETRY.md spells the rest. -func writeUsageCountFields(out *strings.Builder) { - fmt.Fprintf(out, "%severy event, as this machine would send it now\n", showIndent) - for _, prop := range telemetry.CommonPropValues() { - writeField(out, prop.Name, prop.Value) - } - install := "sha256 of a random id, minted on the first send" - if hash, ok := telemetry.InstallIDHashIfMinted(); ok { - install = hash[:12] + "…" - } - writeField(out, "install_id_hash", install) - writeField(out, "session_id_hash", "sha256 of the run id, one per session; absent on first_run") - writeField(out, "event_id", "16 random bytes as hex, one per event") - writeField(out, "event_time", time.Now().UTC().Format(time.RFC3339)) - out.WriteByte('\n') - fmt.Fprintf(out, "%swhat each event adds, for example\n", showIndent) - for _, event := range telemetry.AllowlistedEvents() { - names := telemetry.EventPropNames(event) - if len(names) == 0 { - writeField(out, event, "nothing; sent once per install") - continue - } - writeField(out, event, exampleRow(event, names)) - } - writeField(out, "stop_reason", "one of "+strings.Join(telemetry.StopReasons(), " · ")) -} - -// exampleRow spells one event's props as key=value pairs in the doc's order, -// wrapped so a session_ended row does not run past the terminal's edge. The -// values are [telemetry.ExampleProp]'s, from the contract's constants. -func exampleRow(event string, names []string) string { - var pairs []string - for _, name := range names { - pairs = append(pairs, name+"="+telemetry.ExampleProp(event, name)) - } - const perLine = 5 - var lines []string - for len(pairs) > 0 { - n := perLine - if n > len(pairs) { - n = len(pairs) - } - lines = append(lines, strings.Join(pairs[:n], " ")) - pairs = pairs[n:] - } - continuation := "\n" + showIndent + showIndent + strings.Repeat(" ", showKeyWidth+1) - return strings.Join(lines, continuation) -} - -// writeModelPoolFields prints what one pool row looks like: an example row in -// the bytes a relay would receive, indented so it reads, then the two -// identities a batch travels under. -func writeModelPoolFields(out *strings.Builder) { - fmt.Fprintf(out, "%sone row per judged seat, after a task lands, for example\n", showIndent) - // The row is indented by two, the way `show` prints a waiting one, and - // set in under the heading; json.Indent keeps the bytes the row's own. - var row bytes.Buffer - if err := json.Indent(&row, []byte(record.ExampleRowJSON(time.Now())), showIndent+showIndent, " "); err == nil { - fmt.Fprintf(out, "%s%s%s\n", showIndent, showIndent, row.String()) - } - writeField(out, "nonce", "16 random bytes as hex, one per row, so a resend is not a double count") - writeField(out, "X-Codeaf-Install", "a header: a random per-install id, minted on the first send; not the usage counts' id") -} - -// writeField prints one field line: the key in its column and the value. -func writeField(out *strings.Builder, key, value string) { - fmt.Fprintf(out, "%s%s%-*s %s\n", showIndent, showIndent, showKeyWidth, key, value) -} - -// usageCountsHeading names where the usage counts go, or the rung of the -// opt-out ladder that keeps them here. The two streams are numbered in the -// order the notice names them, so a person can say "the second one". It reads the same ladder `telemetry -// status` reads, so the two verbs cannot disagree about whether anything is -// sent. -func usageCountsHeading() string { - if reason := telemetry.OffReason(); reason != "" { - return fmt.Sprintf("1. Usage Counts (off: %s)", reason) - } - return fmt.Sprintf("1. Usage Counts (%s)", telemetry.Endpoint()) -} - -// modelPoolHeading names where the pool rows go, or the mode that keeps them -// here: `read` uses the pool and sends nothing, `off` asks no judge at all. -func modelPoolHeading(cfg poolcfg.Config) string { - if !cfg.CanSend() { - return fmt.Sprintf("2. Model Pool (model_pool %s, nothing is sent)", cfg.Mode) - } - return fmt.Sprintf("2. Model Pool (%s)", cfg.SubmitURL) -} - -// poolRowsWaiting reads the pool outbox's pending rows the way -// telemetry.SpoolContents reads the spool: raw JSON rows, oldest first, nil -// when nothing waits. It reads the file by path and stats it first, like -// [pendingRows], because [outbox.Open] creates an absent outbox and a reading -// form must not write. -func poolRowsWaiting(poolDir string) []json.RawMessage { - path := filepath.Join(poolDir, "outbox.jsonl") - if _, err := os.Stat(path); err != nil { - return nil - } - box, err := outbox.Open(path) - if err != nil { - return nil - } - defer box.Close() - rows := box.Pending() - if len(rows) == 0 { - return nil - } - out := make([]json.RawMessage, 0, len(rows)) - for _, row := range rows { - // The outbox stores a row compacted; encoding it again here, with - // HTML escaping off as the outbox writes it, answers the same bytes - // the relay is sent. - var line bytes.Buffer - enc := json.NewEncoder(&line) - enc.SetEscapeHTML(false) - if err := enc.Encode(row); err != nil { - continue - } - out = append(out, json.RawMessage(bytes.TrimRight(line.Bytes(), "\n"))) - } - return out -} - -// runTelemetrySet writes the settings row from internal/config: `telemetry off` -// turns the pipe off for this machine, `telemetry on` turns it back on. It -// writes the PROFILE row — the person's own answer — and says one confirming -// line, because a command that changed a setting silently would be a change -// nobody could audit. -func runTelemetrySet(word string, args []string) error { - flags := telemetryFlags(word) - if err := parseCommandFlags(flags, args); err != nil { - return err - } - profileDir := config.ProfileDir() - var value bool - switch word { - case "on": - value = true - case "off": - value = false - } - if err := config.WriteTelemetry(profileDir, value); err != nil { - return err - } - if value { - // `telemetry on` is an explicit opt-in at the command line. Recording - // the notice as shown here keeps the uploader from waiting forever for a - // separate chat frame after the person has already made that choice. - telemetry.MarkNoticeShown() - fmt.Fprintln(usageOut, "telemetry on — anonymous usage counts are sent (see `codeaf telemetry info`)") - return nil - } - fmt.Fprintln(usageOut, "telemetry off — nothing is sent; the session counters still count") - return nil -} - -// telemetryMode decides, from the command line, whether this invocation is a -// session worth counting and which of the two modes it ran in. Every command -// that is not named here emits nothing and sends nothing. -func telemetryMode(args []string) (mode telemetry.Mode, resumed bool, session bool) { - // `plan run` is two args: the verb is the second word. Matching `plan` - // alone would also count `plan new`, which writes a file and runs nothing. - if len(args) >= 2 && args[0] == "plan" && args[1] == "run" { - return telemetry.ModeTask, false, true - } - if len(args) < 1 { - return telemetry.ModeChat, false, true - } - switch args[0] { - case "chat": - return telemetry.ModeChat, false, true - case "resume": - return telemetry.ModeChat, true, true - case "do", "exec", "run": - return telemetry.ModeTask, false, true - } - return "", false, false -} - -// telemetryHasJSON reports whether this invocation carries --json, which -// stdout must stay machine-clean for: the notice would be one more line in -// somebody's jq pipeline. -func telemetryHasJSON(args []string) bool { - for _, arg := range args { - if arg == "--json" || strings.HasPrefix(arg, "--json=") { - return true - } - } - return false -} diff --git a/cmd/codeaf/telemetry_lifecycle.go b/cmd/codeaf/telemetry_lifecycle.go index 1c90623aee..89a4e7e75e 100644 --- a/cmd/codeaf/telemetry_lifecycle.go +++ b/cmd/codeaf/telemetry_lifecycle.go @@ -1,8 +1,11 @@ package main // The execute() lifecycle for the anonymous usage counts: which invocation is -// a session, which mode it ran in, when the notice is printed, and how a run -// ends in the contract's stop-reason vocabulary. Everything here is driven +// a session, which mode it ran in, and how a run ends in the contract's +// stop-reason vocabulary. The disclosure — what is counted and how to turn it +// off — is README.md and docs/TELEMETRY.md in the repository; the binary +// prints nothing about it and has no command for it (both left on +// 2026-10-01, with the notice that used to gate the first send). Everything here is driven // from execute() (main.go), the one exit every command leaves through, so no // door can be missed by a counter that would then miscount the runs it was // built to count. @@ -18,11 +21,9 @@ import ( "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/telemetry" "github.com/Agent-Field/codeaf/internal/trace" - "github.com/Agent-Field/codeaf/internal/tui3" ) -// telemetryConfiguredOff is the config's one answer to the ladder, read the -// same way the `telemetry status` verb reads it. An unreadable config is the +// telemetryConfiguredOff is the config's one answer to the ladder. An unreadable config is the // ladder's default — on — never a failure the run inherits: `codeaf version` // still owes its answer on a machine with no profile, and a config error has // never been a reason to refuse one. @@ -65,13 +66,6 @@ var currentTelemetrySession telemetrySession // The mode is read from os.Args here rather than handed down from each door // because a door that had to remember it would be a door that could forget // it — and the doors differ only in the word they call themselves. -// -// THE NOTICE: printed once per install, to stderr, before the first session's -// events are ever sent — but never into a --json stdout, and never into a -// pipe: a person who cannot see stderr is a person who cannot be asked, so -// the events wait in the spool until a session that can show the notice runs. -// A task command is always shown it, because a task runs unattended and its -// person may never open a chat at all. func telemetryBegin() telemetrySession { // Wired first: a fault can arrive from any goroutine the run spawns from // here on, and the hook reads the session assigned below when it fires. @@ -84,30 +78,6 @@ func telemetryBegin() telemetrySession { } id := telemetrySessionID() currentTelemetrySession = telemetrySession{mode: mode, resumed: resumed, sessionID: id} - // A run the ladder has turned off prints no notice either: the notice is - // the sentence that asks permission to send, and a pipe that will never - // send has nobody to ask — and marking it shown would create the very - // directory this run promised not to write. - // - // A CHAT OWES IT TO THE SURFACE INSTEAD. A chat on a terminal is about to - // hand that terminal to a full-screen surface, and a notice printed here - // sat on the normal screen underneath it: read only after quitting, and - // marked seen from the moment it was printed, so the first exit sent the - // counts at the instant the notice first became visible. So a chat marks - // nothing here. It owes the notice, [runSurface] hands it to the surface, - // and the surface marks it once a frame has drawn it; a chat that never - // draws one (`--once`) prints it on the road it does take - // ([payTelemetryNoticeOnStderr]). Until the mark, [telemetry.Flush] sends - // nothing. - if telemetry.Enabled() && !telemetry.NoticeShown() && !telemetryHasJSON(args) && - (mode == telemetry.ModeTask || noticeTerminal()) { - if mode == telemetry.ModeChat { - telemetryNoticeOwed = true - } else { - telemetry.PrintNotice() - telemetry.MarkNoticeShown() - } - } // Both opening events go through SpoolSync, not the fire-and-forget Spool: // first_run must be on disk before session_started even exists, and a run's // session_started must be spooled before the process can reach the exit and @@ -270,45 +240,25 @@ func telemetryStopReason(code int) string { return telemetry.StopUnknown } -// stderrIsTerminal is whether a person can see the notice: stderr attached to -// a terminal. A redirected or piped stderr is the CI case, and CI is a rung -// of the ladder's own — but the notice's rule is narrower than that, because -// `codeaf do 2>/dev/null` in a person's own script is not CI and still must -// not spend the one line the person will never read. -// noticeTerminal is [stderrIsTerminal] as the notice asks it, a seam so a test -// can stand a terminal behind a process whose stderr is a pipe. -var noticeTerminal = stderrIsTerminal - -// telemetryNoticeOwed says this chat's notice is owed to the surface rather -// than printed: set at the start ([telemetryBegin]) and paid by the frame that -// draws it ([runSurface]). -var telemetryNoticeOwed bool - -// payTelemetryNoticeOnStderr prints an owed notice on a chat road that draws no -// surface — `--once` writes its answer to the terminal as plain lines, so the -// notice printed ahead of it is read ahead of it — and marks it seen. -func payTelemetryNoticeOnStderr() { - if !telemetryNoticeOwed { - return +// telemetryMode decides, from the command line, whether this invocation is a +// session worth counting and which of the two modes it ran in. Every command +// that is not named here emits nothing and sends nothing. +func telemetryMode(args []string) (mode telemetry.Mode, resumed bool, session bool) { + // `plan run` is two args: the verb is the second word. Matching `plan` + // alone would also count `plan new`, which writes a file and runs nothing. + if len(args) >= 2 && args[0] == "plan" && args[1] == "run" { + return telemetry.ModeTask, false, true } - telemetryNoticeOwed = false - telemetry.PrintNotice() - telemetry.MarkNoticeShown() -} - -// telemetryNoticeForSurface lays an owed notice on the surface's options: the -// exact text, and the mark the surface calls after the frame that drew it. -func telemetryNoticeForSurface(options *tui3.Options) { - if !telemetryNoticeOwed { - return + if len(args) < 1 { + return telemetry.ModeChat, false, true } - options.TelemetryNotice = telemetry.Notice - options.TelemetryNoticeShown = func() { - telemetryNoticeOwed = false - telemetry.MarkNoticeShown() + switch args[0] { + case "chat": + return telemetry.ModeChat, false, true + case "resume": + return telemetry.ModeChat, true, true + case "do", "exec", "run": + return telemetry.ModeTask, false, true } -} - -func stderrIsTerminal() bool { - return stdinIsTerminal(os.Stderr) + return "", false, false } diff --git a/cmd/codeaf/telemetry_lifecycle_test.go b/cmd/codeaf/telemetry_lifecycle_test.go index 090735c578..3809bf2e4f 100644 --- a/cmd/codeaf/telemetry_lifecycle_test.go +++ b/cmd/codeaf/telemetry_lifecycle_test.go @@ -286,24 +286,15 @@ func TestTelemetryOffWritesNothingAtAll(t *testing.T) { } } -// TestTelemetryNoticePrintsOnceAcrossTwoInvocations: the notice is shown once -// per install. The first task session marks it shown and spools first_run -// once; the second session of the same install marks nothing and spools no -// second first_run. -func TestTelemetryNoticePrintsOnceAcrossTwoInvocations(t *testing.T) { +// TestFirstRunIsSpooledOnceAcrossTwoInvocations: first_run is one event per +// install. The first task session spools it; the second session of the same +// install spools no second one. +func TestFirstRunIsSpooledOnceAcrossTwoInvocations(t *testing.T) { telemetryLifecycleHome(t) restore := telemetryArgs("do", "fix the bug") defer restore() - if telemetry.NoticeShown() { - t.Fatalf("a fresh home read as notice-already-shown") - } - // A task command shows the notice even with piped stderr, because a task - // runs unattended and its person may never open a chat. first := telemetryBegin() - if !telemetry.NoticeShown() { - t.Fatalf("the notice was not marked shown for a task session") - } telemetryEnd(first, 0) rows := telemetrySpoolRows(t) @@ -333,20 +324,19 @@ func TestTelemetryNoticePrintsOnceAcrossTwoInvocations(t *testing.T) { } } -// TestTelemetryNoticeNeverPrintsUnderJSON: a --json stdout must stay -// machine-clean, so a --json session never shows the notice; the events wait -// in the spool until a session that can show it runs. -func TestTelemetryNoticeNeverPrintsUnderJSON(t *testing.T) { - telemetryLifecycleHome(t) - restore := telemetryArgs("do", "--json", "fix the bug") - defer restore() - - if !telemetryHasJSON(os.Args[1:]) { - t.Fatalf("telemetryHasJSON missed --json") +// THE HELP NAMES THE SWITCHES AND POINTS AT THE REPOSITORY, and it no longer +// names a `codeaf telemetry` command: the command left on 2026-10-01 with the +// notice, and a help line for a command that does not exist is a help line +// that sends somebody to `there is no codeaf telemetry`. +func TestTheHelpNamesTheTelemetrySwitchesAndNoCommand(t *testing.T) { + for _, wanted := range []string{"CODEAF_TELEMETRY", "CODEAF_TELEMETRY_ENDPOINT", "DO_NOT_TRACK", "docs/TELEMETRY.md"} { + if !strings.Contains(environmentText, wanted) { + t.Errorf("the environment table should name %s", wanted) + } } - session := telemetryBegin() - telemetryEnd(session, 0) - if telemetry.NoticeShown() { - t.Fatalf("the notice was marked shown under --json") + for name, text := range map[string]string{"usage": usageText, "environment": environmentText} { + if strings.Contains(text, "codeaf telemetry") { + t.Errorf("%s text still names `codeaf telemetry`, which is not a command", name) + } } } diff --git a/cmd/codeaf/telemetry_notice_surface_test.go b/cmd/codeaf/telemetry_notice_surface_test.go deleted file mode 100644 index e21f98b0a9..0000000000 --- a/cmd/codeaf/telemetry_notice_surface_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -// The door's half of the usage notice: a chat that will draw a full-screen -// surface does not print the notice onto the normal screen the surface is about -// to cover, and does not mark it seen. It owes it to the surface, and the notice -// is marked seen by the surface's own report that a frame drew it. Until then -// nothing is flushed, because [telemetry.Flush] sends nothing before the mark. - -import ( - "context" - "testing" - - "github.com/Agent-Field/codeaf/internal/telemetry" - "github.com/Agent-Field/codeaf/internal/tui3" -) - -// noticeOnATerminal stands a terminal behind stderr for one test and clears -// whatever the notice's bookkeeping was left at. -func noticeOnATerminal(t *testing.T) { - t.Helper() - previous := noticeTerminal - noticeTerminal = func() bool { return true } - t.Cleanup(func() { - noticeTerminal = previous - telemetryNoticeOwed = false - }) - telemetryNoticeOwed = false -} - -// Contract 2.1 and 2.2: a chat on a terminal leaves the notice unmarked at the -// start, hands the exact notice to the surface, and marks it seen only when the -// surface reports the frame that drew it. -func TestAChatHandsTheNoticeToTheSurfaceAndMarksItOnlyWhenDrawn(t *testing.T) { - telemetryLifecycleHome(t) - noticeOnATerminal(t) - restore := telemetryArgs("chat") - defer restore() - - session := telemetryBegin() - if session.mode != telemetry.ModeChat { - t.Fatalf("chat: mode=%q", session.mode) - } - if telemetry.NoticeShown() { - t.Fatal("the chat marked the notice seen before the surface could draw it") - } - - previous := runSurfaceProgram - t.Cleanup(func() { runSurfaceProgram = previous }) - var seen tui3.Options - runSurfaceProgram = func(_ context.Context, options tui3.Options) error { - seen = options - return nil - } - if err := runSurface(context.Background(), tui3.Options{Agent: &quietAgent{}, ProfileDir: t.TempDir()}); err != nil { - t.Fatal(err) - } - if seen.TelemetryNotice != telemetry.Notice { - t.Fatalf("the surface was handed %q, want the notice byte for byte", seen.TelemetryNotice) - } - if seen.TelemetryNoticeShown == nil { - t.Fatal("the surface was handed no way to say the notice was drawn") - } - if telemetry.NoticeShown() { - t.Fatal("the notice was marked seen before the surface said a frame drew it") - } - seen.TelemetryNoticeShown() - if !telemetry.NoticeShown() { - t.Fatal("the surface said the notice was drawn and it was not marked seen") - } -} - -// Contract 2.3: a task command still prints the notice and marks it at once, -// because a task runs unattended and draws no surface. -func TestATaskStillPrintsTheNoticeAndOwesTheSurfaceNothing(t *testing.T) { - telemetryLifecycleHome(t) - noticeOnATerminal(t) - restore := telemetryArgs("do", "fix the bug") - defer restore() - - telemetryBegin() - if !telemetry.NoticeShown() { - t.Fatal("a task command did not mark the notice it printed") - } - if telemetryNoticeOwed { - t.Fatal("a task command left the notice owed to a surface it never draws") - } -} diff --git a/cmd/codeaf/telemetry_test.go b/cmd/codeaf/telemetry_test.go deleted file mode 100644 index 33a4d67ca4..0000000000 --- a/cmd/codeaf/telemetry_test.go +++ /dev/null @@ -1,454 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/Agent-Field/codeaf/internal/config" - "github.com/Agent-Field/codeaf/internal/home" - "github.com/Agent-Field/codeaf/internal/pool/outbox" - "github.com/Agent-Field/codeaf/internal/telemetry" -) - -// telemetryHome is the clean room every telemetry-verb test runs in: a -// throwaway CODEAF_HOME and a local httptest sink pinned as the endpoint, so -// nothing a test spools can ever leave the machine (the package's own spool -// tests POST to the production relay when the endpoint is unset). -// -// AND THE PROFILE IS PINNED WITH THE STATE ROOT, because `telemetry on` and -// `telemetry off` write the profile's own row through config.WriteTelemetry with -// the directory config.ProfileDir() resolves. An exported CODEAF_PROFILE_DIR — -// which the harness that runs this suite sets at a live profile — outranks -// CODEAF_HOME, so a test that moved only the state root wrote that row, and the -// spool it read back, into somebody else's profile. -func telemetryHome(t *testing.T) string { - t.Helper() - root := t.TempDir() - t.Setenv(home.EnvVar, root) - t.Setenv(config.ProfileDirEnv, "") - t.Setenv("CODEAF_TELEMETRY_ENDPOINT", "") - t.Setenv("CODEAF_TELEMETRY", "") - t.Setenv("DO_NOT_TRACK", "") - return root -} - -// telemetrySink pins the endpoint at a local server that answers 2xx and -// swallows everything. -func telemetrySink(t *testing.T) *httptest.Server { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(server.Close) - t.Setenv("CODEAF_TELEMETRY_ENDPOINT", server.URL) - return server -} - -func TestTelemetryStatusReportsOn(t *testing.T) { - telemetryHome(t) - telemetrySink(t) - usageOut = &strings.Builder{} - defer func() { usageOut = os.Stdout }() - if err := runTelemetry([]string{"status"}); err != nil { - t.Fatal(err) - } - got := usageOut.(*strings.Builder).String() - if !strings.HasPrefix(got, "telemetry ") { - t.Fatalf("status should open with the state, got:\n%s", got) - } - if !strings.Contains(got, "endpoint") { - t.Fatalf("status should name the endpoint, got:\n%s", got) - } - if !strings.Contains(got, "install") { - t.Fatalf("status should name the install prefix, got:\n%s", got) - } -} - -func TestTelemetryStatusNamesTheReasonWhenOff(t *testing.T) { - telemetryHome(t) - telemetrySink(t) - t.Setenv("CODEAF_TELEMETRY", "off") - usageOut = &strings.Builder{} - defer func() { usageOut = os.Stdout }() - if err := runTelemetry([]string{"status"}); err != nil { - t.Fatal(err) - } - got := usageOut.(*strings.Builder).String() - if !strings.Contains(got, "telemetry off") { - t.Fatalf("status should report off, got:\n%s", got) - } - if !strings.Contains(got, "CODEAF_TELEMETRY") { - t.Fatalf("off should say why, got:\n%s", got) - } -} - -// telemetryShowReport is `codeaf telemetry show`'s object as the tests read it back. -type telemetryShowReport struct { - Usage telemetryShowStream `json:"usage"` - ModelPool telemetryShowStream `json:"model_pool"` -} - -type telemetryShowStream struct { - Destination string `json:"destination"` - Off string `json:"off,omitempty"` - Waiting []json.RawMessage `json:"waiting"` -} - -// runShow runs the verb and parses its answer, failing on anything that is -// not one JSON object indented by two — the shape the verb promises. -func runTelemetryShowJSON(t *testing.T) (telemetryShowReport, string) { - t.Helper() - usageOut = &strings.Builder{} - defer func() { usageOut = os.Stdout }() - if err := runTelemetry([]string{"show"}); err != nil { - t.Fatal(err) - } - got := usageOut.(*strings.Builder).String() - var report telemetryShowReport - if err := json.Unmarshal([]byte(got), &report); err != nil { - t.Fatalf("show should print one JSON object, got %v:\n%s", err, got) - } - // Re-encoding the parsed report the way the verb encodes it — two-space - // indent, HTML escaping off, usage before model_pool — must give the same - // bytes back. - var again strings.Builder - enc := json.NewEncoder(&again) - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - if err := enc.Encode(report); err != nil { - t.Fatal(err) - } - if got != again.String() { - t.Errorf("show should be indented by two, got:\n%s\nwant:\n%s", got, again.String()) - } - return report, got -} - -func TestTelemetryShowIsOneObjectKeyedByDestination(t *testing.T) { - telemetryHome(t) - telemetrySink(t) - report, got := runTelemetryShowJSON(t) - if !strings.HasPrefix(got, "{\n \"usage\": {") { - t.Errorf("show should open on the usage stream, got:\n%s", got) - } - if !strings.Contains(got, "\n \"model_pool\": {") { - t.Errorf("show should carry the model_pool stream, got:\n%s", got) - } - if report.Usage.Destination == "" || report.ModelPool.Destination == "" { - t.Errorf("both streams should name a destination, got %+v", report) - } - // An empty queue is an empty list, never null. - if !strings.Contains(got, "\"waiting\": []") { - t.Errorf("an empty queue should print as [], got:\n%s", got) - } - if strings.Contains(got, "null") { - t.Errorf("show should never print null, got:\n%s", got) - } -} - -func TestTelemetryOffWritesTheSetting(t *testing.T) { - root := telemetryHome(t) - usageOut = &strings.Builder{} - defer func() { usageOut = os.Stdout }() - if err := runTelemetry([]string{"off"}); err != nil { - t.Fatal(err) - } - if !strings.Contains(usageOut.(*strings.Builder).String(), "off") { - t.Fatal("off should confirm with one line") - } - data, err := os.ReadFile(filepath.Join(root, "config.json")) - if err != nil { - t.Fatalf("the profile config should exist: %v", err) - } - if !strings.Contains(string(data), "telemetry") { - t.Fatalf("the config should carry the telemetry row, got:\n%s", data) - } -} - -func TestTelemetryOnUndoesOff(t *testing.T) { - root := telemetryHome(t) - usageOut = &strings.Builder{} - defer func() { usageOut = os.Stdout }() - if err := runTelemetry([]string{"off"}); err != nil { - t.Fatal(err) - } - if err := runTelemetry([]string{"on"}); err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(filepath.Join(root, "config.json")) - if err != nil { - t.Fatalf("the profile config should exist: %v", err) - } - if !strings.Contains(string(data), `"telemetry": true`) { - t.Fatalf("on should write the row back as true, got:\n%s", data) - } -} - -func TestTelemetryBareDefaultsToStatus(t *testing.T) { - telemetryHome(t) - telemetrySink(t) - usageOut = &strings.Builder{} - defer func() { usageOut = os.Stdout }() - if err := runTelemetry(nil); err != nil { - t.Fatal(err) - } - got := usageOut.(*strings.Builder).String() - if !strings.HasPrefix(got, "telemetry ") { - t.Fatal("a bare `telemetry` should answer with the status") - } -} - -func TestTelemetryUnknownVerbIsRefused(t *testing.T) { - telemetryHome(t) - if err := runTelemetry([]string{"nonsense"}); err == nil { - t.Fatal("an unknown verb should be an error") - } -} - -func TestTelemetryIsInUsageAndEnvironmentText(t *testing.T) { - telemetryHome(t) - for name, text := range map[string]string{ - "usage": usageText, - "environment": environmentText, - } { - if !strings.Contains(text, "telemetry") { - t.Errorf("%s text should name the telemetry command", name) - } - } - if !strings.Contains(environmentText, "CODEAF_TELEMETRY_ENDPOINT") { - t.Error("the environment table should name CODEAF_TELEMETRY_ENDPOINT") - } - if !strings.Contains(environmentText, "DO_NOT_TRACK") { - t.Error("the environment table should name DO_NOT_TRACK") - } -} - -// seedPoolOutbox writes one judged row into the profile's pool outbox the way -// a run would — through the outbox's own open and append, closed again — so -// the show verb reads what stands on disk. -func seedPoolOutbox(t *testing.T, root, payload string) { - t.Helper() - box, err := outbox.Open(filepath.Join(root, "pool", "outbox.jsonl")) - if err != nil { - t.Fatal(err) - } - if err := box.Append([]byte(payload)); err != nil { - t.Fatal(err) - } - if err := box.Close(); err != nil { - t.Fatal(err) - } -} - -// TestTelemetryShowPrintsBothStreams is the promise behind the notice's "what -// is collected": two streams leave this binary, under different switches, and -// a show that printed only the usage-count spool let a person believe -// CODEAF_TELEMETRY=off stopped everything. Both streams print under their own -// key, and the pool row's bytes are the bytes the relay would receive. -func TestTelemetryShowPrintsBothStreams(t *testing.T) { - root := telemetryHome(t) - telemetrySink(t) - t.Setenv("CODEAF_MODEL_POOL", "on") - seedPoolOutbox(t, root, `{"schema":1,"metric":"role_quality","role":"worker","model":"vendor/model-x","score":81}`) - report, got := runTelemetryShowJSON(t) - if !strings.HasPrefix(report.Usage.Destination, "http") { - t.Errorf("the usage stream should name its endpoint, got %+v", report.Usage) - } - // The suite's TestMain pins the relay at an unreachable local address so - // no test can post to the real one; the destination names whatever submit - // address is in force, and the path is the relay's own. - if !strings.HasPrefix(report.ModelPool.Destination, "http") || !strings.HasSuffix(report.ModelPool.Destination, "/v1/rows") { - t.Errorf("the pool stream should name the relay, got %+v", report.ModelPool) - } - if report.ModelPool.Off != "" { - t.Errorf("a pool on should not be off, got %q", report.ModelPool.Off) - } - if len(report.Usage.Waiting) != 0 { - t.Errorf("the usage spool is empty under go test, got %s", report.Usage.Waiting) - } - // The rows print indented inside the object; compacted, they are the - // outbox's own bytes: the envelope a relay receives, the row inside it. - if len(report.ModelPool.Waiting) != 1 { - t.Fatalf("the pool's one row should wait, got:\n%s", got) - } - row := compactWaitingRow(t, report.ModelPool.Waiting[0]) - for _, want := range []string{`"payload":{`, `"model":"vendor/model-x"`, `"score":81`, `"nonce":"`} { - if !strings.Contains(row, want) { - t.Errorf("the pool row should carry %s as the relay would receive it, got %s", want, row) - } - } -} - -// compactWaitingRow is one waiting row without the indent `show` prints it with, -// so a test can compare it to the bytes the outbox or spool holds. -func compactWaitingRow(t *testing.T, raw json.RawMessage) string { - t.Helper() - var out bytes.Buffer - if err := json.Compact(&out, raw); err != nil { - t.Fatalf("waiting row is not JSON: %v\n%s", err, raw) - } - return out.String() -} - -// TestTelemetryShowSaysWhenThePoolSendsNothing pins the off reason for a pool -// in `read`: the rows that wait are still printed, under a reason nothing is -// sent, so the person is not told a destination that nothing goes to. -func TestTelemetryShowSaysWhenThePoolSendsNothing(t *testing.T) { - root := telemetryHome(t) - telemetrySink(t) - t.Setenv("CODEAF_MODEL_POOL", "read") - seedPoolOutbox(t, root, `{"n":1}`) - report, _ := runTelemetryShowJSON(t) - if report.ModelPool.Off != "model_pool read" { - t.Errorf("a pool in read should say so, got %q", report.ModelPool.Off) - } - if len(report.ModelPool.Waiting) != 1 || !strings.Contains(compactWaitingRow(t, report.ModelPool.Waiting[0]), `"payload":{"n":1}`) { - t.Errorf("the waiting row should still print, got %s", report.ModelPool.Waiting) - } -} - -// TestTelemetryShowDoesNotCreateThePoolOutbox is the reading-form law: a show -// on a profile with no outbox prints `[]` for the pool and leaves no file -// behind, because [outbox.Open] creates an absent outbox and a reader must not. -func TestTelemetryShowDoesNotCreateThePoolOutbox(t *testing.T) { - root := telemetryHome(t) - telemetrySink(t) - report, _ := runTelemetryShowJSON(t) - if _, err := os.Stat(filepath.Join(root, "pool", "outbox.jsonl")); !os.IsNotExist(err) { - t.Fatalf("show must not create the pool outbox, stat: %v", err) - } - if len(report.Usage.Waiting) != 0 || len(report.ModelPool.Waiting) != 0 { - t.Fatalf("an empty machine should have nothing waiting for either stream, got %+v", report) - } -} - -// TestTelemetryInfoNamesEveryFieldOnAnEmptyMachine is the notice's "what is -// collected" read on the day a person installs: nothing has been sent yet, -// and the verb shows the shape of every row — this machine's own values where -// they are known before a run, one example row per event where they are not, -// and the pool row in the relay's bytes. It lists only what is sent: no line -// starts with "never", because a person reading a shape wants the shape and -// the notice already carries the disclaimer; and it says nothing about what -// is waiting, which is `show`'s answer. -func TestTelemetryInfoNamesEveryFieldOnAnEmptyMachine(t *testing.T) { - telemetryHome(t) - telemetrySink(t) - usageOut = &strings.Builder{} - defer func() { usageOut = os.Stdout }() - if err := runTelemetry([]string{"info"}); err != nil { - t.Fatal(err) - } - got := usageOut.(*strings.Builder).String() - // The first line is the fact a person came to check, before either stream. - if !strings.HasPrefix(got, "codeaf does NOT collect or share your chat.") { - t.Errorf("info should open on the chat-content fact, got:\n%s", got) - } - for _, want := range []string{ - "No prompts, replies, code, file names,\npaths, repo names, keys, email, IP or machine name leave for AgentField.", - "1. Usage Counts (", - "every event, as this machine would send it now", - "os " + runtime.GOOS, - "arch " + runtime.GOARCH, - "install_method unknown", - "install_id_hash sha256 of a random id, minted on the first send", - "what each event adds, for example", - "first_run nothing; sent once per install", - "session_started mode=chat resumed=false", - "usage_delta mode=chat input_tokens=10000 output_tokens=2500 total_tokens=12500", - "session_ended mode=chat duration=5-30m turns=6-20", - "cost_usd=0.1-1 stop_reason=done", - "exit_code=0", - // No blank line between the last event and the stop reasons. - "fingerprint=3fa9c1e2b7d04e85\n stop_reason one of done · error · incomplete", - "2. Model Pool (", - "one row per judged seat, after a task lands, for example", - " {\n \"schema\": 1,\n \"metric\": \"role_quality\",\n \"role\": \"worker\",", - "\n \"door\": \"task\",\n \"size\": \"M\",\n \"day\": \"", - "\n }\n nonce", - "nonce 16 random bytes as hex", - "X-Codeaf-Install", - } { - if !strings.Contains(got, want) { - t.Errorf("info should print %q, got:\n%s", want, got) - } - } - for _, line := range strings.Split(got, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "never") { - t.Errorf("info lists only what is sent; got a never line: %q", line) - } - } - // The bands are not spelled out: the example rows carry one of each and - // the doc lists the rest, so a listing of every count, dollar and - // duration band is text a person does not need here. - for _, absent := range []string{"bands ", "counts 0 ·", "dollars 0 ·", "duration <1m ·", "waiting"} { - if strings.Contains(got, absent) { - t.Errorf("info should not print %q, got:\n%s", absent, got) - } - } - for _, name := range telemetry.CommonPropNames() { - if !strings.Contains(got, name) { - t.Errorf("info should name the every-event prop %q", name) - } - } - if _, err := os.Stat(filepath.Join(home.Dir(), "telemetry", "install_id")); !os.IsNotExist(err) { - t.Fatalf("info must not mint an install id, stat: %v", err) - } -} - -// TestTelemetryOffQuietsThePoolFromTheEnvironment is the notice's promise read -// end to end: with CODEAF_TELEMETRY=off and the pool explicitly on, the show -// verb reports the pool sending nothing, from the telemetry switch. -func TestTelemetryOffQuietsThePoolFromTheEnvironment(t *testing.T) { - telemetryHome(t) - telemetrySink(t) - t.Setenv("CODEAF_TELEMETRY", "off") - t.Setenv("CODEAF_MODEL_POOL", "on") - report, _ := runTelemetryShowJSON(t) - if report.ModelPool.Off != "model_pool read (capped by the telemetry off switch)" { - t.Errorf("CODEAF_TELEMETRY=off should quiet the pool and say why, got %q", report.ModelPool.Off) - } - if report.Usage.Off != "CODEAF_TELEMETRY=off" { - t.Errorf("the counts should name the same rung, got %q", report.Usage.Off) - } -} - -// TestTelemetryOffCommandQuietsThePool is the profile rung: `codeaf telemetry -// off` writes a row no environment carries, and the pool's resolved config -// reads it off the disk and sends nothing, whatever the pool setting says. -func TestTelemetryOffCommandQuietsThePool(t *testing.T) { - root := telemetryHome(t) - usageOut = &strings.Builder{} - defer func() { usageOut = os.Stdout }() - poolOn := func(name string) (string, bool) { - if name == "CODEAF_MODEL_POOL" { - return "on", true - } - return "", false - } - if cfg := config.ModelPoolResolved(root, poolOn); !cfg.CanSend() { - t.Fatalf("before the command the pool should send, got mode %v from %q", cfg.Mode, cfg.Source.Mode) - } - if err := runTelemetry([]string{"off"}); err != nil { - t.Fatal(err) - } - cfg := config.ModelPoolResolved(root, poolOn) - if cfg.CanSend() || cfg.Source.Mode != "telemetry" || !cfg.CanRead() { - t.Fatalf("after `telemetry off` the pool should read and not send, got mode %v from %q", cfg.Mode, cfg.Source.Mode) - } - if err := runTelemetry([]string{"on"}); err != nil { - t.Fatal(err) - } - if !telemetry.NoticeShown() { - t.Fatal("`telemetry on` is explicit consent and should open the send gate") - } - if cfg := config.ModelPoolResolved(root, poolOn); !cfg.CanSend() { - t.Fatalf("`telemetry on` should hand the pool back, got mode %v from %q", cfg.Mode, cfg.Source.Mode) - } -} diff --git a/cmd/codeaf/usage_test.go b/cmd/codeaf/usage_test.go index 2fb1f8a9a8..f61a6f7919 100644 --- a/cmd/codeaf/usage_test.go +++ b/cmd/codeaf/usage_test.go @@ -3,8 +3,6 @@ package main import ( "bytes" "errors" - "os" - "path/filepath" "strconv" "strings" "testing" @@ -69,15 +67,6 @@ 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 @@ -123,29 +112,6 @@ 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/TELEMETRY.md b/docs/TELEMETRY.md index 8762a2e5b9..bc7bf70433 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -4,33 +4,33 @@ codeaf counts how it is used — how often, in which modes, on which platforms so the parts people rely on get the work. The counts are anonymous: nothing about you or your work ever leaves this machine. -## The notice - -Before the first session's events are sent, codeaf shows this once: - -``` -codeaf sends anonymous usage counts to AgentField. - Sent: version, OS, mode, session counts, errors, and total tokens used. - Never: anything about you or your work. No prompts, code, file names, - paths, repo names, keys, email, IP, or machine name. - What is collected: codeaf telemetry info - Turn off: CODEAF_TELEMETRY=off -``` - -The installer prints nothing about telemetry; the notice above arrives with the -first session, before anything is sent. A chat shows it on the first -conversation's screen, dim, under the starting points, and it counts as shown -only once a frame has drawn it: a window too short to hold all six lines, or the -first-run setup standing in front, leaves it owed for the next launch. A task -command (`do`, `run`, `plan run`) and `chat --once` draw no screen, so they print -it to stderr before they start. Until it has been shown, nothing is sent. +## The disclosure + +This page, and the *Telemetry* section of the repository's README, are the whole +of it. In one paragraph: + +> codeaf sends anonymous usage counts to AgentField. Sent: version, OS, mode, +> session counts, errors, and total tokens used, as counts and bands. Never: +> anything about you or your work — no prompts, code, file names, paths, repo +> names, keys, email, IP, machine name, or model names. Turn off: the `telemetry` +> switch in `/settings`, `CODEAF_TELEMETRY=off`, or `DO_NOT_TRACK=1`. + +**The product itself says nothing about it.** Until 2026-10-01 codeaf printed a +six-line notice once per install — on the first conversation's screen, or to +stderr ahead of a task — and sent nothing until that notice had been shown; it +also carried a `codeaf telemetry` command (`status`, `info`, `show`, `on`, `off`) +that printed the fields and the waiting rows. Both were removed: new people read +the notice as something to deal with on a screen that is supposed to be three +sentences long, and a disclosure belongs in the repository, where it can be read +before anything is installed. The installer prints nothing about telemetry +either. The counts are sent from the first session on, under the switches below. ## What is sent Exactly five events. Each carries the every-event properties; four of them add more. Values are counts, bands, or words from fixed lists. The table's names come from the same allowlist the code is held to and its words -from the table `codeaf telemetry info` prints, and a test fails the build if +from the same table the code describes each field with, and a test fails the build if any of the three drift apart. | Event | Property | What it is | @@ -86,10 +86,10 @@ the marshalled output. Events wait in ~/.codeaf/telemetry/spool.jsonl until they are sent: at most 50 per request, every 30 seconds while a session is open and once more when it -ends. Nothing older than 7 days is sent, at most 1000 lines are kept, and -nothing is sent before the notice has been shown. An event whose version is -unknown is dropped at send time and never leaves the machine. `codeaf telemetry -show` prints exactly what has not left yet, as JSON. +ends. Nothing older than 7 days is sent and at most 1000 lines are kept. An +event whose version is unknown is dropped at send time and never leaves the +machine. The spool is plain JSON lines, so what has not left yet can be read +with any editor. ## Turning it off @@ -100,9 +100,9 @@ Model Pool from sending. They are checked in this order: 2. `DO_NOT_TRACK=1` — also `true`, the ecosystem's own word for it. 3. `telemetry = off` in the project's settings file, `.codeaf/config.json`. A project may only turn the counts off, never on. -4. `codeaf telemetry off`, which writes the profile setting; `codeaf telemetry - on` is the way back and records that explicit opt-in as the notice being - shown. +4. the `telemetry` switch on the *display* tab of the chat's `/settings`, which + writes `telemetry = off` to the profile's `config.json`; the same switch is + the way back. 5. an empty `CODEAF_TELEMETRY_ENDPOINT`. A build that cannot name its own source — dirty or unstamped — never reports, @@ -119,49 +119,15 @@ seat, the judge's slug, the seat (the worker, checker or planner, spelled on the the door the run came in by (task, do, exec or run), the crew size and the UTC day, under a random per-install nonce in an `X-Codeaf-Install` header. No prompt, code, path or name rides in a row. **Every way of turning the counts off turns this stream off too** — `CODEAF_TELEMETRY=off`, -`DO_NOT_TRACK=1`, the project file, `codeaf telemetry off` — by capping the -pool at `read`: the index is still read and the judge still scores into the -install's own sheet, but nothing is sent, and `codeaf pool status` says `mode -read · telemetry`. That cap wins over an explicit `model_pool = on`, because -the notice's "Turn off" line carries no exception. The pool's own switch, -`model_pool` in settings or `CODEAF_MODEL_POOL`, adds `off` (ask no judge at -all). `codeaf telemetry info` describes both streams and `codeaf telemetry -show` prints the rows waiting to leave from both, so "what is collected" is -answered for everything the binary sends. - -## The command - -`codeaf telemetry` reads the counts and never sends anything of its own. - -- `codeaf telemetry status` says whether the counts are on, and why not when - they are off. -- `codeaf telemetry info` opens on the fact a person came to check — `codeaf - does NOT collect or share your chat`, with the never list — then prints what - is collected, from BOTH streams, shaped like the data: for the usage counts, - every every-event field with the value - this machine would send now, one example row per event (`mode=chat - duration=5-30m turns=6-20 …`, from the contract's own bands) and the stop - reasons a row can carry; for the Model Pool, one example row in the bytes - the relay receives and the two identities a batch travels under. Each stream - sits under a numbered heading, `1. Usage Counts` and `2. Model Pool`, naming - where it goes or why it is not sent. It lists only what is sent — the never lists are the notice's and this page's. -- `codeaf telemetry show` prints what is waiting to leave right now, as one - JSON object indented by two: a key per destination, `usage` and - `model_pool`, and under each its `destination`, an `off` reason when - nothing is sent there, and `waiting`, the rows in the bytes the relay would - receive, `[]` when none wait — which is what a fresh install shows. -- `codeaf telemetry off` and `codeaf telemetry on` write the profile setting. - -``` -telemetry on - endpoint: https://agentfield.ai/api/oss/codeaf/telemetry - notice: shown - spooled events: 3 - install: 4f2a9c1b7e08… -``` - -When the counts are off, a `reason:` line follows `telemetry off` and names the -switch that turned them off. +`DO_NOT_TRACK=1`, the project file, the `telemetry` switch in `/settings` — by +capping the pool at `read`: the index is still read and the judge still scores +into the install's own sheet, but nothing is sent, and `codeaf pool status` +says `mode read · telemetry`. That cap wins over an explicit `model_pool = on`, +because the disclosure's "Turn off" line carries no exception. The pool's own +switch, `model_pool` in settings or `CODEAF_MODEL_POOL`, adds `off` (ask no +judge at all). `codeaf pool status` says how many rows wait to leave for the +pool; this page describes both streams, so "what is collected" is answered +for everything the binary sends. ## Download counts diff --git a/internal/config/settings.go b/internal/config/settings.go index bb15b080b6..8b0150b8bd 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -3404,7 +3404,7 @@ func ModelPoolAt(profileDir string) poolcfg.Config { // verbs whose tests hand one in. It is where the telemetry off switch reaches // the pool: the environment rungs (CODEAF_TELEMETRY, DO_NOT_TRACK) are read by // the resolver through lookup, and the two rungs that live on disk — the -// project file and the profile row that `codeaf telemetry off` writes — are +// project file and the profile row the settings sheet's telemetry switch writes — are // read here and applied with [poolcfg.Config.Quieted]. The rows, not the // pin: a caller that injected an environment must get the answer for THAT // environment's CODEAF_TELEMETRY, not the one the harness happens to export @@ -4616,9 +4616,10 @@ func TelemetryAtIn(cwd, profileDir string) bool { return DefaultTelemetry } -// WriteTelemetry persists the person's own answer to the telemetry row — -// the writer `codeaf telemetry on|off` goes through, so the command and the -// settings sheet write the same file the same way and cannot drift. +// WriteTelemetry persists the person's own answer to the telemetry row, the +// one writer the settings sheet's telemetry switch goes through. (`codeaf +// telemetry on|off` wrote through it too, until the command left on +// 2026-10-01.) func WriteTelemetry(profileDir string, on bool) error { return writeProfileValue(profileDir, KeyTelemetry, on) } diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index d40dfda85d..d9994404e2 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -182,7 +182,23 @@ row draws the real catalog: five rows at a time, `↑`/`↓` scroll the rest pas it. The row under the cursor shows its exact id. The model you are already on is always on that list and the cursor opens on it, even with no catalog yet, so accepting confirms rather than changes. Choosing one goes through the same settings row `/model` writes and -is kept for the next launch. +is kept for the next launch, and `enter` then goes on to the next row, as it does on the +limit — so pressing `enter` alone walks the whole form down to `Start a conversation`. + +**The rows take the mouse too.** A click on a row is the key that row would have taken: a +click on the limit focuses it to type into, a click on the chat model opens its list, a +click on one model of the list takes it, a click on the review shows it, and a click on +`Start a conversation` leaves. The wheel scrolls the open list. A click on a sentence, a +blank row or the example panel does nothing. + +**With no credit on the OpenRouter account, the list shows free models only.** When the +account's balance has read low — $0.50 or less, the same reading that puts the low-credits +warning under the message box — the list is cut to the `:free` ids and the catalog rows +priced at zero, its count line says `free only`, and a warning line under the field reads +`Your OpenRouter account is low on credits · the list shows free models only`. The model you +are already on stays on the list whatever it costs, so accepting still confirms. The balance +is read right after the key lands, so the cut usually arrives a moment after the screen does; +a top-up is read on the next launch (*OpenRouter credits and free models*). **There is no crew question**, because the crew is three seats — the worker, the planner and the checker — and codeaf picks all three for each task from what kind of work it is, so there diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 0846a4b72e..9f9ae302c2 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1001,7 +1001,7 @@ What you type, and the files and command output the chat or a task reads, go to provider serving each call: the model you talk to, and each task's worker, planner and checker on the providers you connected. Whether a provider keeps or trains on it is that provider's policy and your account's settings there. codeaf's own usage counts carry no -content (`codeaf telemetry info` says what they carry). +content (`docs/TELEMETRY.md` in the repository lists every field they carry). **Free routes may log prompts.** A provider's free pool of a model (an OpenRouter `…:free` id) may log or train on what it is sent. The crew uses free routes only when you turn diff --git a/internal/manual/chat/openrouter-credits.md b/internal/manual/chat/openrouter-credits.md index a1135e90eb..884aad2500 100644 --- a/internal/manual/chat/openrouter-credits.md +++ b/internal/manual/chat/openrouter-credits.md @@ -13,6 +13,12 @@ OpenRouter account out of credit: a seat nobody pinned is routed to a free pool, task's crew line says `free routes in use (may log prompts) · credit unavailable on openrouter` (*Route health* in *Models and cost*). +While the balance is low, the first-run setup screen's **chat model list shows free models +only** — the `:free` ids and the catalog rows priced at zero, with `free only` on its count +line and `Your OpenRouter account is low on credits · the list shows free models only` under +the field — so a person who has never run codeaf is not handed three hundred paid names to +pick the wrong one from. The model already in use stays on that list. + Free ids are defaults only. A model you chose with `/model` or the setup screen, `--model`, `CODEAF_MODEL`, a seat you pinned with `/crew pin` or a helper row you set stays chosen, and the free defaults are never written into your settings. Nothing is spent diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 405a2cc301..fc5c9d1f8f 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -105,8 +105,9 @@ model. On a terminal it ends by asking `Start codeaf in now? [Y/n]`; `e starts it there, and the first run connects a model. `--no-start` or `CODEAF_NO_START=1` skips the question, and nothing is asked when the output is not a terminal, when `CI` is set, or when the install runs in your home folder or `/` — -`cd` into a project and type `codeaf` there instead. It prints nothing about telemetry; codeaf itself shows that -notice before any count is sent. +`cd` into a project and type `codeaf` there instead. It prints nothing about telemetry, and +neither does codeaf: what the anonymous usage counts carry is written in `docs/TELEMETRY.md` +in the repository, and the `telemetry` switch in `/settings` turns them off. `/update` in the chat or `codeaf update` in a terminal replaces it in place; running the install line again works too. @@ -782,7 +783,7 @@ values: `on` reads and sends, `read` uses the pool and sends nothing, `off` does neither. It defaults to `on`. `CODEAF_MODEL_POOL` pins the same word from the shell, and on a CI machine with neither set codeaf reads but does not send. **The telemetry off switch stops the pool sending too**: -`CODEAF_TELEMETRY=off`, `DO_NOT_TRACK=1` or `codeaf telemetry off` caps the +`CODEAF_TELEMETRY=off`, `DO_NOT_TRACK=1` or the `telemetry` switch in `/settings` caps the pool at `read` — it wins over an explicit `on` — and `codeaf pool status` then says `mode read · telemetry`. @@ -808,8 +809,7 @@ Only `codeaf pool` reads that sheet; it does not pick the next crew. `codeaf pool status` reports how many outbox rows wait to be sent and whether the mode allows sending and reading. Its `pending` line can also say `dropped N` and `identity set`; -it counts waiting rows but does not list them. `codeaf telemetry show` prints the rows -themselves as JSON. Status also says whether the relay and mirror answered, what the last +it counts waiting rows but does not list them. Status also says whether the relay and mirror answered, what the last judge did, how many runs are `pending judge:`, and what happened in the `last sweep:`. `--json` prints the same answer as one object; `show` reads nothing off the network. @@ -986,7 +986,7 @@ as a command that broke. ## Which of these cost money, and which need no API key -**These read, need no key and spend nothing**: `why`, `telemetry`, `notebook`, `competence`, `services` +**These read, need no key and spend nothing**: `why`, `notebook`, `competence`, `services` (listing), `doctor`, `logs`, `cache`, `show`, `manual`, `version` and `--help`. They are safe in a shell prompt, a CI step or a bug report. @@ -1044,33 +1044,31 @@ first — `cache clean` wants the word `now` typed out, the same word `/cache cl wants in the chat, and `rebuild` wants `y` — and `--yes` skips the question on both. The other three act at once, and all three can be undone: a retracted belief restores, a stopped service starts again, a revoked device pairs again. -## What does it count about a run — the anonymous usage counts, and `codeaf telemetry` - -`codeaf telemetry` is the door onto the anonymous usage counts: `status` says whether -they are on and why not when they are off; `info` opens on `codeaf does NOT collect or -share your chat` and then says what is collected, shaped like the data — for each stream, under a line naming where it goes or why it is not sent, every -every-event field with the value this machine would send now and one example row per -event (`session_ended mode=chat duration=5-30m turns=6-20 …`) with the stop reasons a -row can carry, and for the Model Pool one example row in the relay's own bytes; `show` -prints what is waiting to leave right now as one JSON object, a key per destination -(`usage`, `model_pool`) with its `destination`, an `off` reason when nothing is sent -there, and `waiting`, the rows themselves, `[]` on the day you install; and `off` and -`on` write the answer to your profile; `on` is an explicit opt-in and opens the send gate -without waiting for another chat frame. `info` lists only what is sent, never a disclaimer. -Each completed provider call queues a `usage_delta` with exact input, output and total -tokens, never which model or what it read. The queue is sent every 30 seconds while the -session stays open and once more when it ends; `session_ended` carries the outcome and -bucketed session counts without repeating tokens that were already reported. -`CODEAF_TELEMETRY=off` — or `DO_NOT_TRACK=1`, or `codeaf telemetry off` — stops both: the -usage counts go quiet and the Model Pool is capped at `read`, so it can still fetch the index -and sends nothing. The pool's own switch, `model_pool` in `/settings` or -`CODEAF_MODEL_POOL`, adds `off`, which asks no judge at all. It reads and -sends nothing of its own — it is a command about the counts, not a session. The -notice names the bargain before the first byte leaves. A chat shows it once, dim, -on the first conversation's screen under the starting points, and nothing is sent -until a frame has drawn it. A task command and `chat --once` print it to stderr -instead. `CODEAF_TELEMETRY=off` or `DO_NOT_TRACK=1` turns the counts off entirely. See -docs/TELEMETRY.md for the whole contract. +## Does codeaf collect data about me — telemetry, the anonymous usage counts, and how to turn them off + +codeaf sends anonymous usage counts to AgentField: the version, OS, mode (chat or task), +session counts and errors in bands, and the tokens each provider call used. Never anything +about you or your work — no prompts, code, file names, paths, repo names, keys, email, IP or +machine name, and no model names. Each completed provider call queues a `usage_delta` with +exact input, output and total tokens, never which model or what it read; the queue is sent +every 30 seconds while the session stays open and once more when it ends, and `session_ended` +carries the outcome and bucketed session counts without repeating tokens already reported. +There is **no notice in the product and no `codeaf telemetry` command**: the whole account of +what leaves, field by field, is `docs/TELEMETRY.md` in the repository (linked from the +README's *Telemetry* section), and a test holds that page to the code. The counts are not +the only stream: with `model_pool` on, one scored row per crew seat leaves for the Model +Pool after a task lands (*Model Pool* above). + +**To turn it off**, any one of these does it, and every one of them also caps the Model +Pool at `read`, so it still fetches the index and sends nothing: the `telemetry` switch on +the *display* tab of `/settings`, which writes `telemetry = off` to your profile; +`CODEAF_TELEMETRY=off` in the shell; `DO_NOT_TRACK=1`, the ecosystem's own word for it; +`telemetry = off` in the project's `.codeaf/config.json`, which may only turn it off, never +on; or an empty `CODEAF_TELEMETRY_ENDPOINT`. A build that cannot name its own source — dirty +or unstamped — never reports, and neither does a test binary. There is nothing to show or +inspect from the terminal: the spool waits in `~/.codeaf/telemetry/spool.jsonl` and the +`codeaf telemetry status`, `info` and `show` verbs that used to print it were removed on +2026-10-01 along with the notice. ## Reading a plan by hand — codeaf plan new, show, revise and run diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index ca1956ba93..6dad4d31be 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -127,6 +127,12 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"it worked and then stopped", "openrouter-credits"}, {"why is my model a free one", "openrouter-credits"}, {"low on credits warning", "openrouter-credits"}, + {"setup only shows free models", "openrouter-credits"}, + // The anonymous usage counts, asked the two ways people ask: whether + // anything is collected, and how to stop it. + {"does codeaf collect data about me", "running-from-the-terminal"}, + {"turn off telemetry", "running-from-the-terminal"}, + {"can I click on the setup screen", "getting-started"}, {"can I use my own deepseek key", "services"}, {"how do I connect glm", "services"}, {"how do I add an api key for another provider", "services"}, diff --git a/internal/telemetry/doc_test.go b/internal/telemetry/doc_test.go index a39fafbe51..5352a75953 100644 --- a/internal/telemetry/doc_test.go +++ b/internal/telemetry/doc_test.go @@ -157,12 +157,13 @@ func TestDocPropertyTableMatchesTheAllowlist(t *testing.T) { } } -func TestDocCarriesTheNoticeAndTheSwitches(t *testing.T) { +// THE DOC IS THE DISCLOSURE. The product prints no notice and has no command +// about the counts since 2026-10-01, so the page in the repository is the one +// place a person is told what leaves and how to stop it, and it must name +// every switch and everything that is never sent. +func TestDocCarriesTheDisclosureAndTheSwitches(t *testing.T) { body := docBody(t) - if !strings.Contains(body, Notice) { - t.Error("docs/TELEMETRY.md must quote the notice byte for byte") - } - for _, wanted := range []string{"CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "telemetry info", "telemetry show"} { + for _, wanted := range []string{"CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "/settings", "codeaf sends anonymous usage counts"} { if !strings.Contains(body, wanted) { t.Errorf("docs/TELEMETRY.md must mention %q", wanted) } diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go index 6c38552dec..fe407b4681 100644 --- a/internal/telemetry/events.go +++ b/internal/telemetry/events.go @@ -422,10 +422,10 @@ func StopReasons() []string { // exampleProps is one plausible value per event prop, spelled from the // contract's own constants wherever the contract has one, so an example row -// can never show a value a real row could not carry. `codeaf telemetry show` -// prints one row per event from this table so a person sees the shape of -// what leaves before anything has. The fingerprint is the one invented value: -// sixteen hex characters, which is all a real one is. +// can never show a value a real row could not carry. docs/TELEMETRY.md is held +// to this table so a person reading the repository sees the shape of what +// leaves. The fingerprint is the one invented value: sixteen hex characters, +// which is all a real one is. var exampleProps = map[string]map[string]string{ "session_started": { "mode": string(ModeChat), diff --git a/internal/telemetry/notice.go b/internal/telemetry/notice.go deleted file mode 100644 index bbf98af2c5..0000000000 --- a/internal/telemetry/notice.go +++ /dev/null @@ -1,35 +0,0 @@ -package telemetry - -import ( - "fmt" - "os" - "sync" -) - -// Notice is the exact text the contract fixes for the one line codeaf prints -// to stderr before the first session's events are ever sent, and that the -// installer prints. It is a constant, byte for byte, and a test holds the -// bytes. -const Notice = `codeaf sends anonymous usage counts to AgentField. - Sent: version, OS, mode, session counts, errors, and total tokens used. - Never: anything about you or your work. No prompts, code, file names, - paths, repo names, keys, email, IP, or machine name. - What is collected: codeaf telemetry info - Turn off: CODEAF_TELEMETRY=off` - -// noticeOnce keeps the notice to one line per process even when several -// sessions open under one run. It is separate from the failure channel's once: -// a failure that kept quiet must not swallow the notice, and a notice shown -// must not spend the failure's single line. -var noticeOnce sync.Once - -// PrintNotice writes the notice to stderr once per process, the shape the -// surface job calls before the first session's events are sent. It never -// touches the on-disk marker — that is MarkNoticeShown's job, and the two are -// separate so a notice printed by the installer does not stand in for the -// surface having shown it. -func PrintNotice() { - noticeOnce.Do(func() { - fmt.Fprintln(os.Stderr, Notice) - }) -} diff --git a/internal/telemetry/spool.go b/internal/telemetry/spool.go index a3edb8ebbd..1062c7e0df 100644 --- a/internal/telemetry/spool.go +++ b/internal/telemetry/spool.go @@ -52,29 +52,16 @@ const persistSizeLimit = 1 << 20 // goroutines apart. var spoolMu sync.Mutex -// noticeSeenPath is the marker that says the person has seen the notice. Until -// it exists, nothing is sent: spooling is silent, flushing is a no-op. -const noticeSeenFile = "notice_seen" - -// noticeSeenPath is where NoticeShown reads and MarkNoticeShown writes. -func noticeSeenPath() string { return telemetryFile(noticeSeenFile) } - // spoolPath is the JSON-lines file events wait in. +// +// THERE IS NO NOTICE GATE ON THE SPOOL ANY MORE. Until 2026-10-01 the binary +// printed a six-line notice once per install and sent nothing until a frame or +// a terminal had shown it; the disclosure now lives in the repository +// (README.md and docs/TELEMETRY.md), the product says nothing, and the ladder +// in enabled.go — CODEAF_TELEMETRY, DO_NOT_TRACK, the project file, the +// profile's telemetry row — is the whole of what decides whether a flush sends. func spoolPath() string { return telemetryFile("spool.jsonl") } -// NoticeShown reports whether the notice has been marked shown. Until it has, -// events may be spooled but are never flushed. -func NoticeShown() bool { return fileExists(noticeSeenPath()) } - -// MarkNoticeShown records that the person has seen the notice, which opens the -// gate on flushing. A failure is silent for the same reason every other write -// here is: the worst case is a notice seen twice. -func MarkNoticeShown() { - if err := writeFilePrivate(noticeSeenPath(), []byte("shown\n")); err != nil { - oneWarning("telemetry: could not record that the notice was shown") - } -} - // warnOnce is the one line to stderr a process may ever produce for telemetry // failures, however many failures there are. var warnOnce sync.Once @@ -173,9 +160,6 @@ func Flush(ctx context.Context) error { if !enabledFor() { return nil } - if !NoticeShown() { - return nil - } deadline, hasDeadline := ctx.Deadline() if !hasDeadline { deadline = time.Now().Add(time.Second) @@ -470,9 +454,8 @@ func SpoolContents() []json.RawMessage { return out } -// Show returns the spool as pretty JSON — the exact answer a future -// `codeaf telemetry show` prints, so a person can read everything that has -// not left yet. +// Show returns the spool as pretty JSON, so a person — or a test — can read +// everything that has not left yet. func Show() string { contents := SpoolContents() if len(contents) == 0 { diff --git a/internal/telemetry/spool_test.go b/internal/telemetry/spool_test.go index 6754387e55..46818d0cfb 100644 --- a/internal/telemetry/spool_test.go +++ b/internal/telemetry/spool_test.go @@ -75,7 +75,6 @@ func newRelay(t *testing.T) *relayRecorder { func TestFlushSendsFiftyPerPostAndRemovesSentLines(t *testing.T) { testHome(t) recorder := newRelay(t) - MarkNoticeShown() for i := 0; i < 120; i++ { if err := SpoolSync(stamped(SessionStarted(ModeChat, false, fmt.Sprintf("session-%d", i), freshClock(t)))); err != nil { t.Fatalf("spooling event %d: %v", i, err) @@ -112,34 +111,9 @@ func TestFlushSendsFiftyPerPostAndRemovesSentLines(t *testing.T) { } } -func TestFlushIsSilentUntilTheNoticeWasShown(t *testing.T) { - testHome(t) - recorder := newRelay(t) - if err := SpoolSync(stamped(SessionStarted(ModeChat, false, "session-gated", freshClock(t)))); err != nil { - t.Fatal(err) - } - if err := Flush(context.Background()); err != nil { - t.Fatal(err) - } - if got := recorder.count(); got != 0 { - t.Fatalf("the relay saw %d posts before the notice was marked shown, want 0", got) - } - if left := len(SpoolContents()); left != 1 { - t.Fatalf("%d lines remain after a gated flush, want 1", left) - } - MarkNoticeShown() - if err := Flush(context.Background()); err != nil { - t.Fatal(err) - } - if got := recorder.count(); got != 1 { - t.Fatalf("after MarkNoticeShown the relay saw %d posts, want 1", got) - } -} - func TestFlushDropsEventsOlderThanSevenDays(t *testing.T) { testHome(t) recorder := newRelay(t) - MarkNoticeShown() fresh := stamped(SessionStarted(ModeChat, false, "session-fresh", freshClock(t))) ancient := stamped(SessionStarted(ModeChat, false, "session-ancient", freshClock(t))) @@ -193,7 +167,6 @@ func TestFlushRespectsTheCallerDeadline(t *testing.T) { hanging.Close() }) t.Setenv("CODEAF_TELEMETRY_ENDPOINT", hanging.URL) - MarkNoticeShown() for i := 0; i < 3; i++ { if err := SpoolSync(stamped(SessionStarted(ModeChat, false, fmt.Sprintf("session-hang-%d", i), freshClock(t)))); err != nil { t.Fatal(err) @@ -264,7 +237,6 @@ func TestFlushDropsEventsThatCannotNameTheirVersion(t *testing.T) { t.Run(tc.name, func(t *testing.T) { testHome(t) recorder := newRelay(t) - MarkNoticeShown() event := SessionStarted(ModeChat, false, "session-"+tc.name, freshClock(t)) spoolAsVersion(t, event, tc.version, tc.omit) if err := Flush(context.Background()); err != nil { @@ -286,7 +258,6 @@ func TestFlushDropsEventsThatCannotNameTheirVersion(t *testing.T) { func TestFlushSendsOnlyStampedLinesFromAMixedBatch(t *testing.T) { testHome(t) recorder := newRelay(t) - MarkNoticeShown() unknown := SessionStarted(ModeChat, false, "session-unknown", freshClock(t)) spoolAsVersion(t, unknown, "unknown", false) // The two stamped lines are written by hand too: the test binary has no @@ -322,7 +293,6 @@ func TestFlushSendsOnlyStampedLinesFromAMixedBatch(t *testing.T) { func TestFlushCapsAtOneThousandLinesDroppingTheOldest(t *testing.T) { testHome(t) recorder := newRelay(t) - MarkNoticeShown() if err := ensureDir(); err != nil { t.Fatal(err) } @@ -364,7 +334,6 @@ func TestFlushKeepsTheNewestThousandLines(t *testing.T) { })) t.Cleanup(relay.Close) t.Setenv("CODEAF_TELEMETRY_ENDPOINT", relay.URL) - MarkNoticeShown() if err := ensureDir(); err != nil { t.Fatal(err) } @@ -405,7 +374,6 @@ func TestFlushKeepsTheNewestThousandLines(t *testing.T) { func TestFlushDropsPropKeysAndEventNamesTheContractDoesNotAllow(t *testing.T) { testHome(t) recorder := newRelay(t) - MarkNoticeShown() // A spool line carrying a prop key the contract does not name — written by // an older build, or by hand. The key must not reach the wire. @@ -473,7 +441,6 @@ func TestFlushPartialFailure(t *testing.T) { })) t.Cleanup(flaky.Close) t.Setenv("CODEAF_TELEMETRY_ENDPOINT", flaky.URL) - MarkNoticeShown() const total = 120 // three batches: 50 + 50 + 20 ids := make([]string, total) for i := 0; i < total; i++ { @@ -569,7 +536,6 @@ func TestSpoolConcurrent(t *testing.T) { func TestFlushRacesSpool(t *testing.T) { testHome(t) recorder := newRelay(t) - MarkNoticeShown() // Warm the spool so the flush has lines to send while the spooler runs. for i := 0; i < 30; i++ { if err := SpoolSync(stamped(SessionStarted(ModeChat, false, fmt.Sprintf("warm-%d", i), freshClock(t)))); err != nil { @@ -652,7 +618,6 @@ func TestFlushRacesSpool(t *testing.T) { // spool at the start of the next flush, and nothing is adopted twice. func TestFlushAdoptsOrphanedSendingFiles(t *testing.T) { testHome(t) - MarkNoticeShown() orphan := filepath.Join(telemetryDir(), "spool.sending.999999.deadbeef") if err := os.MkdirAll(telemetryDir(), 0o700); err != nil { t.Fatal(err) @@ -729,7 +694,6 @@ func TestAFailingRelayKeepsTheSpoolAndStaysSilent(t *testing.T) { testHome(t) // Nothing listens here. t.Setenv("CODEAF_TELEMETRY_ENDPOINT", "http://127.0.0.1:1/telemetry") - MarkNoticeShown() if err := SpoolSync(stamped(SessionStarted(ModeChat, false, "session-lost", freshClock(t)))); err != nil { t.Fatal(err) } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 282fde9817..f74affdb1c 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "io" "net/http" "os" "strings" @@ -261,7 +260,6 @@ func TestOptOutLadderGatesTheWritePaths(t *testing.T) { rung.configure(t) } recorder := newRelay(t) - MarkNoticeShown() Spool(SessionStarted(ModeChat, false, "session-gated", freshClock(t))) if err := SpoolSync(SessionStarted(ModeChat, false, "session-gated-sync", freshClock(t))); err != nil { t.Fatalf("SpoolSync returned %v on a gated run; a no-op answers nil", err) @@ -354,44 +352,6 @@ func TestFirstRunIsEmittedOncePerInstallID(t *testing.T) { } } -// The notice is the contract's text, byte for byte. The contract file itself -// is not committed, so the expected text lives here. -const contractNotice = `codeaf sends anonymous usage counts to AgentField. - Sent: version, OS, mode, session counts, errors, and total tokens used. - Never: anything about you or your work. No prompts, code, file names, - paths, repo names, keys, email, IP, or machine name. - What is collected: codeaf telemetry info - Turn off: CODEAF_TELEMETRY=off` - -func TestNoticeIsTheContractText(t *testing.T) { - if Notice != contractNotice { - t.Errorf("Notice drifted from the contract:\n got: %q\nwant: %q", Notice, contractNotice) - } -} - -func TestPrintNoticeWritesOneLineOnce(t *testing.T) { - old := os.Stderr - reader, writer, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stderr = writer - captured := make(chan string, 1) - go func() { - var buf strings.Builder - _, _ = io.Copy(&buf, reader) - captured <- buf.String() - }() - PrintNotice() - PrintNotice() - os.Stderr = old - writer.Close() - got := <-captured - if count := strings.Count(got, Notice); count != 1 { - t.Errorf("the notice printed %d times in one process, want 1", count) - } -} - func TestUsageContextBuckets(t *testing.T) { testHome(t) t.Setenv("CI", "1") @@ -533,7 +493,6 @@ func TestNoTestBinaryCanReachTheProductionRelay(t *testing.T) { previous := httpClient httpClient = &http.Client{Transport: counter} t.Cleanup(func() { httpClient = previous }) - MarkNoticeShown() event := SessionStarted(ModeChat, false, "session-law", freshClock(t)) if err := SpoolSync(event); err != nil { t.Fatalf("SpoolSync returned %v; a spool that is never sent answers nil", err) @@ -572,7 +531,6 @@ func TestNewRelayAfterTestHomeOverridesTheDeadLoopback(t *testing.T) { if override := os.Getenv("CODEAF_TELEMETRY_ENDPOINT"); override != Endpoint() { t.Fatalf("after newRelay Endpoint() answers %q but the override is %q", Endpoint(), override) } - MarkNoticeShown() event := stamped(SessionStarted(ModeChat, false, "session-relay", freshClock(t))) if err := SpoolSync(event); err != nil { t.Fatalf("SpoolSync returned %v; a spool that is sent answers nil", err) @@ -608,7 +566,6 @@ func TestFlushAgainstTheDefaultEndpointRefusesLikeADeadRelay(t *testing.T) { previous := httpClient httpClient = &http.Client{Transport: counter} t.Cleanup(func() { httpClient = previous }) - MarkNoticeShown() event := SessionStarted(ModeChat, false, "session-refused", freshClock(t)) if err := SpoolSync(event); err != nil { t.Fatalf("SpoolSync returned %v; a spool that is never sent answers nil", err) @@ -648,7 +605,6 @@ func TestForcingTheLadderOnCannotReopenTheDefaultEndpointPath(t *testing.T) { previous := httpClient httpClient = &http.Client{Transport: counter} t.Cleanup(func() { httpClient = previous }) - MarkNoticeShown() event := SessionStarted(ModeChat, false, "session-forced-ladder", freshClock(t)) if err := SpoolSync(event); err != nil { t.Fatalf("SpoolSync returned %v; a spool that is never sent answers nil", err) diff --git a/internal/telemetry/usage_test.go b/internal/telemetry/usage_test.go index 3f9d55d359..7218e389fc 100644 --- a/internal/telemetry/usage_test.go +++ b/internal/telemetry/usage_test.go @@ -44,7 +44,8 @@ func TestPeriodicFlushSendsBeforeTheSessionEnds(t *testing.T) { testHome(t) VersionForTest(t, "v0.6.1-test") recorder := newRelay(t) - MarkNoticeShown() + // No notice gate stands before the first send since 2026-10-01: a queued + // row leaves on the next flush, which is the whole claim here. if err := SpoolSync(UsageDelta(ModeChat, 100, 25, "open-session", time.Now())); err != nil { t.Fatal(err) } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index c7a980a133..72c11f9d3c 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -884,17 +884,6 @@ type app struct { hostReplayWaiting bool hostCalls int hostDeferred []func() tea.Cmd - // telemetryNotice is the usage notice still owed to the person, drawn on the - // first conversation's greeting ([app.welcomeNoticeRows]); empty when nothing - // is owed or once the greeting that showed it has gone. - telemetryNotice string - // telemetryNoticeShown is the door's "it was seen" record, and - // telemetryNoticeOnFrame is a frame's note that it drew the notice. The frame - // only notes; the update loop calls the door, once - // ([app.settleTelemetryNotice]), and telemetryNoticeSettled says it has. - telemetryNoticeShown func() - telemetryNoticeOnFrame bool - telemetryNoticeSettled bool questionReplacement *questionReplacement @@ -3035,7 +3024,6 @@ func newApp(ctx context.Context, opts Options) *app { lastQuestionKey: time.Now(), questionReach: newQuestionDeliveryRule(), } - a.telemetryNotice, a.telemetryNoticeShown = opts.TelemetryNotice, opts.TelemetryNoticeShown // THE MEMOS ARE BUILT BEFORE ANYTHING ASKS THEM ANYTHING, because the frame's // door onto each is a memo lookup and nothing else: a memo with no reader // behind it answers "nobody has read that" forever (learned.go). They are @@ -3507,7 +3495,6 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // A USAGE NOTICE THE LAST FRAME DREW IS RECORDED HERE, on the loop and once: // the frame may only note that it drew it (view.go), because the door's // record is a write to disk. - a.settleTelemetryNotice() // A JUMP TO A MESSAGE WAITING FOR ITS CONVERSATION lands here, on the first // message after that conversation is in front (teamjump.go). if a.traffic.jump.key != "" { @@ -4038,6 +4025,12 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.tsheet.on || a.tmove.on { return a, nil } + if a.setup.open { + // The setup is a sheet over everything; its open model list is the + // one thing under the wheel (onboarding.go's [app.setupWheel]). + a.setupWheel(msg.Mouse().Button == tea.MouseWheelDown) + return a, nil + } if a.wall.on { a.wallWheel(msg.Mouse().X, msg.Mouse().Y, msg.Mouse().Button == tea.MouseWheelDown) return a, nil @@ -4350,13 +4343,21 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.pasteEdit.open { return a, nil } - if a.copy.on || a.setup.open { + if a.copy.on { // A click in copy mode acts on nothing: the rows under the pointer are // a FROZEN snapshot, and expanding a call in it would be expanding a - // row that is no longer where the conversation says it is. The setup - // screen is the same for the pointer's own reason: it is three - // keystrokes, and a press through it would land on a frame that is - // not being drawn (firstrun.go). + // row that is no longer where the conversation says it is. + return a, nil + } + if a.setup.open { + // THE SETUP OWNS THE PRESS WHILE IT IS UP, for the modal's reason: a + // press through it would land on a frame that is not being drawn. + // The controls screen answers a press on its own rows the way the + // keys would (onboarding.go's [app.setupPress]); the key step, which + // is one box, takes nothing from the pointer. + if msg.Mouse().Button == tea.MouseLeft && a.setupPress(msg.Mouse().X, msg.Mouse().Y) { + return a, a.endSetup(false) + } return a, nil } if msg.Mouse().Button == tea.MouseLeft { diff --git a/internal/tui3/credits.go b/internal/tui3/credits.go index 98960ebc4e..ffa276e6f7 100644 --- a/internal/tui3/credits.go +++ b/internal/tui3/credits.go @@ -122,6 +122,16 @@ func (a *app) tookCredits(msg creditReadMsg) tea.Cmd { } if msg.err == nil { a.refreshCreditWarnings() + // A READING THAT LANDS WHILE THE SETUP'S LIST IS OPEN RE-AIMS IT. The + // list is cut to free rows the moment the account reads low + // (onboarding.go's [app.setupModelChoices]), so a cursor that was on + // the ninth row of the whole catalog would be on the ninth row of a + // much shorter list — or past its end. The filter pass puts the cursor + // back on the model in use, which is where it opened. + if a.setup.open && a.setup.modelOpen { + a.filterSetupModels(a.setup.modelFind) + a.touch() + } } return a.takeCreditWake() } diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index 082450c8e2..12f262ae50 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -119,6 +119,10 @@ type setupFlow struct { // refusal is the one line the screen says under the box when enter was // pressed on something it will not write. Any other key clears it. refusal string + // doors is what the last frame of the controls screen drew for the pointer + // (onboarding.go's [setupDoors]): a press is answered against the rows that + // are on the screen, and nothing else. + doors setupDoors // auth is the default provider's browser trip. Starting covers the short // interval before its listener is handed back; flow and link cover the wait // after that. id names the attempt so a late answer after esc is dropped. diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 2364dd1042..df8f40fabe 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -402,6 +402,14 @@ func (a *app) setupControlsEnter() bool { a.takeSetupModel(models[s.modelAt].ID) } s.closeChoosers() + // AND THEN ENTER GOES ON, as it does on the limit. A model taken + // from the list is this row answered; an earlier build left the + // focus on it, so the next enter opened the list again and a person + // pressing enter to get through the form never got past this row. + // A refusal keeps the focus here so it can be read. + if s.refusal == "" { + s.focusControl(a, 1) + } return false } // A ROW THE ENVIRONMENT OWNS DOES NOT OPEN A LIST. Offering a choice that @@ -418,7 +426,14 @@ func (a *app) setupControlsEnter() bool { return false case controlReview: - s.reviewOpen = !s.reviewOpen + // Enter shows the review, and enter on a review already showing goes + // on to the way out rather than folding it away: the rows stay readable + // and the form keeps moving under repeated enters. + if s.reviewOpen { + s.focusControl(a, 1) + return false + } + s.reviewOpen = true return false } // `Start a conversation` — everything the screen holds is written down, and @@ -426,6 +441,90 @@ func (a *app) setupControlsEnter() bool { return a.commitSetupControls() } +// ── the pointer ───────────────────────────────────────────────────────────── + +// setupDoors is what the last frame of the controls screen drew for the pointer: +// a door per body row, and where the body stands in the window. It is written by +// [app.setupControlsFrame] and read by [app.setupPress], so a press is answered +// against the rows a person can see rather than against a form rebuilt from +// state that may have moved under them. +type setupDoors struct { + rows []setupDoor + // top is the frame row the first body row is on; left and width are the + // form's columns, so a press on the example column beside the form — which + // is an illustration — selects nothing. + top, left, width int +} + +// setupPress is a left press on the controls screen, answered the way the keys +// would have answered it. It reports whether the screen is finished. +// +// THE SCREEN USED TO SWALLOW EVERY PRESS, on the argument that the setup was +// three keystrokes. It is a form now, with rows that look like rows and a list +// that looks like a list, and the first thing a person who sees a list does is +// click on it. So a press on a control is a tab to it, and a press on a row that +// enter would act on — the model field, the review, the way out, or one model of +// the open list — is that enter. The limit row is only focused, because what a +// press on an amount means is "I want to type here". A press anywhere else on +// the screen — a sentence, a blank, the example — does nothing, which is what a +// press on words should do. +func (a *app) setupPress(x, y int) bool { + s := &a.setup + if s.step() != setupControls { + return false + } + d := s.doors + row := y - d.top + if row < 0 || row >= len(d.rows) || x < d.left || x >= d.left+d.width { + return false + } + door := d.rows[row] + switch door.kind { + case doorModel: + models := a.setupModelChoices() + if door.model < 0 || door.model >= len(models) { + return false + } + s.modelAt = door.model + a.touch() + return a.setupControlsEnter() + case doorControl: + if s.control != door.control { + // A tab to the row, which closes whatever was open on the way and + // moves the example with it ([setupFlow.focusControl]). + s.focusControl(a, int(door.control)-int(s.control)) + } else if s.modelOpen && door.control == controlChatModel { + // A press on the field whose list is open puts the list away and + // chooses nothing, which is esc's rule: a cursor is not an answer. + s.closeChoosers() + a.touch() + return false + } + if door.control == controlLimit { + a.touch() + return false + } + a.touch() + return a.setupControlsEnter() + } + return false +} + +// setupWheel is the wheel over the controls screen. It turns the open model +// list — the one thing on the screen that scrolls — and nothing else. +func (a *app) setupWheel(down bool) { + s := &a.setup + if s.step() != setupControls || !s.modelOpen { + return + } + delta := -1 + if down { + delta = 1 + } + a.moveSetupModel(delta) + a.touch() +} + // clampIndex keeps a cursor inside a list that may have changed under it. func clampIndex(at, count int) int { if count <= 0 { @@ -537,6 +636,22 @@ const setupModelUnsavedWord = "this conversation is on it, but it could not be s // catalog does not carry it, and the cursor opens on it either way. func (a *app) setupModelChoices() []Model { models := a.modelList() + if a.setupFreeOnly() { + // ONLY THE FREE ROWS WHILE THE ACCOUNT READS LOW. The warning under the + // message box says some models may not be available; on this screen, + // where a person who has never run the program is choosing by name, a + // list of three hundred paid models they cannot use is a list they will + // pick the wrong row from (five new people did, 2026-09-30). The same + // test the warning uses decides a row ([app.paidCreditModel]): a `:free` + // id, a catalog row priced at zero, or a model another service serves. + free := make([]Model, 0, len(models)) + for _, model := range models { + if !a.paidCreditModel(model.ID, models) { + free = append(free, model) + } + } + models = free + } if current := strings.TrimSpace(a.model); current != "" { found := false for _, model := range models { @@ -581,6 +696,15 @@ func (a *app) setupModelChoices() []Model { return out } +// setupFreeOnly reports whether the model list is cut to free rows: the default +// provider's account is known low, by the same reading the warning under the +// message box follows ([app.refreshCreditWarnings]). It is read live rather than +// at seed time because the balance is read AFTER the key lands, on its own +// goroutine, and usually answers while this screen is already up. +func (a *app) setupFreeOnly() bool { + return a.readCredits != nil && a.creditsLow +} + // setupModelSlots is how many models the list SHOWS AT ONCE, and it is a height // budget rather than a limit on the catalog: the cursor scrolls the rest past it // and typing narrows them. Five is what a twenty-four-row window has to spare @@ -864,7 +988,11 @@ func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { // order the form itself ranked them. A wrapped sentence cut off in the middle // is worse than a sentence that is not there. Three rows are spoken for // before the form gets any: the header, the blank under it, and the legend. - body, caretRow := sheet.trim(max(height-3, 1)) + body, doors, caretRow := sheet.trim(max(height-3, 1)) + // THE DOORS ARE KEPT WITH THE FRAME THAT DREW THEM, so a press reads the + // rows that are actually on the screen: body row i is frame row i+2, under + // the header and the blank, and it spans the form's own columns. + a.setup.doors = setupDoors{rows: doors, top: 2, left: lead, width: form} var show []string if wide { @@ -950,6 +1078,11 @@ type controlsSheet struct { // block is the block id each row belongs to, or zero for a row that is never // given up: a field's value, an open chooser, the primary action, the legend. block []int + // door is what each row is a door onto for the pointer, row for row with + // rows: the control a press on it focuses, or the model it chooses. A row + // that is only words carries the zero value, which is a row a press lands on + // and nothing happens. + door []setupDoor // ranked is every droppable block with how willingly it goes. ranked []controlsRank next int @@ -957,6 +1090,32 @@ type controlsSheet struct { caretX int } +// setupDoor is what one row of the form means to a press. A row is one of three +// things: nothing (a sentence, a blank, the count under the list), a control +// (its label-and-value row, or the primary action), or one model of the open +// list — in which case model is its index into [app.setupModelChoices]. +// +// IT IS RECORDED WHILE THE FORM IS BUILT AND NOT RECOMPUTED FROM A CLICK, +// because the form is not a fixed shape: a short window gives up whole blocks +// ([controlsSheet.trim]) and an open list or review adds rows under a field, so +// the only thing that knows which control is on row nine is the pass that put it +// there. The frame keeps the doors it drew ([setupFlow.doors]) and a press reads +// them back — the same memo-at-draw-time rule every other page's press obeys. +type setupDoor struct { + control setupControl + model int + kind setupDoorKind +} + +// setupDoorKind is which of the three a row is. +type setupDoorKind int + +const ( + doorNone setupDoorKind = iota + doorControl + doorModel +) + // controlsRank pairs a block with how willingly it goes. type controlsRank struct{ rank, id int } @@ -977,7 +1136,15 @@ const ( func (f *controlsSheet) add(lines ...string) { for _, line := range lines { - f.rows, f.block = append(f.rows, line), append(f.block, 0) + f.rows, f.block, f.door = append(f.rows, line), append(f.block, 0), append(f.door, setupDoor{}) + } +} + +// open adds rows that are never given up AND are a door for the pointer: a +// control's own row, or one model of the list. +func (f *controlsSheet) open(door setupDoor, lines ...string) { + for _, line := range lines { + f.rows, f.block, f.door = append(f.rows, line), append(f.block, 0), append(f.door, door) } } @@ -990,15 +1157,15 @@ func (f *controlsSheet) soft(rank int, lines ...string) { id := f.next f.ranked = append(f.ranked, controlsRank{rank: rank, id: id}) for _, line := range lines { - f.rows, f.block = append(f.rows, line), append(f.block, id) + f.rows, f.block, f.door = append(f.rows, line), append(f.block, id), append(f.door, setupDoor{}) } } // trim gives the window back the rows it does not have, block by block, and -// carries the caret with it. -func (f *controlsSheet) trim(height int) ([]string, int) { +// carries the caret and the doors with it. +func (f *controlsSheet) trim(height int) ([]string, []setupDoor, int) { if len(f.rows) <= height { - return f.rows, f.caretAt + return f.rows, f.door, f.caretAt } over := len(f.rows) - height // The blocks are ordered by rank, and within one rank the LOWEST ON THE @@ -1027,6 +1194,7 @@ func (f *controlsSheet) trim(height int) ([]string, int) { } } out := make([]string, 0, len(f.rows)) + doors := make([]setupDoor, 0, len(f.rows)) caret := f.caretAt for at, line := range f.rows { if gone[f.block[at]] { @@ -1036,6 +1204,7 @@ func (f *controlsSheet) trim(height int) ([]string, int) { continue } out = append(out, line) + doors = append(doors, f.door[at]) } if len(out) > height { // EVERY BLOCK HAS GONE AND IT IS STILL TOO TALL, which is a window shorter @@ -1044,9 +1213,10 @@ func (f *controlsSheet) trim(height int) ([]string, int) { // person can do without, and the legend at the foot is the one they cannot. cut := len(out) - height out = out[cut:] + doors = doors[cut:] caret -= cut } - return out, caret + return out, doors, caret } // setupControlsForm builds the form: its rows, the blocks a short window gives @@ -1066,15 +1236,23 @@ func (a *app) setupControlsForm(width int) *controlsSheet { if s.control == controlLimit { f.caretAt, f.caretX = len(f.rows), limitCaret } - f.add(limitRow) + f.open(setupDoor{kind: doorControl, control: controlLimit}, limitRow) a.addControlWords(f, width, controlLimit, controlLimitWord, controlLimitDetail) f.soft(rankSpacer, "") // ── the chat model ── - f.add(a.setupFieldRow(width, controlChatModel, controlModelLabel, modelWord(a.model), a.setupModelSource())) + f.open(setupDoor{kind: doorControl, control: controlChatModel}, + a.setupFieldRow(width, controlChatModel, controlModelLabel, modelWord(a.model), a.setupModelSource())) a.addControlWords(f, width, controlChatModel, controlModelWord, a.setupModelDetail()) + // A LOW ACCOUNT IS NOT DETAIL EITHER. It is why the list under this row is + // shorter than the catalog, so it is on the screen whether or not anybody + // asked. + f.add(a.setupLowCreditsRows(width)...) if s.modelOpen { - f.add(a.setupModelRows(width)...) + rows, doors := a.setupModelRows(width) + for i, row := range rows { + f.open(doors[i], row) + } } f.soft(rankSpacer, "") @@ -1086,7 +1264,7 @@ func (a *app) setupControlsForm(width int) *controlsSheet { f.soft(rankSpacer, "") // ── the optional review ── - f.add(a.setupReviewRow(width)) + f.open(setupDoor{kind: doorControl, control: controlReview}, a.setupReviewRow(width)) if s.reviewOpen { rows, rest := a.setupReviewRows(width) f.add(rows...) @@ -1095,7 +1273,7 @@ func (a *app) setupControlsForm(width int) *controlsSheet { f.soft(rankSpacer, "") // ── the way out ── - f.add(a.setupStartRow(width)) + f.open(setupDoor{kind: doorControl, control: controlStart}, a.setupStartRow(width)) if s.refusal != "" { // A REFUSAL IS NEVER GIVEN UP. It is the one row on this screen that is // about something that just went wrong, and a window too short to show it @@ -1277,31 +1455,40 @@ func (a *app) setupLimitRow(width int) (string, int) { // catalog with the cursor's exact id under it, and a count that says how much // more there is and how to reach it. A machine with no catalog at all still shows // the model in use, so enter confirms rather than changes. -func (a *app) setupModelRows(width int) []string { +// +// It answers the rows and, row for row, what each is a door onto for a press: +// a model's name row and the id row under the cursor's model both choose that +// model, and the count line chooses nothing. +func (a *app) setupModelRows(width int) ([]string, []setupDoor) { pal := a.pal s := &a.setup models := a.setupModelChoices() if len(models) == 0 { if strings.TrimSpace(s.modelFind) != "" { - return []string{strings.Repeat(" ", 4) + pal.dim(fit(setupNoMatchWord, width-4))} + return []string{strings.Repeat(" ", 4) + pal.dim(fit(setupNoMatchWord, width-4))}, []setupDoor{{}} } - return []string{strings.Repeat(" ", 4) + pal.dim(fit(setupNoCatalogWord, width-4))} + return []string{strings.Repeat(" ", 4) + pal.dim(fit(setupNoCatalogWord, width-4))}, []setupDoor{{}} } top := clampIndex(s.modelTop, max(len(models)-setupModelSlots+1, 1)) out := make([]string, 0, setupModelSlots+2) + doors := make([]setupDoor, 0, setupModelSlots+2) for i := top; i < len(models) && i < top+setupModelSlots; i++ { name := modelWord(models[i].ID) + door := setupDoor{kind: doorModel, control: controlChatModel, model: i} if i == s.modelAt { out = append(out, " "+pal.accent(setupLead)+pal.bold(pal.ink(fit(name, width-6)))) // THE EXACT ID, UNDER THE ONE ROW IT IS ABOUT. The list reads as names // and the address is still on the screen for whoever needs it. out = append(out, strings.Repeat(" ", 6)+pal.dim(fit(models[i].ID, width-6))) + doors = append(doors, door, door) continue } out = append(out, " "+pal.dim(fit(name, width-6))) + doors = append(doors, door) } out = append(out, strings.Repeat(" ", 4)+pal.dim(fit(a.setupModelCountWord(len(models)), width-4))) - return out + doors = append(doors, setupDoor{}) + return out, doors } // setupModelCountWord is the line under the list: where the cursor is in the @@ -1310,12 +1497,40 @@ func (a *app) setupModelRows(width int) []string { func (a *app) setupModelCountWord(count int) string { at := clampIndex(a.setup.modelAt, count) + 1 where := itoa(at) + " of " + itoa(count) + if a.setupFreeOnly() { + where += " · " + setupFreeOnlyWord + } if find := strings.TrimSpace(a.setup.modelFind); find != "" { return where + " · matching " + find } return where + " · type to narrow" } +// setupFreeOnlyWord is the count line's word for a list cut to free rows, and +// setupLowCreditsWord is the dim line under the chat model that says why. The +// line names the account rather than the list, because the account is the fact +// a person can act on, and it ends on where the rest went so the cut does not +// read as a catalog that failed to load. +const ( + setupFreeOnlyWord = "free only" + setupLowCreditsWord = "Your OpenRouter account is low on credits · the list shows free models only" +) + +// setupLowCreditsRows is the line under the chat model while the account reads +// low — wrapped at the form's width rather than cut, because every word of it +// is the reason the list is short — and nothing, the emptiness law, when the +// account does not read low. +func (a *app) setupLowCreditsRows(width int) []string { + if !a.setupFreeOnly() { + return nil + } + lines := wrap(setupLowCreditsWord, max(width-4, 1)) + for i, line := range lines { + lines[i] = strings.Repeat(" ", 4) + a.pal.warn(line) + } + return lines +} + // setupNoCatalogWord is what the list says where there is no catalog to choose // from — no key yet, no cache, nothing fetched — and no model in use either. It // names the door rather than the absence, because the absence is not something a @@ -1455,6 +1670,9 @@ func (a *app) setupControlsKeys(width int) string { parts = []string{"enter opens the list", "tab moves", a.setupBackWord()} case controlReview: parts = []string{"enter shows them", "tab moves", a.setupBackWord()} + if s.reviewOpen { + parts[0] = "enter goes on" + } case controlStart: parts = []string{"enter starts", "tab moves", a.setupBackWord()} } diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 3f2f204973..9b6ae0cbe9 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -1,10 +1,14 @@ package tui3 import ( + "context" "strings" "testing" + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/credits" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -91,15 +95,20 @@ func TestTheModelListReachesTheWholeCatalogAndOpensOnTheModelInUse(t *testing.T) if got := choices[a.setup.modelAt].ID; got != a.model { t.Fatalf("the cursor opened on %q, want the model in use %q", got, a.model) } - // And enter on it confirms rather than changes. + // And enter on it confirms rather than changes — and then goes on to the + // next row, as it does on the limit, so a person pressing enter to get + // through the form is not handed the same list again. was := a.model pressSetup(a, key("enter")) if a.model != was { t.Fatalf("enter on the model in use switched to %q", a.model) } + if a.setup.modelOpen || a.setup.control != controlReview { + t.Fatalf("after taking a model the focus is on control %v with the list open=%v, want the review row and the list closed", a.setup.control, a.setup.modelOpen) + } // The last row of the catalog is reachable by walking, and the count says // how far there is to go. - pressSetup(a, key("enter")) + pressSetup(a, key("shift+tab"), key("enter")) for i := 0; i < len(catalog); i++ { if a.setupModelChoices()[a.setup.modelAt].ID == "i/india" { break @@ -828,3 +837,223 @@ func TestAFolderWithEarlierConversationsGetsTheOrdinaryGreeting(t *testing.T) { t.Fatal("the ordinary greeting no longer goes on the first keystroke") } } + +// ENTER GETS A PERSON THROUGH THE WHOLE FORM. Each row answers enter by going on +// to the next: the limit commits and moves, the model row opens its list and a +// taken model moves, the review opens and then moves, and the way out starts. +// An earlier build left the focus on the model row after a choice, so the next +// enter opened the list again and nothing a person did with enter alone ever +// reached `Start a conversation`. +func TestEnterAloneWalksTheWholeControlsScreen(t *testing.T) { + a, dir := controlsApp(t, nil) + a.models = func() []Model { return []Model{{ID: "openai/gpt-4.1-mini"}, {ID: "b/bravo"}} } + pressSetup(a, key("enter")) // the limit, taken as it stands + if a.setup.control != controlChatModel { + t.Fatalf("after the limit the focus is on %v, want the chat model", a.setup.control) + } + pressSetup(a, key("enter")) // opens the list + pressSetup(a, key("enter")) // takes the model in use and goes on + if a.setup.control != controlReview || a.setup.modelOpen { + t.Fatalf("after the model the focus is on %v (list open=%v), want the review", a.setup.control, a.setup.modelOpen) + } + pressSetup(a, key("enter")) // shows the review + if !a.setup.reviewOpen || a.setup.control != controlReview { + t.Fatal("enter on the review row did not show it") + } + if screen := setupScreen(a); !strings.Contains(screen, "enter goes on") { + t.Fatalf("the legend on an open review must say enter goes on; got:\n%s", screen) + } + pressSetup(a, key("enter")) // goes on, leaving the review readable + if a.setup.control != controlStart || !a.setup.reviewOpen { + t.Fatalf("after the review the focus is on %v (review open=%v), want the way out with the review still showing", a.setup.control, a.setup.reviewOpen) + } + pressSetup(a, key("enter")) // starts + if a.setup.open { + t.Fatal("enter on `Start a conversation` left the setup up") + } + if !config.DailyBudgetConfigured(dir) { + t.Fatal("leaving the screen did not write the day's limit") + } +} + +// setupRowOf finds the frame row a control's label is drawn on, so a click test +// aims at the row a person sees rather than at a number the test invented. +func setupRowOf(t *testing.T, a *app, label string) (x, y int) { + t.Helper() + frame, _, _ := a.frame() + for row, line := range strings.Split(plain(frame), "\n") { + if at := strings.Index(line, label); at >= 0 { + return at, row + } + } + t.Fatalf("no row of the screen carries %q:\n%s", label, plain(frame)) + return 0, 0 +} + +// THE CONTROLS SCREEN ANSWERS THE POINTER. A press on a row is the key that row +// would have taken: a press on the limit focuses it, a press on the model row +// opens its list, a press on one model of the list takes it, and a press on +// `Start a conversation` leaves. The screen used to swallow every press, on the +// argument that it was three keystrokes; new people clicked its rows and read +// the silence as a menu that could not be selected. +func TestAClickOnTheControlsScreenActsLikeTheKeyOnThatRow(t *testing.T) { + a, dir := controlsApp(t, nil) + a.models = func() []Model { + return []Model{{ID: "openai/gpt-4.1-mini"}, {ID: "b/bravo"}, {ID: "c/charlie"}} + } + // A press on the model row opens the list. + x, y := setupRowOf(t, a, controlModelLabel) + pressSetup(a, clickAt(x+2, y)) + if a.setup.control != controlChatModel || !a.setup.modelOpen { + t.Fatalf("a press on the model row left the focus on %v with the list open=%v", a.setup.control, a.setup.modelOpen) + } + // A press on one row of the list takes that model and goes on. + x, y = setupRowOf(t, a, modelWord("c/charlie")) + pressSetup(a, clickAt(x, y)) + if a.model != "c/charlie" { + t.Fatalf("a press on a model row put the conversation on %q, want c/charlie", a.model) + } + if a.setup.modelOpen || a.setup.control != controlReview { + t.Fatalf("after the press the focus is on %v (list open=%v), want the review", a.setup.control, a.setup.modelOpen) + } + // A press on the limit only focuses it: what a press on an amount means is + // "I want to type here". + x, y = setupRowOf(t, a, controlLimitLabel) + pressSetup(a, clickAt(x, y)) + if a.setup.control != controlLimit { + t.Fatalf("a press on the limit row left the focus on %v", a.setup.control) + } + // A press on a sentence does nothing. + x, y = setupRowOf(t, a, "new work waits until midnight") + pressSetup(a, clickAt(x, y)) + if a.setup.control != controlLimit || a.setup.modelOpen { + t.Fatalf("a press on a sentence changed the focus to %v (list open=%v)", a.setup.control, a.setup.modelOpen) + } + // A press on the way out leaves, with the limit written. + x, y = setupRowOf(t, a, controlStartWord) + pressSetup(a, clickAt(x, y)) + if a.setup.open { + t.Fatal("a press on `Start a conversation` left the setup up") + } + if !config.DailyBudgetConfigured(dir) { + t.Fatal("leaving by a press did not write the day's limit") + } +} + +// AND THE WHEEL TURNS THE OPEN LIST, and only the list: with it closed a notch +// changes nothing on the screen. +func TestTheWheelWalksTheOpenModelList(t *testing.T) { + a, _ := controlsApp(t, nil) + catalog := make([]Model, 0, 8) + for _, id := range []string{"openai/gpt-4.1-mini", "b/bravo", "c/charlie", "d/delta", "e/echo", "f/foxtrot", "g/golf", "h/hotel"} { + catalog = append(catalog, Model{ID: id}) + } + a.models = func() []Model { return catalog } + wheel := func(down bool) tea.MouseWheelMsg { + button := tea.MouseWheelUp + if down { + button = tea.MouseWheelDown + } + return tea.MouseWheelMsg{X: 20, Y: 10, Button: button} + } + before := setupScreen(a) + pressSetup(a, wheel(true)) + if setupScreen(a) != before { + t.Fatal("a notch with no list open changed the screen") + } + walkToControl(t, a, controlChatModel) + pressSetup(a, key("enter")) + // A run of notches is folded by the pointer coalescer and spent at the + // frame boundary (coalesce.go), which the settle message stands for here. + pressSetup(a, wheel(true), wheel(true), wheel(true), pointerMsg{}) + if got := a.setupModelChoices()[a.setup.modelAt].ID; got != "d/delta" { + t.Fatalf("three notches down put the cursor on %q, want d/delta", got) + } + pressSetup(a, wheel(false), pointerMsg{}) + if got := a.setupModelChoices()[a.setup.modelAt].ID; got != "c/charlie" { + t.Fatalf("a notch up put the cursor on %q, want c/charlie", got) + } +} + +// A LOW ACCOUNT CUTS THE LIST TO FREE ROWS. With the default provider's balance +// known low, the model list offers the `:free` ids and the catalog rows priced +// at zero, says `free only` on its count line, and the line under the field +// says why. The model in use stays on the list whatever it costs, so enter +// still confirms rather than changes. A reading that lands while the list is +// open re-aims the cursor at the model in use. +func TestALowAccountOffersOnlyFreeModelsOnTheSetupScreen(t *testing.T) { + a, dir := controlsApp(t, nil) + catalog := []Model{ + {ID: "openai/gpt-4.1-mini", PriceKnown: true, PromptPrice: 0.4, CompletionPrice: 1.6}, + {ID: "paid/alpha", PriceKnown: true, PromptPrice: 1, CompletionPrice: 2}, + {ID: "qwen/qwen3.8-27b:free", PriceKnown: true}, + {ID: "zero/priced", PriceKnown: true}, + {ID: "unknown/price"}, + } + a.models = func() []Model { return catalog } + a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{Known: true, Low: true}, nil } + // Before any reading the whole catalog is offered. + if got := len(a.setupModelChoices()); got != len(catalog) { + t.Fatalf("with no reading the list offers %d rows, want the catalog's %d", got, len(catalog)) + } + walkToControl(t, a, controlChatModel) + pressSetup(a, key("enter")) + pressSetup(a, key("down"), key("down"), key("down")) + // The reading lands while the list is open, by the road the read takes. + if err := config.WriteCreditsReading(dir, config.APIKeyAt(dir), credits.Reading{Known: true, Low: true}); err != nil { + t.Fatal(err) + } + pressSetup(a, creditReadMsg{reading: credits.Reading{Known: true, Low: true}}) + if !a.creditsLow { + t.Fatal("the fixture's reading did not read as low") + } + choices := a.setupModelChoices() + ids := make([]string, 0, len(choices)) + for _, model := range choices { + ids = append(ids, model.ID) + } + want := []string{"openai/gpt-4.1-mini", "qwen/qwen3.8-27b:free", "zero/priced"} + if strings.Join(ids, " ") != strings.Join(want, " ") { + t.Fatalf("a low account offers %v, want %v (the model in use, then the free rows)", ids, want) + } + if got := choices[a.setup.modelAt].ID; got != a.model { + t.Fatalf("after the reading the cursor is on %q, want the model in use %q", got, a.model) + } + // The sentence is read at a width with no example column beside the form, + // so its wrapped halves are not interleaved with the panel's border. + a.width = 100 + a.touch() + screen := setupScreen(a) + for _, wanted := range []string{"free only", setupLowCreditsWord} { + if !strings.Contains(screen, wanted) { + t.Fatalf("the screen must say %q; got:\n%s", wanted, screen) + } + } + // And typing narrows the cut list, never the whole catalog. + pressSetup(a, key("a")) + for _, model := range a.setupModelChoices() { + if model.ID == "paid/alpha" { + t.Fatal("typing reached a paid row through the filter") + } + } +} + +// THE SECOND ENTER SENDS. A starting point fills the box on the first enter and +// stays selected; the next enter is the ordinary send of what is in the box. +// An earlier build kept taking that enter for the starting point — which could +// fill nothing, the box being full — so pressing enter twice did nothing at all. +func TestEnterAfterAStartingPointSendsWhatItFilled(t *testing.T) { + a := firstChatApp(t) + agent, ok := a.agent.(*fakeAgent) + if !ok { + t.Skip("this fixture's agent cannot be asked what it was sent") + } + drive(t, a, key("down"), key("enter")) + if len(agent.sent) != 0 { + t.Fatalf("the first enter sent %v", agent.sent) + } + drive(t, a, key("enter")) + if len(agent.sent) != 1 || !strings.Contains(agent.sent[0], welcomeStarters[0].fills) { + t.Fatalf("the second enter sent %v, want the starting point's sentence", agent.sent) + } +} diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index f8abe978fc..e9fc11b616 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -594,8 +594,8 @@ var settingUI = map[string]settingMeta{ config.KeyTelemetry: { tab: tabDisplay, label: "telemetry", widget: widgetToggle, about: "sends anonymous usage counts (version, OS, mode, session and error " + - "counts) after a notice has been printed once; never prompts, code, paths " + - "or names. Off sends nothing.", + "counts); never prompts, code, paths or names. Off sends nothing. " + + "docs/TELEMETRY.md in the repository lists every field.", }, config.KeyDraftPersist: { tab: tabDisplay, label: "keep drafts", widget: widgetToggle, diff --git a/internal/tui3/telemetrynotice_test.go b/internal/tui3/telemetrynotice_test.go deleted file mode 100644 index 208e13d2e5..0000000000 --- a/internal/tui3/telemetrynotice_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package tui3 - -// THE USAGE NOTICE IS READ ON THE SURFACE, BEFORE ANYTHING IT DESCRIBES HAPPENS. -// It used to be printed on the normal screen a moment before the full-screen -// surface covered it, and marked as seen at the same moment, so a new person met -// it only after quitting — by which time the exit had already sent the counts it -// describes (the fresh-install check of 2026-09-25). These tests hold the -// surface's half: the first conversation's screen draws the notice whole, and -// the door is told it was seen only after a frame has drawn it. - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/Agent-Field/codeaf/internal/telemetry" -) - -// noticeWords is one notice line as the screen reads it: the fields rejoined, -// the same flattening [welcomeScreen] applies to the frame. -func noticeWords(line string) string { - return strings.Join(strings.Fields(line), " ") -} - -// owingApp is the first conversation's surface with the notice still owed and a -// counter standing where the door's "it was seen" write would be. -func owingApp(t *testing.T, height int) (*app, *int) { - t.Helper() - a := firstChatApp(t) - seen := 0 - a.telemetryNotice = telemetry.Notice - a.telemetryNoticeShown = func() { seen++ } - a.width, a.height = 120, height - a.touch() - return a, &seen -} - -// Contract 2.1 and 2.2: the first conversation's screen carries every line of -// the notice, and the door hears that it was seen only on the loop after a -// frame drew it — never before the first frame, and never twice. -func TestTheFirstConversationShowsTheUsageNoticeBeforeAnythingIsSent(t *testing.T) { - a, seen := owingApp(t, 44) - - pressSetup(a, tea.WindowSizeMsg{Width: 120, Height: 44}) - if *seen != 0 { - t.Fatalf("the notice was counted as seen %d times before any frame drew it", *seen) - } - screen := welcomeScreen(a) - for _, line := range strings.Split(telemetry.Notice, "\n") { - if !strings.Contains(screen, noticeWords(line)) { - t.Fatalf("the first conversation's screen is missing the notice line %q:\n%s", line, screen) - } - } - pressSetup(a, tea.WindowSizeMsg{Width: 120, Height: 44}) - if *seen != 1 { - t.Fatalf("after a frame drew the notice the door heard %d times, want once", *seen) - } - _ = welcomeScreen(a) - pressSetup(a, tea.WindowSizeMsg{Width: 120, Height: 44}) - if *seen != 1 { - t.Fatalf("a second frame told the door again: %d times, want once", *seen) - } -} - -// Contract 2.2: a frame with no room for the whole notice does not draw half of -// it and does not count it as seen, so it is still owed on the next launch. -func TestAFrameWithNoRoomForTheNoticeDoesNotCountItAsSeen(t *testing.T) { - a, seen := owingApp(t, 24) - screen := welcomeScreen(a) - if strings.Contains(screen, noticeWords(strings.Split(telemetry.Notice, "\n")[0])) { - t.Fatalf("the notice was drawn on a frame the test sized too short for it:\n%s", screen) - } - pressSetup(a, tea.WindowSizeMsg{Width: 120, Height: 24}) - if *seen != 0 { - t.Fatalf("a notice no frame drew was counted as seen %d times", *seen) - } -} - -// Contract 2.2: the first-run setup stands in front of the greeting, and a -// notice behind it is not on the screen, so it is not seen either. The options -// are the door's own, handed in the way the door hands them. -func TestTheUsageNoticeBehindTheSetupIsNotCountedAsSeen(t *testing.T) { - a, _, _ := setupApp(t, nil) - seen := 0 - b := newApp(t.Context(), Options{ - Agent: &fakeAgent{model: "openai/gpt-4.1-mini"}, - Workspace: "/tmp/lab", - ProfileDir: a.profileDir, - Setup: true, - ApplyAPIKey: func(string) error { return nil }, - TelemetryNotice: telemetry.Notice, - TelemetryNoticeShown: func() { seen++ }, - }) - b.width, b.height = 120, 44 - b.touch() - if !b.setup.open { - t.Fatal("the fixture's setup is not in front") - } - screen := welcomeScreen(b) - if strings.Contains(screen, noticeWords(strings.Split(telemetry.Notice, "\n")[0])) { - t.Fatalf("the setup screen drew the notice:\n%s", screen) - } - pressSetup(b, tea.WindowSizeMsg{Width: 120, Height: 44}) - if seen != 0 { - t.Fatalf("a notice behind the setup was counted as seen %d times", seen) - } -} diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 1e620c6641..80a39973b4 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -468,19 +468,6 @@ type Options struct { UpdateArgs []string Restart *codeupdate.Plan - // TelemetryNotice is the anonymous usage counts' notice while this install - // still owes it to the person, and empty once it has been seen. The first - // conversation's screen draws it whole, beside the greeting, because the - // notice promises to be read BEFORE any count is sent, and a line printed on - // the normal screen just before this surface covered it was read only after - // quitting, by which time the exit had already sent (docs/TELEMETRY.md). - TelemetryNotice string - // TelemetryNoticeShown is the door's record that the notice was seen. It is - // called once, on the update loop, after a frame has drawn TelemetryNotice — - // never from the frame, which may not touch the disk — and never for a notice - // that no frame drew: a frame too short for it, or a setup standing in front. - TelemetryNoticeShown func() - // Memory is the durable memory store behind the memory place. Nil means the // place is unavailable; the live door passes the same store it gave the // session, wrapped so that the two READING methods are spelled the way this diff --git a/internal/tui3/view.go b/internal/tui3/view.go index 3efe153e4b..7b60c7db29 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -441,12 +441,6 @@ func (a *app) chatFrameLines(width, height int) ([]string, int, int) { } } chrome, chromeMarks, caretX, caretRow := a.chrome(width) - // The chrome just laid out is the frame's own, so what its greeting drew is - // what this frame shows: an owed usage notice among it is noted here, and the - // update loop tells the door ([app.settleTelemetryNotice]). - if a.welcome.noticeDrawn { - a.telemetryNoticeOnFrame = true - } // The welcome box rides at the top of the frame rather than at the bottom // with the chrome it is built with ([welcomeLift] states why). Splitting it // off here keeps [app.frameOut]'s law intact: what is left is still the tail, diff --git a/internal/tui3/welcome.go b/internal/tui3/welcome.go index e673829e85..c3107d8362 100644 --- a/internal/tui3/welcome.go +++ b/internal/tui3/welcome.go @@ -156,10 +156,6 @@ type welcome struct { // move it (see [app.welcomeKey]). sel int recent []Session - // noticeDrawn says the last layout of this unit carried the whole usage - // notice ([app.welcomeNoticeRows]). It is the frame's own note, read by the - // frame that drew it (view.go) and by nothing that only measures. - noticeDrawn bool } func (w *welcome) animating() bool { return w.open && w.step < welcomeFrames } @@ -227,12 +223,6 @@ func (a *app) dismissWelcome() { return } a.welcome = welcome{spent: true} - // THE NOTICE GOES WITH THE GREETING THAT SHOWED IT. Once a frame has drawn it - // and the door has recorded it, the next greeting in this process — a `/new`, - // a second tab — is not owed it again. - if a.telemetryNoticeSettled { - a.telemetryNotice = "" - } a.noteLandingKeys() a.touch() } @@ -369,7 +359,12 @@ const ( // ENTER ON A STARTING POINT FILLS THE BOX AND SENDS NOTHING. That is the whole // contract: the sentence lands in the composer, the caret goes after it, and the // next thing that happens is whatever the person types. An enter with no row -// selected is an ordinary send and is not taken here. +// selected is an ordinary send and is not taken here — AND SO IS AN ENTER WITH +// THE BOX ALREADY FULL. The row stays selected after it fills the box, and an +// earlier build kept taking enter for it: the second enter, the one a person +// presses to send what the first one wrote, filled nothing (the box was not +// empty) and sent nothing either. Four of five new people read that as enter +// not working. Once there are words in the box, enter is the composer's. func (a *app) welcomeStarterKey(name string) (tea.Cmd, bool) { w := &a.welcome switch name { @@ -391,7 +386,7 @@ func (a *app) welcomeStarterKey(name string) (tea.Cmd, bool) { a.touch() return nil, true case "enter": - if w.starter < 0 || w.starter >= len(welcomeStarters) { + if w.starter < 0 || w.starter >= len(welcomeStarters) || !a.input.empty() { return nil, false } a.takeStarter(w.starter) @@ -1100,24 +1095,13 @@ func (a *app) welcomeUnit(width int) ([]string, []welcomeMark, int, int) { add(pal.dim(line), welcomeMark{}) } - // THE USAGE NOTICE, WHOLE OR NOT AT ALL. An install that has not yet shown - // the anonymous usage counts' notice shows it here, dim, under the starting - // points, because this is the first screen a new person reads with the - // surface up, and the notice promises to be read before any count is sent - // (docs/TELEMETRY.md). Half a notice is not a notice, so a frame without the - // room draws none of it, and a notice no frame drew is not counted as seen - // ([app.settleTelemetryNotice]); it is still owed on the next launch. - w.noticeDrawn = false - if notice := a.welcomeNoticeRows(unit); len(notice) > 0 { - spare := a.welcomeRowsLeft() - a.statusHeight(width) - len(rows) - 1 - if len(notice)+1 <= spare { - add("", welcomeMark{}) - for _, line := range notice { - add(pal.dim(line), welcomeMark{}) - } - w.noticeDrawn = true - } - } + // THERE IS NO USAGE NOTICE UNDER THE STARTING POINTS ANY MORE. Until + // 2026-10-01 the first conversation's screen drew the anonymous usage + // counts' six-line notice here, dim, and nothing was sent until a frame had + // drawn it. New people read it as a thing to deal with on a screen that is + // supposed to be three sentences long; the disclosure is README.md and + // docs/TELEMETRY.md in the repository, and the switch is `telemetry` in + // /settings. // THE SESSIONS TAKE ONLY THE ROOM THE WINDOW HAS LEFT. A twelve-row window // with four sessions to list would draw the last of them over the status row; @@ -1138,39 +1122,6 @@ func (a *app) welcomeUnit(width int) ([]string, []welcomeMark, int, int) { return rows, marks, caretX, caretRow } -// welcomeNoticeRows is the owed usage notice as the unit's rows, or nil when -// nothing is owed. A line wider than the unit is wrapped at its own indent -// rather than cut, because every word of the notice is part of what it says. -func (a *app) welcomeNoticeRows(unit int) []string { - if a.telemetryNotice == "" || unit <= 0 { - return nil - } - var rows []string - for _, line := range strings.Split(a.telemetryNotice, "\n") { - if ansi.StringWidth(line) <= unit { - rows = append(rows, line) - continue - } - body := strings.TrimLeft(line, " ") - indent := strings.Repeat(" ", len(line)-len(body)) - for _, part := range wrap(body, max(1, unit-len(indent))) { - rows = append(rows, indent+part) - } - } - return rows -} - -// settleTelemetryNotice tells the door, once, that a frame has drawn the owed -// notice. It runs on the update loop and never inside a frame, because the door -// writes the record to disk and the frame may not touch the disk. -func (a *app) settleTelemetryNotice() { - if !a.telemetryNoticeOnFrame || a.telemetryNoticeSettled || a.telemetryNoticeShown == nil { - return - } - a.telemetryNoticeSettled = true - a.telemetryNoticeShown() -} - // welcomeStarterKeysWord is the line under the three starting points: the two // keys that work on them, and the fact that typing is always an option. It says // `fills the box` on purpose — a person choosing off a list in a terminal expects From 0f511efe9a184050e2823c4b2dc05c8ce02dcace Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:04:31 -0400 Subject: [PATCH 02/26] changes: the entry for #1720 Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/changes/unreleased/1720-onboarding-ux-cleanups.md diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md new file mode 100644 index 0000000000..2f21f909d8 --- /dev/null +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -0,0 +1,20 @@ +--- +kind: changed +title: the first run takes the mouse and a second enter, says nothing about telemetry, and offers only free models to an empty account +pr: 1720 +surface: [chat, docs] +invalidates: + - "On the first conversation's screen, enter on a starting point filled the box and the next enter did nothing — the starting point kept taking it. With words in the box, enter now sends them: ↓ enter enter sends the starting point's sentence." + - "The Models and spending setup screen swallowed every mouse press. A press on a row is now the key that row would take — the limit focuses, the chat model opens its list, a model in the list is taken, the review shows, `Start a conversation` leaves — and the wheel scrolls the open list." + - "Enter on the chat-model row left the focus there after a model was taken, so the next enter reopened the list; enter on an open review folded it away. Both now go on to the next row, so enter alone walks the whole form down to `Start a conversation`." + - "codeaf printed a six-line anonymous-usage-counts notice once per install — on the first conversation's screen under the starting points, or to stderr ahead of `chat --once` and task commands — and sent nothing until a frame or a terminal had shown it. It prints no notice anywhere now and the gate is gone; the disclosure is the README's Telemetry section and docs/TELEMETRY.md, which a test holds to the switches." + - "`codeaf telemetry` (`status`, `info`, `show`, `on`, `off`) existed and `--help` listed it. It does not exist; `codeaf telemetry` is an unknown command. The switches are unchanged: the `telemetry` toggle in /settings, CODEAF_TELEMETRY=off, DO_NOT_TRACK=1, `telemetry = off` in the project file, an empty CODEAF_TELEMETRY_ENDPOINT. tui3.Options no longer has TelemetryNotice or TelemetryNoticeShown, and internal/telemetry no longer has Notice, PrintNotice, NoticeShown or MarkNoticeShown." + - "The setup screen's chat-model list was the whole catalog whatever the account's balance. While the OpenRouter balance reads low ($0.50 or less, the reading behind the low-credits warning), it offers only the `:free` ids and the catalog rows priced at zero, its count line says `free only`, and `Your OpenRouter account is low on credits · the list shows free models only` stands under the field. The model in use stays on the list." +--- + +Santosh watched five new people through the first run on 2026-09-30. Four read +the second enter doing nothing as enter being broken, most read the telemetry +notice as something to deal with, and the ones on an empty OpenRouter account +picked paid models they knew by name from a list that should not have offered +them. The notice's legal job is done by the repository, so it left the product +with the command that explained it. From 85e0acca8eca0c6d254b1ef455add272a4a1fe28 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:04:44 -0400 Subject: [PATCH 03/26] changes: a title that reads as one line Co-Authored-By: Claude Fable 5.1 --- docs/changes/unreleased/1720-onboarding-ux-cleanups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 2f21f909d8..b48ad80907 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -1,6 +1,6 @@ --- kind: changed -title: the first run takes the mouse and a second enter, says nothing about telemetry, and offers only free models to an empty account +title: the first run takes the mouse and a second enter, prints no telemetry notice, and offers an empty account free models pr: 1720 surface: [chat, docs] invalidates: From a228221707fe6101508ea9e9b705600d45edb635 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:05:39 -0400 Subject: [PATCH 04/26] changes: a title the entry check accepts Co-Authored-By: Claude Fable 5.1 --- docs/changes/unreleased/1720-onboarding-ux-cleanups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index b48ad80907..95c5b3484e 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -1,6 +1,6 @@ --- kind: changed -title: the first run takes the mouse and a second enter, prints no telemetry notice, and offers an empty account free models +title: the first run takes clicks and a second enter, shows no telemetry notice, and lists free models only pr: 1720 surface: [chat, docs] invalidates: From 09f413e66066e6ea3ca22a05d35b273ca142f87c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:42:38 -0400 Subject: [PATCH 05/26] chat: an expired OpenRouter key is a reading, and it warns under the message box and on the setup screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A balance read that OpenRouter answered with `API key expired` was a failed read: nothing recorded, nothing said, and the next turn failed with a plain auth error. The 401 that says expired is now a reading of its own (credits.Reading.Expired, kept beside low in credits.json): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands at the right of the keys row in a conversation and on Home for every model the default service serves, free included, and under the chat model on the setup screen, where the list is not cut because the free rows fail on the same key. A turn refused as expired asks for a fresh read, through a fact stamped on the provider's refusal (APIError.KeyExpired) so no surface reads the status for itself. A new key clears it before it is read. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 1 + internal/config/credits.go | 28 ++++++++--- internal/config/credits_test.go | 28 +++++++++++ internal/credits/credits.go | 32 ++++++++++++- internal/credits/credits_test.go | 26 +++++++++++ internal/manual/chat/getting-started.md | 5 +- internal/manual/chat/openrouter-credits.md | 20 ++++++++ internal/manual/chat_test.go | 2 + internal/provider/client.go | 15 ++++++ internal/tui3/app.go | 2 + internal/tui3/credits.go | 34 ++++++++++++-- internal/tui3/credits_test.go | 46 +++++++++++++++++++ internal/tui3/onboarding.go | 21 +++++++-- internal/tui3/onboarding_test.go | 34 ++++++++++++++ 14 files changed, 277 insertions(+), 17 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 95c5b3484e..4483e7b467 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -9,6 +9,7 @@ invalidates: - "Enter on the chat-model row left the focus there after a model was taken, so the next enter reopened the list; enter on an open review folded it away. Both now go on to the next row, so enter alone walks the whole form down to `Start a conversation`." - "codeaf printed a six-line anonymous-usage-counts notice once per install — on the first conversation's screen under the starting points, or to stderr ahead of `chat --once` and task commands — and sent nothing until a frame or a terminal had shown it. It prints no notice anywhere now and the gate is gone; the disclosure is the README's Telemetry section and docs/TELEMETRY.md, which a test holds to the switches." - "`codeaf telemetry` (`status`, `info`, `show`, `on`, `off`) existed and `--help` listed it. It does not exist; `codeaf telemetry` is an unknown command. The switches are unchanged: the `telemetry` toggle in /settings, CODEAF_TELEMETRY=off, DO_NOT_TRACK=1, `telemetry = off` in the project file, an empty CODEAF_TELEMETRY_ENDPOINT. tui3.Options no longer has TelemetryNotice or TelemetryNoticeShown, and internal/telemetry no longer has Notice, PrintNotice, NoticeShown or MarkNoticeShown." + - "A balance read that OpenRouter answered with `API key expired` was a failed read: nothing was recorded and nothing was said, and the turn then failed with a plain auth error. It is now a reading of its own (credits.Reading.Expired, stored beside low in credits.json, config.CreditsExpiredAt): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands under the message box in a conversation and on Home for every OpenRouter model, free included, the setup screen says the same under the chat model, a turn refused as expired starts a fresh read, and the free defaults are not used for it." - "The setup screen's chat-model list was the whole catalog whatever the account's balance. While the OpenRouter balance reads low ($0.50 or less, the reading behind the low-credits warning), it offers only the `:free` ids and the catalog rows priced at zero, its count line says `free only`, and `Your OpenRouter account is low on credits · the list shows free models only` stands under the field. The model in use stays on the list." --- diff --git a/internal/config/credits.go b/internal/config/credits.go index 2b46f4b518..7f837a26bb 100644 --- a/internal/config/credits.go +++ b/internal/config/credits.go @@ -16,10 +16,14 @@ import ( ) type creditsRecord struct { - Low bool `json:"low"` - Known bool `json:"known"` - Key string `json:"key"` - ReadAt time.Time `json:"read_at"` + Low bool `json:"low"` + Known bool `json:"known"` + // Expired says the service refused the key as expired at the last read. A + // record written before the field existed reads as not expired, which is + // the honest answer: nobody asked. + Expired bool `json:"expired,omitempty"` + Key string `json:"key"` + ReadAt time.Time `json:"read_at"` } var creditsMemo = filememo.Stamped(SettingsGeneration, func(_ string, data []byte, missing bool) (creditsRecord, error) { @@ -50,13 +54,22 @@ func CreditsLowAt(profileDir string) bool { return r.Known && r.Low && r.Key == CreditsKeyPrint(APIKeyAt(profileDir)) } -// CreditsNeedRead asks again at launch for a missing, changed, or low record. +// CreditsExpiredAt reads only a known-expired record for the key in force; a +// damaged or missing record, or one about another key, is not expired. +func CreditsExpiredAt(profileDir string) bool { + r := creditsAt(profileDir) + return r.Known && r.Expired && r.Key == CreditsKeyPrint(APIKeyAt(profileDir)) +} + +// CreditsNeedRead asks again at launch for a missing, changed, low or expired +// record. An expired key is read again for the same reason a low one is: the +// warning it carries should stand only as long as the fact does. func CreditsNeedRead(profileDir, key string) bool { if strings.TrimSpace(key) == "" { return false } r := creditsAt(profileDir) - return r.Key != CreditsKeyPrint(key) || r.Low + return r.Key != CreditsKeyPrint(key) || r.Low || r.Expired } // WriteCreditsReading atomically replaces machine state. Dollars and the key @@ -66,7 +79,8 @@ func WriteCreditsReading(profileDir, key string, reading credits.Reading) error if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - value := creditsRecord{Low: reading.Known && reading.Low, Known: reading.Known, Key: CreditsKeyPrint(key), ReadAt: time.Now().UTC()} + value := creditsRecord{Low: reading.Known && reading.Low, Known: reading.Known, Expired: reading.Known && reading.Expired, + Key: CreditsKeyPrint(key), ReadAt: time.Now().UTC()} data, err := json.Marshal(value) if err != nil { return err diff --git a/internal/config/credits_test.go b/internal/config/credits_test.go index 315d200e02..1704e2b786 100644 --- a/internal/config/credits_test.go +++ b/internal/config/credits_test.go @@ -252,3 +252,31 @@ func TestLowCreditsFollowTheEffectiveEnvironmentKey(t *testing.T) { }) } } + +// AN EXPIRED KEY IS RECORDED AS ITS OWN FACT. It is not low — the free defaults +// would fail on the same key, so nothing moves to them — it belongs to the key +// that was read, and it is read again at the next launch. +func TestAnExpiredReadingIsRecordedWithoutMovingTheDefaults(t *testing.T) { + dir := creditsProfile(t) + if err := WriteAPIKey(dir, "key-A"); err != nil { + t.Fatal(err) + } + if err := WriteCreditsReading(dir, "key-A", credits.Reading{Known: true, Expired: true}); err != nil { + t.Fatal(err) + } + if !CreditsExpiredAt(dir) { + t.Fatal("the expired reading was lost") + } + if CreditsLowAt(dir) || ChatDefaultAt(dir) != DefaultModel { + t.Fatal("an expired key moved the defaults to the free models, which fail on the same key") + } + if !CreditsNeedRead(dir, APIKeyAt(dir)) { + t.Fatal("an expired key was not scheduled for another read") + } + if err := WriteAPIKey(dir, "key-B"); err != nil { + t.Fatal(err) + } + if CreditsExpiredAt(dir) { + t.Fatal("key A's expiry was pinned on key B before B was read") + } +} diff --git a/internal/credits/credits.go b/internal/credits/credits.go index 8eda0616b0..05c4ce411d 100644 --- a/internal/credits/credits.go +++ b/internal/credits/credits.go @@ -18,11 +18,23 @@ import ( // roughly $0.04 of input and $0.30 of output at its published limits, rounded up. const LowCreditsUSD = 0.50 -// Reading says only whether the balance is known and below the one threshold. -type Reading struct{ Known, Low bool } +// Reading says whether the balance is known and below the one threshold — or, +// when the service refused the key as expired, that the key itself is the +// answer. An expired reading is Known and never Low: there is no balance to be +// low, and the free defaults would fail on the same key. +type Reading struct{ Known, Low, Expired bool } + +// expiredWord is the one word the service's 401 carries when the key has run +// out rather than never been valid: OpenRouter answers `API key expired.` in the +// body and `error_description="API key expired"` in WWW-Authenticate. Any other +// 401 stays a failed read, because a key that was never accepted is already +// answered by the turn's own auth failure. +const expiredWord = "expired" // Read asks for the account balance and the key's own cap. An absent number is // unknown; a failed request is an error and must not change a previous reading. +// A 401 that says the key has expired is not a failed request: it is a reading, +// and the one fact the account can still tell us. func Read(ctx context.Context, client *http.Client, baseURL, key string) (Reading, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -61,6 +73,9 @@ func Read(ctx context.Context, client *http.Client, baseURL, key string) (Readin return Reading{}, err } } + if keyExpired(keyStatus, keyBody) { + return Reading{Known: true, Expired: true}, nil + } var capValue *float64 switch keyStatus { case http.StatusOK: @@ -81,6 +96,9 @@ func Read(ctx context.Context, client *http.Client, baseURL, key string) (Readin if err != nil { return Reading{}, err } + if keyExpired(creditsStatus, creditsBody) { + return Reading{Known: true, Expired: true}, nil + } var account *float64 switch creditsStatus { case http.StatusOK: @@ -117,6 +135,16 @@ func Read(ctx context.Context, client *http.Client, baseURL, key string) (Readin return Reading{Known: true, Low: remaining <= LowCreditsUSD}, nil } +// keyExpired reports whether an answer is the service refusing the key as +// expired: a 401 whose body says so. +func keyExpired(status int, body []byte) bool { + switch status { + case http.StatusUnauthorized: + return strings.Contains(strings.ToLower(string(body)), expiredWord) + } + return false +} + // Reason names the event that asked for a balance. A launch or changed key is // never debounced; repeated refusals and paid-model switches share a quiet span. type Reason uint8 diff --git a/internal/credits/credits_test.go b/internal/credits/credits_test.go index d696a76b2e..3f413eb160 100644 --- a/internal/credits/credits_test.go +++ b/internal/credits/credits_test.go @@ -135,3 +135,29 @@ func TestTriggerCoalescesAndDebouncesRefusalsWithAFakeClock(t *testing.T) { t.Fatal("a changed key was debounced") } } + +// AN EXPIRED KEY IS A READING, NOT A FAILURE. The service's 401 that says the +// key has expired is the one fact the account can still give, so it comes back +// as a known, expired reading — never low, because there is no balance under +// it — on either of the two routes. A 401 that says nothing about expiry stays +// a failed read (the table above holds that). +func TestAnExpiredKeyIsAKnownReadingOnEitherRoute(t *testing.T) { + const refusal = `{"error":{"message":"API key expired.","code":401}}` + for _, where := range []string{"/api/v1/key", "/api/v1/credits"} { + t.Run(where, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == where { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, refusal) + return + } + fmt.Fprint(w, `{"data":{"limit_remaining":50,"total_credits":0,"total_usage":0}}`) + })) + defer server.Close() + reading, err := Read(context.Background(), server.Client(), server.URL+"/api/v1", "key") + if err != nil || !reading.Known || !reading.Expired || reading.Low { + t.Fatalf("an expired key read as %+v, %v; want known and expired, not low", reading, err) + } + }) + } +} diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index d9994404e2..ac3cecd611 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -198,7 +198,10 @@ priced at zero, its count line says `free only`, and a warning line under the fi `Your OpenRouter account is low on credits · the list shows free models only`. The model you are already on stays on the list whatever it costs, so accepting still confirms. The balance is read right after the key lands, so the cut usually arrives a moment after the screen does; -a top-up is read on the next launch (*OpenRouter credits and free models*). +a top-up is read on the next launch (*OpenRouter credits and free models*). A key OpenRouter +refuses as **expired** is said the same way — `Your OpenRouter key has expired · make a new +one at openrouter.ai/settings/keys and paste it with esc` — but the list is not cut, because +free models fail on an expired key too; `esc` goes back to the connect step to paste a new one. **There is no crew question**, because the crew is three seats — the worker, the planner and the checker — and codeaf picks all three for each task from what kind of work it is, so there diff --git a/internal/manual/chat/openrouter-credits.md b/internal/manual/chat/openrouter-credits.md index 884aad2500..ac2ff4a544 100644 --- a/internal/manual/chat/openrouter-credits.md +++ b/internal/manual/chat/openrouter-credits.md @@ -70,6 +70,26 @@ sent a message keeps its model either way. After a top-up, the next new conversation opens on the usual default, the helper rows return to their usual models, and the crew is routed over paid routes again. +## My OpenRouter key has expired — the warning and what to do + +OpenRouter keys can carry an expiry date, and an expired key is refused on every model, +the free ones included. codeaf learns it from the same balance read it makes for a low +account: when OpenRouter answers that read with `API key expired`, codeaf records the +key as expired instead of treating the read as failed, and +`Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` +appears at the right of the keys row under a conversation's or Home's message box, in the +warning colour. Unlike the low-credits line it shows on **every** model the default +OpenRouter service serves — free or paid — and never on a model served by Codex, a local +model or another connected service. It wins over the low-credits line, and the free +defaults are **not** used, because they would fail on the same key. A turn refused with +`API key expired` mid-session starts a fresh read, so the warning arrives without a +relaunch. On the first-run setup screen the same fact stands under the chat model: +`Your OpenRouter key has expired · make a new one at openrouter.ai/settings/keys and +paste it with esc`, and the list is not cut to free models. The fix is a new key at +https://openrouter.ai/settings/keys, pasted on the setup's connect step (`esc` goes back +to it), in `/settings`, or in `/connect`; the old key's warning goes the moment the new +key is saved, before it has even been read. + ## Low on credits warning under the message box `Your OpenRouter account is low on credits — some models may not be available` diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 6dad4d31be..578e1b5168 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -128,6 +128,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"why is my model a free one", "openrouter-credits"}, {"low on credits warning", "openrouter-credits"}, {"setup only shows free models", "openrouter-credits"}, + {"my openrouter key has expired", "openrouter-credits"}, + {"api key expired warning", "openrouter-credits"}, // The anonymous usage counts, asked the two ways people ask: whether // anything is collected, and how to stop it. {"does codeaf collect data about me", "running-from-the-terminal"}, diff --git a/internal/provider/client.go b/internal/provider/client.go index bcddd3087f..29149db424 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -2723,6 +2723,21 @@ func (e *APIError) AccountCannotPay() bool { return e != nil && paymentrefusal.Matches(e.Status, []byte(e.Body)) } +// KeyExpired reports the service refusing the key as expired: a 401 whose +// sentence says so (OpenRouter answers `API key expired.`). It is a fact read +// off the wire, stamped here so no caller reads the status for itself; the +// balance read spells the same fact off its own routes (internal/credits). +func (e *APIError) KeyExpired() bool { + if e == nil { + return false + } + switch e.Status { + case http.StatusUnauthorized: + return strings.Contains(strings.ToLower(e.Message+" "+e.Body), "expired") + } + return false +} + // RefusalFrom recovers the provider's refusal from anywhere in an error chain, // which is how a caller several wraps away asks the two questions above rather // than grepping the sentence. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 72c11f9d3c..59596fe72c 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -967,6 +967,7 @@ type app struct { creditRecordPending atomic.Bool creditTrigger *credits.Trigger creditsLow bool + creditsExpired bool implicitTalk bool creditSwitching bool chatCreditWarning string @@ -3034,6 +3035,7 @@ func newApp(ctx context.Context, opts Options) *app { a.creditTrigger = credits.NewTrigger(time.Now) a.creditWake = newDoorbell(creditWakeMsg{}) a.creditsLow = config.CreditsLowAt(a.profileDir) + a.creditsExpired = config.CreditsExpiredAt(a.profileDir) if a.paymentRefusals != nil { a.creditHookStop = a.paymentRefusals(func() { a.creditRecordPending.Store(true); a.creditWake.ring() }) } diff --git a/internal/tui3/credits.go b/internal/tui3/credits.go index ffa276e6f7..f203245188 100644 --- a/internal/tui3/credits.go +++ b/internal/tui3/credits.go @@ -17,6 +17,12 @@ import ( const lowCreditsWarning = "Your OpenRouter account is low on credits — some models may not be available" +// expiredKeyWarning is the same row's line when the service has refused the key +// as expired. It names the fix, because unlike a low balance there is nothing +// to wait for: every OpenRouter model, the free ones included, fails on this +// key until a new one is pasted. +const expiredKeyWarning = "Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys" + type creditWakeMsg struct{} type creditReadMsg struct { reading credits.Reading @@ -128,8 +134,12 @@ func (a *app) tookCredits(msg creditReadMsg) tea.Cmd { // the ninth row of the whole catalog would be on the ninth row of a // much shorter list — or past its end. The filter pass puts the cursor // back on the model in use, which is where it opened. - if a.setup.open && a.setup.modelOpen { - a.filterSetupModels(a.setup.modelFind) + if a.setup.open { + if a.setup.modelOpen { + a.filterSetupModels(a.setup.modelFind) + } + // And the screen is redrawn either way: the line under the chat + // model says what the reading found, list open or not. a.touch() } } @@ -141,6 +151,7 @@ func (a *app) tookCredits(msg creditReadMsg) tea.Cmd { func (a *app) refreshCreditWarnings() { if a.readCredits != nil { a.creditsLow = config.CreditsLowAt(a.profileDir) + a.creditsExpired = config.CreditsExpiredAt(a.profileDir) } // AN UNTOUCHED CONVERSATION FOLLOWS THE DEFAULT, BOTH WAYS. One that has sent // nothing, on the build's own default, with no model chosen anywhere, is not @@ -159,7 +170,22 @@ func (a *app) refreshCreditWarnings() { a.creditSwitching = false } a.chatCreditWarning, a.homeCreditWarning = "", "" - if a.readCredits == nil || !a.creditsLow { + if a.readCredits == nil { + return + } + if a.creditsExpired { + // AN EXPIRED KEY WARNS ON EVERY MODEL THE DEFAULT SERVICE SERVES, free + // or paid, because the key fails them all; a model another service + // serves is untouched and says nothing. + if !a.modelIsDirect(a.model) { + a.chatCreditWarning = expiredKeyWarning + } + if !a.modelIsDirect(a.targetModel()) { + a.homeCreditWarning = expiredKeyWarning + } + return + } + if !a.creditsLow { return } var models []Model @@ -183,7 +209,7 @@ func (a *app) creditRefusalEnded(err error) bool { if err == nil || a.readCredits == nil || a.modelIsDirect(a.model) { return false } - if refusal, ok := provider.RefusalFrom(err); ok && refusal.AccountCannotPay() { + if refusal, ok := provider.RefusalFrom(err); ok && (refusal.AccountCannotPay() || refusal.KeyExpired()) { return true } prefix := config.ConnectionOutcomeWord(modelsource.DefaultSource("").Name, modelsource.Outcome{Kind: modelsource.OutcomeAccountCannotPay}) diff --git a/internal/tui3/credits_test.go b/internal/tui3/credits_test.go index c97e44adc4..47a1d7e760 100644 --- a/internal/tui3/credits_test.go +++ b/internal/tui3/credits_test.go @@ -365,3 +365,49 @@ func TestAHealthyReadMovesAnUntouchedImplicitConversationBackToTheDefault(t *tes }) } } + +// seedExpiredKey is seedLowCredits for a key the service refused as expired. +func seedExpiredKey(t *testing.T, a *app) { + t.Helper() + if err := config.WriteAPIKey(a.profileDir, "credit-test-key"); err != nil { + t.Fatal(err) + } + if err := config.WriteCreditsReading(a.profileDir, "credit-test-key", credits.Reading{Known: true, Expired: true}); err != nil { + t.Fatal(err) + } +} + +// AN EXPIRED KEY WARNS ON EVERY OPENROUTER MODEL, the free default included, +// because the key fails them all — and says nothing on a model another service +// serves. It wins over the low-credits line, and a new key clears it before +// that key is read. +func TestAnExpiredKeyWarnsOnEveryOpenRouterModel(t *testing.T) { + a := placeApp(t) + a.width = 200 + a.readCredits = func(context.Context) (credits.Reading, error) { + return credits.Reading{Known: true, Expired: true}, nil + } + seedExpiredKey(t, a) + a.refreshCreditWarnings() + if !a.creditsExpired || a.creditsLow { + t.Fatalf("the expired record read as expired=%v low=%v", a.creditsExpired, a.creditsLow) + } + if !strings.Contains(plain(a.hintRow(200)), expiredKeyWarning) || !strings.Contains(placeFrameText(a), expiredKeyWarning) { + t.Fatal("conversation and home boxes did not show the expired-key warning") + } + a.switchModel(config.FreeChatModel, 0) + if !strings.Contains(plain(a.hintRow(200)), expiredKeyWarning) { + t.Fatal("the free model hid the expired-key warning, though the key fails it too") + } + if strings.Contains(plain(a.hintRow(200)), lowCreditsWarning) { + t.Fatal("the low-credits line was drawn beside the expired-key one") + } + // A new key is a different fact, not yet read: nothing is said about it. + if err := config.WriteAPIKey(a.profileDir, "another-key"); err != nil { + t.Fatal(err) + } + a.refreshCreditWarnings() + if a.creditsExpired || a.chatCreditWarning != "" { + t.Fatalf("the old key's expiry was pinned on the new key (expired=%v, warning %q)", a.creditsExpired, a.chatCreditWarning) + } +} diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index df8f40fabe..11d2c89e5e 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -702,7 +702,15 @@ func (a *app) setupModelChoices() []Model { // at seed time because the balance is read AFTER the key lands, on its own // goroutine, and usually answers while this screen is already up. func (a *app) setupFreeOnly() bool { - return a.readCredits != nil && a.creditsLow + return a.readCredits != nil && a.creditsLow && !a.creditsExpired +} + +// setupKeyExpired reports whether the default provider refused the key as +// expired at the last read. The list is NOT cut for it — the free rows fail on +// the same key — but the line under the field says what is wrong and where the +// fix is. +func (a *app) setupKeyExpired() bool { + return a.readCredits != nil && a.creditsExpired } // setupModelSlots is how many models the list SHOWS AT ONCE, and it is a height @@ -1514,6 +1522,7 @@ func (a *app) setupModelCountWord(count int) string { const ( setupFreeOnlyWord = "free only" setupLowCreditsWord = "Your OpenRouter account is low on credits · the list shows free models only" + setupExpiredKeyWord = "Your OpenRouter key has expired · make a new one at openrouter.ai/settings/keys and paste it with esc" ) // setupLowCreditsRows is the line under the chat model while the account reads @@ -1521,10 +1530,16 @@ const ( // is the reason the list is short — and nothing, the emptiness law, when the // account does not read low. func (a *app) setupLowCreditsRows(width int) []string { - if !a.setupFreeOnly() { + word := "" + switch { + case a.setupKeyExpired(): + word = setupExpiredKeyWord + case a.setupFreeOnly(): + word = setupLowCreditsWord + default: return nil } - lines := wrap(setupLowCreditsWord, max(width-4, 1)) + lines := wrap(word, max(width-4, 1)) for i, line := range lines { lines[i] = strings.Repeat(" ", 4) + a.pal.warn(line) } diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 9b6ae0cbe9..cbc9f5297e 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -1057,3 +1057,37 @@ func TestEnterAfterAStartingPointSendsWhatItFilled(t *testing.T) { t.Fatalf("the second enter sent %v, want the starting point's sentence", agent.sent) } } + +// AN EXPIRED KEY DOES NOT CUT THE LIST — the free rows fail on the same key — +// but the line under the field says what is wrong and where the fix is. +func TestAnExpiredKeyIsSaidUnderTheModelRowWithoutCuttingTheList(t *testing.T) { + a, dir := controlsApp(t, nil) + catalog := []Model{ + {ID: "openai/gpt-4.1-mini", PriceKnown: true, PromptPrice: 0.4, CompletionPrice: 1.6}, + {ID: "paid/alpha", PriceKnown: true, PromptPrice: 1, CompletionPrice: 2}, + {ID: "qwen/qwen3.8-27b:free", PriceKnown: true}, + } + a.models = func() []Model { return catalog } + a.readCredits = func(context.Context) (credits.Reading, error) { + return credits.Reading{Known: true, Expired: true}, nil + } + if err := config.WriteCreditsReading(dir, config.APIKeyAt(dir), credits.Reading{Known: true, Expired: true}); err != nil { + t.Fatal(err) + } + pressSetup(a, creditReadMsg{reading: credits.Reading{Known: true, Expired: true}}) + if !a.creditsExpired || a.setupFreeOnly() { + t.Fatalf("expired=%v freeOnly=%v, want expired and the whole list", a.creditsExpired, a.setupFreeOnly()) + } + if got := len(a.setupModelChoices()); got != len(catalog) { + t.Fatalf("an expired key cut the list to %d rows, want the catalog's %d", got, len(catalog)) + } + a.width = 100 + a.touch() + screen := setupScreen(a) + if !strings.Contains(screen, setupExpiredKeyWord) { + t.Fatalf("the screen must say %q; got:\n%s", setupExpiredKeyWord, screen) + } + if strings.Contains(screen, "free only") || strings.Contains(screen, setupLowCreditsWord) { + t.Fatalf("an expired key was spelled as a low account:\n%s", screen) + } +} From d1fd6cae02bab71501392b91656eabfe414b8b04 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:24:08 -0400 Subject: [PATCH 06/26] chat: the setup's example panel stands under the form, as wide as the window allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example was a second column beside the form, 36 cells wide, drawn from 112 columns up and level with the first field: on a tall window the lower half of the screen stayed empty while the panel wrapped every sentence three ways. It is one column now — the form, its keys line, one blank row, then the panel at up to 92 cells, whole or not at all. A window with no rows to spare under the form draws the form alone and the keys line stops naming the arrows. The form is 64 cells on every window. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 1 + internal/manual/chat/getting-started.md | 18 +- internal/tui3/firstrun.go | 3 + internal/tui3/onboarding.go | 165 ++++++++---------- internal/tui3/onboarding_test.go | 80 +++++++-- 5 files changed, 154 insertions(+), 113 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 4483e7b467..355bb29b38 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -10,6 +10,7 @@ invalidates: - "codeaf printed a six-line anonymous-usage-counts notice once per install — on the first conversation's screen under the starting points, or to stderr ahead of `chat --once` and task commands — and sent nothing until a frame or a terminal had shown it. It prints no notice anywhere now and the gate is gone; the disclosure is the README's Telemetry section and docs/TELEMETRY.md, which a test holds to the switches." - "`codeaf telemetry` (`status`, `info`, `show`, `on`, `off`) existed and `--help` listed it. It does not exist; `codeaf telemetry` is an unknown command. The switches are unchanged: the `telemetry` toggle in /settings, CODEAF_TELEMETRY=off, DO_NOT_TRACK=1, `telemetry = off` in the project file, an empty CODEAF_TELEMETRY_ENDPOINT. tui3.Options no longer has TelemetryNotice or TelemetryNoticeShown, and internal/telemetry no longer has Notice, PrintNotice, NoticeShown or MarkNoticeShown." - "A balance read that OpenRouter answered with `API key expired` was a failed read: nothing was recorded and nothing was said, and the turn then failed with a plain auth error. It is now a reading of its own (credits.Reading.Expired, stored beside low in credits.json, config.CreditsExpiredAt): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands under the message box in a conversation and on Home for every OpenRouter model, free included, the setup screen says the same under the chat model, a turn refused as expired starts a fresh read, and the free defaults are not used for it." + - "The setup screen's example panel (`○ Example · what you can do`) was a second column to the right of the form, 36 cells wide, drawn only from 112 columns up and level with the first field; on a tall window the lower half of the screen stayed empty while the panel wrapped every sentence. It now stands under the form and its keys line, one blank row below, as wide as the window allows up to 92 cells, whole or not at all: a window with no rows to spare under the form (24 rows) draws the form alone, and the keys line names `←→ examples` only when the panel is drawn. The form itself is 64 cells wide on every window (it was 54 beside the panel), and setupWideCols, setupFormWidth=54, setupShowGap and setupShowWidth are gone." - "The setup screen's chat-model list was the whole catalog whatever the account's balance. While the OpenRouter balance reads low ($0.50 or less, the reading behind the low-credits warning), it offers only the `:free` ids and the catalog rows priced at zero, its count line says `free only`, and `Your OpenRouter account is low on credits · the list shows free models only` stands under the field. The model in use stays on the list." --- diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index ac3cecd611..cd097f77d7 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -216,13 +216,17 @@ If a **task model** is pinned (`task.model`), one dim line under the chat model `Tasks are pinned to … · /settings changes that` — because that pin decides the worker seat, and a screen that did not mention it would be hiding where tasks run. -At **112 columns and wider** a bordered panel stands beside these rows, labelled -`○ Example · what you can do` and footed `An illustration. Nothing here has run.` — the -only bordered surface codeaf draws, so it cannot be read as more form. It holds one -request you could type and what it leads to, and follows the row you are on: beside the -review row it shows `/task Fix the failing tests and explain the changes.` That request **types -itself out once** on arriving and on `←`/`→`, then settles; typing settles it at once. -Under 112 columns it is not drawn and the form is unchanged. +**Under the form**, below the keys line, a bordered panel labelled +`○ Example · what you can do` and footed `An illustration. Nothing here has run.` stands +on any window with the rows to hold the whole of it — the only bordered surface codeaf +draws, so it cannot be read as more form. It is as wide as the screen allows, up to 92 +columns, so the request in it stands on one row. It holds one request you could type and +what it leads to, and follows the row you are on: on the review row it shows `/task Fix the +failing tests and explain the changes.` That request **types itself out once** on arriving +and on `←`/`→`, then settles; typing settles it at once. On a window too short to hold the +form and the whole panel — 24 rows, say — the panel is not drawn and the keys line does not +name the arrows; the form is unchanged. (Until 2026-10-01 the panel was a second column to +the right of the form, drawn only from 112 columns up.) The controls screen shows **once, ever**. The default OpenRouter prerequisite above is the only step that may return. diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index 12f262ae50..b5ff50729d 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -123,6 +123,9 @@ type setupFlow struct { // (onboarding.go's [setupDoors]): a press is answered against the rows that // are on the screen, and nothing else. doors setupDoors + // exampleShown says the last frame drew the example panel under the form, + // which is what decides whether the legend names the arrows that browse it. + exampleShown bool // auth is the default provider's browser trip. Starting covers the short // interval before its listener is handed back; flow and link cover the wait // after that. id names the attempt so a late answer after esc is dropped. diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 11d2c89e5e..117d478a44 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -54,11 +54,11 @@ import ( // - IT SPENDS NOTHING. Opening a model list reads the catalog this process // already has; nothing on this screen sends a prompt or calls a model. // -// The example on the right is labelled as an example. It is one request somebody -// could make and the kind of result it leads to, and it moves only when the -// person moves — a deliberate focus change or the arrow keys, never a timer and -// never a keystroke inside a field. No invented cost, no fabricated activity, no -// claim that anything has already run. +// The example under the form is labelled as an example. It is one request +// somebody could make and the kind of result it leads to, and it moves only when +// the person moves — a deliberate focus change or the arrow keys, never a timer +// and never a keystroke inside a field. No invented cost, no fabricated +// activity, no claim that anything has already run. // setupControl is one row of the controls screen, in the order tab walks them. // The day's limit comes first because it is the consequential one; the two model @@ -940,22 +940,26 @@ func titleWord(word string) string { // ── the drawing ───────────────────────────────────────────────────────────── -// The composition, in cells. At 120×24 it is a 54-cell form, an eight-cell gap -// and a 36-cell example, centred as one 98-cell object with eleven cells of -// margin either side. Under [setupWideCols] the example is not drawn at all — -// stacking it under the form would put promotional content between a person and -// the thing they came here to do. +// The composition, in cells: ONE COLUMN. The form stands at the top, the legend +// under it, and the example panel under that, at the composition's full width, +// so the request and the three lines it leads to each stand on one row instead +// of wrapping inside a thirty-six-cell box. Until 2026-10-01 the example was a +// second column to the right of the form, drawn only from 112 columns up and +// level with the first field; on a tall window that left the whole lower half +// of the screen empty while the panel squeezed its sentences three ways. The +// panel now takes the rows the form leaves, WHOLE OR NOT AT ALL — a window with +// no rows to spare under the form draws the form alone, which is also what keeps +// the panel from ever standing between a person and `Start a conversation`. const ( - setupFormWidth = 54 - setupShowGap = 8 - setupShowWidth = 36 - // setupWideCols is the width at which the pair fits with the three-cell - // margins the design asks for, rounded to the round number the design study - // states. - setupWideCols = 112 - // setupNarrowForm is the form's width when it stands alone, which is a - // comfortable measure for a sentence and no wider. - setupNarrowForm = 64 + // setupFormWidth is the form's width, a comfortable measure for a sentence + // and no wider. + setupFormWidth = 64 + // setupShowcaseWidth is the example panel's width on a window that has it — + // the old pair's own width, which the two columns used to share — and + // setupShowcaseMinWidth is the least a panel is drawn at, which is the width + // the design gave the old column. + setupShowcaseWidth = 92 + setupShowcaseMinWidth = 36 // setupMargin is the least the composition is ever inset from the frame. setupMargin = 3 ) @@ -969,17 +973,20 @@ const ( // floated up and down as its rows opened and closed would move the thing a person // is aiming at every time they pressed a key. func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { - wide := width >= setupWideCols - form := min(width-2*setupMargin, setupNarrowForm) - pair := form - if wide { - form = setupFormWidth - pair = setupFormWidth + setupShowGap + setupShowWidth - } + s := &a.setup + form := min(width-2*setupMargin, setupFormWidth) if form < 20 { form = max(width-2, 1) - pair = form } + show := min(width-2*setupMargin, setupShowcaseWidth) + if show < setupShowcaseMinWidth { + show = 0 + } + // The composition is centred on the wider of the two, and the form keeps to + // its own measure inside it: a sentence set ninety cells wide is a sentence + // nobody reads to the end, where a panel that wide is a panel whose lines + // do not wrap. + pair := max(form, show) lead := max((width-pair)/2, 0) pad := strings.Repeat(" ", lead) @@ -1000,38 +1007,42 @@ func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { // THE DOORS ARE KEPT WITH THE FRAME THAT DREW THEM, so a press reads the // rows that are actually on the screen: body row i is frame row i+2, under // the header and the blank, and it spans the form's own columns. - a.setup.doors = setupDoors{rows: doors, top: 2, left: lead, width: form} + s.doors = setupDoors{rows: doors, top: 2, left: lead, width: form} - var show []string - if wide { - show = a.setupShowcase(setupShowWidth, len(body)) + // THE EXAMPLE TAKES THE ROWS THE FORM LEAVES, and only the whole of it. + // Four rows are spoken for around the form — the header, the blank under + // it, the legend, and the blank between the legend and the panel — and a + // panel that does not fit whole in what is left is not drawn: a cut panel + // is a panel without the line at its foot that says nothing in it has run. + // The decision is made before the legend is written, because the legend + // names the arrows only when there is a panel for them to browse. + var block []string + if show > 0 { + block = a.setupShowcaseBlock(show, height-2-len(body)-2) } + s.exampleShown = len(block) > 0 lines := make([]string, 0, height) lines = append(lines, pad+head, "") - for i, line := range body { - row := pad + line - if i < len(show) && show[i] != "" { - // The example is placed at a fixed column so its own left edge is - // straight down the screen: a column whose rows started wherever the - // form's row happened to end would not read as a column at all. - row = pad + padTo(line, setupFormWidth+setupShowGap) + show[i] - } - lines = append(lines, row) - } - // THE KEYBOARD GUIDANCE FOLLOWS THE FORM WITH ONE BLANK ROW UNDER IT, and the - // window's own empty rows fall below that. It is measured against the WHOLE - // composition rather than the form, because it is the only row the example - // column never stands beside — budgeting it at the form's fifty-four cells cut - // `←→ examples` off the one screen the arrows exist on - // ([app.setupControlsKeys] then cuts by whole clauses, never mid-word). - // - // It is not pinned to the last row of the window. A legend nailed to the foot - // of a twenty-four-row frame under an eighteen-row form leaves a hole in the - // middle of the composition, and a hole reads as a screen that stopped. + for _, line := range body { + lines = append(lines, pad+line) + } + // THE KEYBOARD GUIDANCE FOLLOWS THE FORM, and the panel follows that with + // one blank row between them. The legend is measured against the whole + // composition rather than the form ([app.setupControlsKeys] then cuts by + // whole clauses, never mid-word). It is not pinned to the last row of the + // window: a legend nailed to the foot of a frame under a short form leaves a + // hole in the middle of the composition, and a hole reads as a screen that + // stopped. if len(lines) < height { lines = append(lines, pad+a.pal.dim(a.setupControlsKeys(pair))) } + if len(block) > 0 { + lines = append(lines, "") + for _, line := range block { + lines = append(lines, pad+line) + } + } for len(lines) < height { lines = append(lines, "") } @@ -1694,7 +1705,9 @@ func (a *app) setupControlsKeys(width int) string { if s.control <= controlChatModel { parts = append(parts, "? detail") } - if w, _ := a.size(); w >= setupWideCols { + // The arrows are named only on a frame that drew the panel they browse + // ([app.setupControlsFrame] decides that before it writes this row). + if s.exampleShown { parts = append(parts, "←→ examples") } } @@ -1771,22 +1784,14 @@ const ( showcaseCaretASCII = "_" ) -// setupShowcase draws the right-hand column: a framed panel holding one example -// request and what it leads to, with where in the four it is on the bottom edge. -// -// It is drawn LOWER CONTRAST than the form beside it — nothing in it is at ink -// weight except the request being typed — because it is an illustration standing -// beside the thing a person came here to do, and the active control on the left -// is the anchor. -// -// The panel is a FIXED HEIGHT for a given example: the rows the leads will land -// in are drawn empty before they arrive, so the frame that is on screen at the -// first beat is the same size as the frame at the last. A box that grew three -// rows while somebody was reading the form would be motion in the corner of -// their eye that means nothing. -func (a *app) setupShowcase(width, height int) []string { +// setupShowcaseBlock is the example panel as a framed block at the given width, +// or nil when the window cannot hold the whole of it in maxHeight rows. It is +// whole or nothing: the line at its foot — that nothing in it has run — is the +// sentence that keeps the panel from being read as a report about this machine, +// and a panel trimmed from the bottom would lose exactly that line first. +func (a *app) setupShowcaseBlock(width, maxHeight int) []string { pal := a.pal - if len(setupExamples) == 0 || height <= 0 || width < 20 { + if len(setupExamples) == 0 || width < setupShowcaseMinWidth { return nil } at := clampIndex(a.setup.example, len(setupExamples)) @@ -1794,17 +1799,9 @@ func (a *app) setupShowcase(width, height int) []string { inner := frameInner(width) - 2 // one cell of air inside each edge body := a.showcaseBody(example, inner) - // The frame comes off the top of what the window has left, whole rows at a - // time, and the leads go before the title does: a panel with no title is - // still labelled by its own top edge, where a panel with no request is a - // panel about nothing. - for len(body)+2 > height && len(body) > 0 { - body = body[:len(body)-1] - } - if len(body)+2 > height { + if len(body)+2 > maxHeight { return nil } - rows := make([]string, 0, len(body)) for _, line := range body { rows = append(rows, " "+padTo(line, inner)+" ") @@ -1813,19 +1810,7 @@ func (a *app) setupShowcase(width, height int) []string { title: pal.dim(showcaseTitle(pal)), keysAside: pal.dim(setupShowcaseCount(at, len(setupExamples))), }.draw(pal, width, rows) - - // showcaseTop is where the panel begins: level with the first field, which is - // three rows into the form (heading, lead, blank). A short window has dropped - // those, and the panel comes up with them rather than floating. - showcaseTop := min(3, max(height-len(block), 0)) - out := make([]string, height) - for i, line := range block { - if showcaseTop+i >= height { - break - } - out[showcaseTop+i] = line - } - return out + return block } // showcaseBody is what stands inside the frame, at the inner width, with the diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index cbc9f5297e..5f90858e66 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -27,7 +27,9 @@ func controlsApp(t *testing.T, seed func(dir string)) (*app, string) { t.Helper() a, dir, _ := setupApp(t, seed) a.pal = newPalette(tokens.ANSI256, false) - a.width, a.height = 120, 24 + // Tall enough for the form AND the example panel under it; the tests about + // a short window size the frame themselves. + a.width, a.height = 120, 44 pressSetup(a, key("enter")) if !a.setup.open || a.setup.step() != setupControls { t.Fatalf("the fixture is not on the controls screen (open=%v)", a.setup.open) @@ -405,12 +407,10 @@ func rawSetupFrame(a *app) string { } // showcasePanel is the example panel cut out of the frame, one row per line and -// each row the panel's own columns and nothing else. -// -// THE TWO COLUMNS SHARE EVERY ROW, so a claim about what the panel says cannot -// be made against the frame: the form's own sentence runs into it on the left of -// every line. Cutting the panel out first is what makes "the panel says X" a -// question with an answer. +// each row the panel's own columns and nothing else — the columns its own top +// edge spans, from its top-left corner to its top-right one. Cutting the panel +// out first is what makes "the panel says X" a question with an answer, and +// what makes a claim about its frame a claim about a box. func showcasePanel(a *app) []string { box := framePiecesOf(a.pal) rows := strings.Split(rawSetupFrame(a), "\n") @@ -430,12 +430,14 @@ func showcasePanel(a *app) []string { // them, so the byte offset the search answered becomes a rune offset before // any row is cut with it. left = len([]rune(rows[top][:left])) + topRunes := []rune(strings.TrimRight(rows[top], " ")) + width := len(topRunes) - left cut := func(row string) string { runes := []rune(row) if left >= len(runes) { return "" } - return string(runes[left:min(left+setupShowWidth, len(runes))]) + return string(runes[left:min(left+width, len(runes))]) } // THE TOP EDGE IS TAKEN BEFORE THE LOOP AND THE LOOP ENDS ON THE BOTTOM ONE. // In the ascii tier every corner is the same `+`, so a walk that stopped at @@ -522,11 +524,57 @@ func TestTheExampleColumnIsLabelledFollowsTheFocusAndHidesWhenNarrow(t *testing. if a.setup.limitText != limit { t.Fatal("browsing the examples changed a control") } - // And below the width the pair needs, the column is gone entirely. - a.width, a.height = setupWideCols-1, 24 - narrow, _, _ := a.frame() - if strings.Contains(plain(narrow), showcaseTitleWord) { - t.Fatalf("the example panel was drawn under %d columns:\n%s", setupWideCols, plain(narrow)) + // And on a window with no rows to spare under the form, the panel is gone + // entirely — never cut, because its last line is the one that says nothing + // in it has run. + a.width, a.height = 120, 24 + short, _, _ := a.frame() + if strings.Contains(plain(short), showcaseTitleWord) { + t.Fatalf("the example panel was drawn on a 24-row window with no room for the whole of it:\n%s", plain(short)) + } + if strings.Contains(plain(short), "←→ examples") { + t.Fatalf("the legend names the arrows on a frame with no panel:\n%s", plain(short)) + } +} + +// THE PANEL STANDS UNDER THE FORM, AT THE COMPOSITION'S WIDTH. It used to be a +// second column beside the form, drawn from 112 columns up; on a tall window +// that left the lower half of the screen empty while the panel wrapped every +// sentence three ways. Now it follows the legend, one blank row under it, and +// its top edge is as wide as the composition, so the request stands on one row. +func TestTheExamplePanelStandsUnderTheFormAtTheCompositionsWidth(t *testing.T) { + a, _ := controlsApp(t, nil) + a.settleSetupDemo() + rows := strings.Split(rawSetupFrame(a), "\n") + legend, top := -1, -1 + for i, row := range rows { + if strings.Contains(row, "tab moves") { + legend = i + } + if strings.Contains(row, showcaseTitleWord) { + top = i + } + } + if legend < 0 || top < 0 { + t.Fatalf("no legend (%d) or no panel (%d) on the frame:\n%s", legend, top, rawSetupFrame(a)) + } + if top != legend+2 { + t.Fatalf("the panel's top edge is on row %d and the legend on row %d; want the panel two rows under the legend", top, legend) + } + panel := showcasePanel(a) + if got := len([]rune(panel[0])); got != setupShowcaseWidth { + t.Fatalf("the panel is %d cells wide, want the composition's %d:\n%s", got, setupShowcaseWidth, panel[0]) + } + // The request stands on one row inside it, which is what the width is for. + ask := setupExamples[a.setup.example].ask + found := false + for _, row := range panel { + if strings.Contains(row, ask) { + found = true + } + } + if !found { + t.Fatalf("the request %q wrapped inside a %d-cell panel:\n%s", ask, setupShowcaseWidth, strings.Join(panel, "\n")) } } @@ -562,9 +610,9 @@ func TestTheExamplePanelIsFramedAndFallsBackToAscii(t *testing.T) { // EVERY ROW IS THE SAME WIDTH, which is the difference between a box and // four characters that happen to be near each other. for _, row := range panel { - if got := len([]rune(row)); got != setupShowWidth { + if got := len([]rune(row)); got != setupShowcaseWidth { t.Fatalf("ascii=%v: a panel row is %d cells, want %d: %q", - ascii, got, setupShowWidth, row) + ascii, got, setupShowcaseWidth, row) } } } @@ -924,7 +972,7 @@ func TestAClickOnTheControlsScreenActsLikeTheKeyOnThatRow(t *testing.T) { t.Fatalf("a press on the limit row left the focus on %v", a.setup.control) } // A press on a sentence does nothing. - x, y = setupRowOf(t, a, "new work waits until midnight") + x, y = setupRowOf(t, a, "waits until midnight") pressSetup(a, clickAt(x, y)) if a.setup.control != controlLimit || a.setup.modelOpen { t.Fatalf("a press on a sentence changed the focus to %v (list open=%v)", a.setup.control, a.setup.modelOpen) From 8db0ded38b1a79c7ed6f556b8e62dedc7919a01e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:54:27 -0400 Subject: [PATCH 07/26] chat: the setup's example stands above the legend and form, carries its title on its edge, and shows a /senior-dev hand-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the form, the legend stood between the form and the panel and read as a caption for the wrong one. The order is now the panel, two blank rows, the keys line, and the form directly under it. The panel's top edge reads `○ Example · ` and the title is no longer a row of the body. A fifth example, `Hand off complex coding tasks`, asks `/senior-dev Add retries with backoff to the HTTP client, with tests.` beside `Start a conversation`, and a command in any example's request wears the composer's chip. The keys line says `enter sets the limit` on the limit row and `↑↓ moves` in place of `tab moves` everywhere; the tmux suite's needle follows it. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 3 +- internal/e2e/tmux_test.go | 4 +- internal/manual/chat/getting-started.md | 33 ++-- internal/tui3/onboarding.go | 144 ++++++++++++------ internal/tui3/onboarding_test.go | 98 +++++++++--- 5 files changed, 197 insertions(+), 85 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 355bb29b38..7b774c1e63 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -10,7 +10,8 @@ invalidates: - "codeaf printed a six-line anonymous-usage-counts notice once per install — on the first conversation's screen under the starting points, or to stderr ahead of `chat --once` and task commands — and sent nothing until a frame or a terminal had shown it. It prints no notice anywhere now and the gate is gone; the disclosure is the README's Telemetry section and docs/TELEMETRY.md, which a test holds to the switches." - "`codeaf telemetry` (`status`, `info`, `show`, `on`, `off`) existed and `--help` listed it. It does not exist; `codeaf telemetry` is an unknown command. The switches are unchanged: the `telemetry` toggle in /settings, CODEAF_TELEMETRY=off, DO_NOT_TRACK=1, `telemetry = off` in the project file, an empty CODEAF_TELEMETRY_ENDPOINT. tui3.Options no longer has TelemetryNotice or TelemetryNoticeShown, and internal/telemetry no longer has Notice, PrintNotice, NoticeShown or MarkNoticeShown." - "A balance read that OpenRouter answered with `API key expired` was a failed read: nothing was recorded and nothing was said, and the turn then failed with a plain auth error. It is now a reading of its own (credits.Reading.Expired, stored beside low in credits.json, config.CreditsExpiredAt): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands under the message box in a conversation and on Home for every OpenRouter model, free included, the setup screen says the same under the chat model, a turn refused as expired starts a fresh read, and the free defaults are not used for it." - - "The setup screen's example panel (`○ Example · what you can do`) was a second column to the right of the form, 36 cells wide, drawn only from 112 columns up and level with the first field; on a tall window the lower half of the screen stayed empty while the panel wrapped every sentence. It now stands under the form and its keys line, one blank row below, as wide as the window allows up to 92 cells, whole or not at all: a window with no rows to spare under the form (24 rows) draws the form alone, and the keys line names `←→ examples` only when the panel is drawn. The form itself is 64 cells wide on every window (it was 54 beside the panel), and setupWideCols, setupFormWidth=54, setupShowGap and setupShowWidth are gone." + - "The setup screen's example panel (`○ Example · what you can do`) was a second column to the right of the form, 36 cells wide, drawn only from 112 columns up and level with the first field; on a tall window the lower half of the screen stayed empty while the panel wrapped every sentence. It now stands ABOVE the form, under the header: the panel, two blank rows, the keys line, then the form directly under it, as wide as the window allows up to 92 cells, whole or not at all: a window with no rows to spare (24 rows) draws the keys line and the form alone, and the keys line names `←→ examples` only when the panel is drawn. The panel's top edge carries the example's title (`○ Example · Follow the work and its cost`) instead of `Example · what you can do`, and the title is no longer a row inside the body. There is a fifth example, `Hand off complex coding tasks`, whose request is `/senior-dev Add retries with backoff to the HTTP client, with tests.`; it stands beside `Start a conversation`, and a command in any example's request is painted with the composer's command chip. The form itself is 64 cells wide on every window (it was 54 beside the panel), and setupWideCols, setupFormWidth=54, setupShowGap and setupShowWidth are gone." + - "The setup screen's keys line said `enter goes on · tab moves · …` on the limit row. It says `enter sets the limit · ↑↓ moves · …`; `tab` still walks the rows but is no longer named, and the tmux suite's setupMovesWord needle is `↑↓ moves`." - "The setup screen's chat-model list was the whole catalog whatever the account's balance. While the OpenRouter balance reads low ($0.50 or less, the reading behind the low-credits warning), it offers only the `:free` ids and the catalog rows priced at zero, its count line says `free only`, and `Your OpenRouter account is low on credits · the list shows free models only` stands under the field. The model in use stays on the list." --- diff --git a/internal/e2e/tmux_test.go b/internal/e2e/tmux_test.go index aa27fccff4..88b668abed 100644 --- a/internal/e2e/tmux_test.go +++ b/internal/e2e/tmux_test.go @@ -316,14 +316,14 @@ const setupPatience = 8 * time.Second // asks for a word. // // THE LEGEND'S SECOND CLAUSE IS HERE FOR NARROW FRAMES. Below sixty columns the -// form draws no title and its legend keeps only `enter goes on · tab moves` +// form draws no title and its legend keeps only `enter sets the limit · ↑↓ moves` // (internal/tui3's onboarding.go, [app.setupControlsKeys]), so a rig started at // forty-four columns saw neither of the other two words, decided there was no // setup, and left its scenario typing into the daily-limit field. const ( setupSkipKeysWord = "esc skips setup" setupTitleWord = "setting up" - setupMovesWord = "tab moves" + setupMovesWord = "↑↓ moves" ) // keylessEnv is every variable a fresh-install run must not inherit: the two the diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index cd097f77d7..3be2e818a5 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -216,17 +216,28 @@ If a **task model** is pinned (`task.model`), one dim line under the chat model `Tasks are pinned to … · /settings changes that` — because that pin decides the worker seat, and a screen that did not mention it would be hiding where tasks run. -**Under the form**, below the keys line, a bordered panel labelled -`○ Example · what you can do` and footed `An illustration. Nothing here has run.` stands -on any window with the rows to hold the whole of it — the only bordered surface codeaf -draws, so it cannot be read as more form. It is as wide as the screen allows, up to 92 -columns, so the request in it stands on one row. It holds one request you could type and -what it leads to, and follows the row you are on: on the review row it shows `/task Fix the -failing tests and explain the changes.` That request **types itself out once** on arriving -and on `←`/`→`, then settles; typing settles it at once. On a window too short to hold the -form and the whole panel — 24 rows, say — the panel is not drawn and the keys line does not -name the arrows; the form is unchanged. (Until 2026-10-01 the panel was a second column to -the right of the form, drawn only from 112 columns up.) +**Above the form**, under the `codeaf · setup · 2 of 2` header, a bordered panel stands on +any window with the rows to hold the whole of it — the only bordered surface codeaf draws, +so it cannot be read as more form. Its top edge is labelled `○ Example · ` followed by the +example's own title (`Understand an unfamiliar project`, `Hand off something longer`, +`Follow the work and its cost`, `Compare the options`, `Hand off complex coding tasks`), and +its foot reads `An illustration. Nothing here has run.` It is as wide as the screen allows, +up to 92 columns, so the request in it stands on one row. It holds one request you could +type and what it leads to, and follows the row you are on: on the review row it shows +`/task Fix the failing tests and explain the changes.`, and on `Start a conversation` it +shows `/senior-dev Add retries with backoff to the HTTP client, with tests.` — a command +in a request is painted as the same chip the message box paints a recognised command with. +That request **types itself out once** on arriving and on `←`/`→`, then settles; typing +settles it at once. Two blank rows separate the panel from the keys line, and the form +stands directly under the keys line. On a window too short to hold the form and the whole +panel — 24 rows, say — the panel is not drawn and the keys line does not name the arrows; +the form is unchanged. (Until 2026-10-01 the panel was a second column to the right of the +form, drawn only from 112 columns up.) + +The keys line reads `enter sets the limit · ↑↓ moves · esc back · type an amount or none · +? detail · ←→ examples` on the limit row; `enter` on the other rows says what it does +there (`opens the list`, `shows them`, `goes on`, `starts`). `↑`/`↓` and `tab` both walk +the rows. The controls screen shows **once, ever**. The default OpenRouter prerequisite above is the only step that may return. diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 117d478a44..e7cd33b00e 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -323,9 +323,8 @@ func (s *setupFlow) focusControl(a *app, delta int) { was := s.example s.example = exampleForControl(s.control) s.refusal = "" - // The other deliberate act. Two rows share an example — the model row and - // `Start a conversation` both stand beside the first — and walking between - // them does not replay it: the panel would restart under a person who never + // The other deliberate act. A row that keeps the example the last row had + // does not replay it: the panel would restart under a person who never // changed what it was showing. if s.example != was { a.restartSetupDemo() @@ -829,6 +828,22 @@ var setupExamples = []setupExample{ "A recommendation you can argue with", }, }, + { + // THE OTHER EXAMPLE THAT SPELLS A COMMAND. `/senior-dev ` hands the + // whole brief to the autonomous coding agent codeaf carries, and the + // manual's account of it (senior-dev.md) is what the three lines are held + // to: it writes the brief down word for word, works in a private copy on + // a branch of its own, and hands back a change it has built and tested on + // a frozen tree. Nothing here says "approved" or "merged" — it submits, + // and what happens to the branch is the person's. + title: "Hand off complex coding tasks", + ask: "/senior-dev Add retries with backoff to the HTTP client, with tests.", + leads: []string{ + "Your brief written down word for word", + "Work on a branch of its own, step by step", + "A change built and tested, handed back", + }, + }, } // exampleForControl is which example accompanies which field, and it is the @@ -840,6 +855,11 @@ func exampleForControl(control setupControl) int { switch control { case controlLimit: return 2 + case controlStart: + // THE LAST ROW SHOWS THE BIGGEST THING THE PROGRAM DOES: a person about + // to start a conversation is shown that a whole change can be handed + // off, which is the one capability nothing above has hinted at. + return 4 case controlReview: // THE HAND-OFF EXAMPLE stood beside the crew row, which is gone: a task's // crew is picked per task now and asks nothing here. The review row is @@ -940,16 +960,19 @@ func titleWord(word string) string { // ── the drawing ───────────────────────────────────────────────────────────── -// The composition, in cells: ONE COLUMN. The form stands at the top, the legend -// under it, and the example panel under that, at the composition's full width, -// so the request and the three lines it leads to each stand on one row instead -// of wrapping inside a thirty-six-cell box. Until 2026-10-01 the example was a +// The composition, in cells: ONE COLUMN. The example panel stands at the top, +// at the composition's full width, so the request and the three lines it leads +// to each stand on one row instead of wrapping inside a thirty-six-cell box; +// two blank rows under it; then the keyboard legend with the form directly +// under it, because the legend is about the form and the two read as one +// object when nothing stands between them. Until 2026-10-01 the example was a // second column to the right of the form, drawn only from 112 columns up and // level with the first field; on a tall window that left the whole lower half -// of the screen empty while the panel squeezed its sentences three ways. The -// panel now takes the rows the form leaves, WHOLE OR NOT AT ALL — a window with -// no rows to spare under the form draws the form alone, which is also what keeps -// the panel from ever standing between a person and `Start a conversation`. +// of the screen empty while the panel squeezed its sentences three ways, and +// the first move — under the form — put the legend between the form and the +// panel, where it read as a caption for the wrong one. The panel takes the rows +// the form leaves, WHOLE OR NOT AT ALL: a window with no rows to spare draws +// the legend and the form alone, from the top. const ( // setupFormWidth is the form's width, a comfortable measure for a sentence // and no wider. @@ -1005,43 +1028,42 @@ func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { // before the form gets any: the header, the blank under it, and the legend. body, doors, caretRow := sheet.trim(max(height-3, 1)) // THE DOORS ARE KEPT WITH THE FRAME THAT DREW THEM, so a press reads the - // rows that are actually on the screen: body row i is frame row i+2, under - // the header and the blank, and it spans the form's own columns. - s.doors = setupDoors{rows: doors, top: 2, left: lead, width: form} + // rows that are actually on the screen: body row i is frame row top+i, + // where top is settled below once the panel and the legend are placed, and + // it spans the form's own columns. + s.doors = setupDoors{rows: doors, left: lead, width: form} // THE EXAMPLE TAKES THE ROWS THE FORM LEAVES, and only the whole of it. - // Four rows are spoken for around the form — the header, the blank under - // it, the legend, and the blank between the legend and the panel — and a - // panel that does not fit whole in what is left is not drawn: a cut panel - // is a panel without the line at its foot that says nothing in it has run. - // The decision is made before the legend is written, because the legend - // names the arrows only when there is a panel for them to browse. + // Three rows are spoken for around the form — the header, the blank under + // it, and the legend — and the panel needs its own rows plus the two blank + // ones under it; a panel that does not fit whole in what is left is not + // drawn, because a cut panel is a panel without the line at its foot that + // says nothing in it has run. The decision is made before the legend is + // written, because the legend names the arrows only when there is a panel + // for them to browse. var block []string if show > 0 { - block = a.setupShowcaseBlock(show, height-2-len(body)-2) + block = a.setupShowcaseBlock(show, height-3-len(body)-setupShowcaseGap) } s.exampleShown = len(block) > 0 lines := make([]string, 0, height) lines = append(lines, pad+head, "") - for _, line := range body { + for _, line := range block { lines = append(lines, pad+line) } - // THE KEYBOARD GUIDANCE FOLLOWS THE FORM, and the panel follows that with - // one blank row between them. The legend is measured against the whole - // composition rather than the form ([app.setupControlsKeys] then cuts by - // whole clauses, never mid-word). It is not pinned to the last row of the - // window: a legend nailed to the foot of a frame under a short form leaves a - // hole in the middle of the composition, and a hole reads as a screen that - // stopped. - if len(lines) < height { - lines = append(lines, pad+a.pal.dim(a.setupControlsKeys(pair))) - } - if len(block) > 0 { + for i := 0; len(block) > 0 && i < setupShowcaseGap; i++ { lines = append(lines, "") - for _, line := range block { - lines = append(lines, pad+line) - } + } + // THE KEYBOARD GUIDANCE STANDS DIRECTLY OVER THE FORM, with nothing between + // them. It is measured against the whole composition rather than the form + // ([app.setupControlsKeys] then cuts by whole clauses, never mid-word). + lines = append(lines, pad+a.pal.dim(a.setupControlsKeys(pair))) + // The form begins here: the row a press is measured from, and the caret's. + top := len(lines) + s.doors.top = top + for _, line := range body { + lines = append(lines, pad+line) } for len(lines) < height { lines = append(lines, "") @@ -1049,7 +1071,7 @@ func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { if len(lines) > height { lines = lines[:height] } - caretY := caretRow + 2 + caretY := caretRow + top a.caret = caretRow >= 0 && caretY < height if !a.caret { return lines, 0, 0 @@ -1057,6 +1079,11 @@ func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { return lines, lead + sheet.caretX, caretY } +// setupShowcaseGap is the blank rows between the example panel and the legend: +// enough that the panel reads as its own object above the form, and the legend +// as the form's. +const setupShowcaseGap = 2 + // padTo pads a painted row out to a column, measuring the plain text under the // paint so an escape sequence is never counted as a cell. func padTo(text string, width int) string { @@ -1689,18 +1716,21 @@ func (a *app) setupControlsKeys(width int) string { // has been told everything except how to reach the other four rows — // which is the one thing this screen cannot be completed without. The way // out is third and appears from sixty columns up. + // THE ROWS ARE WALKED WITH THE ARROWS, AND THE LEGEND SAYS SO. Tab + // walks them too, and used to be the word here; it was one more key to + // learn on a screen whose list a person already walks with ↑↓. switch s.control { case controlLimit: - parts = []string{"enter goes on", "tab moves", a.setupBackWord(), "type an amount or none"} + parts = []string{"enter sets the limit", setupMovesWord, a.setupBackWord(), "type an amount or none"} case controlChatModel: - parts = []string{"enter opens the list", "tab moves", a.setupBackWord()} + parts = []string{"enter opens the list", setupMovesWord, a.setupBackWord()} case controlReview: - parts = []string{"enter shows them", "tab moves", a.setupBackWord()} + parts = []string{"enter shows them", setupMovesWord, a.setupBackWord()} if s.reviewOpen { parts[0] = "enter goes on" } case controlStart: - parts = []string{"enter starts", "tab moves", a.setupBackWord()} + parts = []string{"enter starts", setupMovesWord, a.setupBackWord()} } if s.control <= controlChatModel { parts = append(parts, "? detail") @@ -1714,6 +1744,11 @@ func (a *app) setupControlsKeys(width int) string { return clausesWithin(parts, width) } +// setupMovesWord is the legend's clause for walking the rows. It is one +// constant because the tmux suite waits for it to know the form is up +// (internal/e2e's tmux_test.go), and a respelling has to be one edit. +const setupMovesWord = "↑↓ moves" + // clausesWithin joins as many whole clauses as fit, in the order they are given. // It never cuts one in half, which is the whole reason it is not [fit]. func clausesWithin(parts []string, width int) string { @@ -1771,7 +1806,7 @@ func (a *app) setupBackWord() string { // something that LOOKS like a run, and the panel says outright that it was not // one. Neither line is decoration and neither is dropped before the leads are. const ( - showcaseTitleWord = "Example · what you can do" + showcaseTitleWord = "Example" showcaseHonestWord = "An illustration. Nothing here has run." ) @@ -1807,7 +1842,7 @@ func (a *app) setupShowcaseBlock(width, maxHeight int) []string { rows = append(rows, " "+padTo(line, inner)+" ") } block, _ := framed{ - title: pal.dim(showcaseTitle(pal)), + title: showcaseTitle(pal, example), keysAside: pal.dim(setupShowcaseCount(at, len(setupExamples))), }.draw(pal, width, rows) return block @@ -1822,7 +1857,8 @@ func (a *app) showcaseBody(example setupExample, inner int) []string { caret = showcaseCaretASCII } rows := make([]string, 0, 16) - rows = append(rows, pal.muted(fit(example.title, inner)), "") + // The example's title is on the panel's top edge ([showcaseTitle]), so the + // body opens straight on the request. // THE REQUEST, TYPED. It is drawn behind this surface's own `you` marker, // which is the mark the transcript opens a person's own line with — so what @@ -1839,12 +1875,18 @@ func (a *app) showcaseBody(example setupExample, inner int) []string { } return strings.Repeat(" ", ansi.StringWidth(pal.youGlyph())) } + // A COMMAND IN THE REQUEST WEARS ITS CHIP, the same chip the composer draws + // over a recognised command (slashchip.go's [paintCommands]), so the panel + // shows `/senior-dev` the way the box will show it when it is typed: as a + // word the program knows. While the request is still typing itself out the + // word is painted the moment it is whole, and plain before that, exactly as + // it is under a person's own fingers. for i := range full { switch { case i < len(shown)-1: - rows = append(rows, lead(i)+pal.ink(shown[i])) + rows = append(rows, lead(i)+paintCommands(shown[i], pal, pal.ink, i == 0)) case i == len(shown)-1: - line := pal.ink(shown[i]) + line := paintCommands(shown[i], pal, pal.ink, i == 0) if typing { line += pal.accent(caret) } @@ -1882,15 +1924,17 @@ func (a *app) showcaseBody(example setupExample, inner int) []string { } // showcaseTitle is the panel's label, written into its top edge by the frame, -// behind the one mark on it. A label on an edge is a label that cannot be -// mistaken for content. -func showcaseTitle(pal palette) string { +// behind the one mark on it: the word that says what the panel IS, dim, and +// then the example's own title at the panel's reading weight. A label on an edge +// is a label that cannot be mistaken for content, and a title on the edge is a +// row the body does not have to spend. +func showcaseTitle(pal palette, example setupExample) string { // THE MARK IS THIS SURFACE'S OWN GLYPH FOR "NOTHING IS TURNING" // (tokens.GQueued, the empty circle that is deliberately not a spinner), which // is exactly what this panel is. Borrowing it rather than inventing a shape // keeps one vocabulary, and it means the one glyph on the frame agrees with // the sentence at its foot. - return pal.glyph(tokens.GQueued) + " " + showcaseTitleWord + return pal.dim(pal.glyph(tokens.GQueued)+" "+showcaseTitleWord+" · ") + pal.muted(example.title) } // setupShowcaseCount is the position line on the panel's bottom edge — diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 5f90858e66..985ff0c490 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -9,6 +9,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/credits" + "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -372,7 +373,7 @@ func TestTheControlsScreenStaysUsableDownToFortyColumns(t *testing.T) { // and a keyboard line that ends in one has taught nobody anything. legend := "" for _, line := range strings.Split(screen, "\n") { - if strings.Contains(line, "enter goes on") { + if strings.Contains(line, "enter sets the limit") { legend = strings.TrimSpace(line) } } @@ -386,8 +387,8 @@ func TestTheControlsScreenStaysUsableDownToFortyColumns(t *testing.T) { // A legend teaching only what enter does and how to go back has taught // everything except how to reach the other four rows, which is the one // thing this screen cannot be completed without. - if !strings.Contains(legend, "tab moves") { - t.Fatalf("%s dropped `tab moves` from the keyboard line: %q", where, legend) + if !strings.Contains(legend, setupMovesWord) { + t.Fatalf("%s dropped %q from the keyboard line: %q", where, setupMovesWord, legend) } // AND NO SENTENCE IS LEFT HALF-DRAWN. Rows are given up whole block at a // time, so a wrapped explanation is either all there or not there at all. @@ -532,49 +533,104 @@ func TestTheExampleColumnIsLabelledFollowsTheFocusAndHidesWhenNarrow(t *testing. if strings.Contains(plain(short), showcaseTitleWord) { t.Fatalf("the example panel was drawn on a 24-row window with no room for the whole of it:\n%s", plain(short)) } - if strings.Contains(plain(short), "←→ examples") { + if strings.Contains(plain(short), "examples") { t.Fatalf("the legend names the arrows on a frame with no panel:\n%s", plain(short)) } } -// THE PANEL STANDS UNDER THE FORM, AT THE COMPOSITION'S WIDTH. It used to be a -// second column beside the form, drawn from 112 columns up; on a tall window -// that left the lower half of the screen empty while the panel wrapped every -// sentence three ways. Now it follows the legend, one blank row under it, and -// its top edge is as wide as the composition, so the request stands on one row. -func TestTheExamplePanelStandsUnderTheFormAtTheCompositionsWidth(t *testing.T) { +// THE PANEL STANDS ABOVE THE LEGEND AND THE FORM, AT THE COMPOSITION'S WIDTH. +// It used to be a second column beside the form, drawn from 112 columns up, and +// then for a day it stood under the legend, where the legend read as a caption +// for the wrong object. Now the order is panel, two blank rows, legend, form — +// the legend directly over the form it is about — and the panel's top edge is +// as wide as the composition, so the request stands on one row and the +// example's title rides on the edge instead of spending a row of the body. +func TestTheExamplePanelStandsAboveTheLegendAndTheForm(t *testing.T) { a, _ := controlsApp(t, nil) a.settleSetupDemo() rows := strings.Split(rawSetupFrame(a), "\n") - legend, top := -1, -1 + legend, title, top, bottom := -1, -1, -1, -1 + box := framePiecesOf(a.pal) for i, row := range rows { - if strings.Contains(row, "tab moves") { + switch { + case strings.Contains(row, setupMovesWord): legend = i - } - if strings.Contains(row, showcaseTitleWord) { + case strings.Contains(row, controlsTitle): + title = i + case strings.Contains(row, showcaseTitleWord): top = i + case top >= 0 && bottom < 0 && strings.Contains(row, box.bl): + bottom = i } } - if legend < 0 || top < 0 { - t.Fatalf("no legend (%d) or no panel (%d) on the frame:\n%s", legend, top, rawSetupFrame(a)) + if legend < 0 || title < 0 || top < 0 || bottom < 0 { + t.Fatalf("legend %d, form title %d, panel top %d, panel bottom %d on the frame:\n%s", legend, title, top, bottom, rawSetupFrame(a)) + } + if top != 2 { + t.Fatalf("the panel's top edge is on row %d, want row 2 under the header and its blank", top) } - if top != legend+2 { - t.Fatalf("the panel's top edge is on row %d and the legend on row %d; want the panel two rows under the legend", top, legend) + if legend != bottom+1+setupShowcaseGap { + t.Fatalf("the legend is on row %d and the panel's bottom edge on row %d; want %d blank rows between them", legend, bottom, setupShowcaseGap) + } + if title != legend+1 { + t.Fatalf("the form's title is on row %d and the legend on row %d; want the form directly under the legend", title, legend) } panel := showcasePanel(a) if got := len([]rune(panel[0])); got != setupShowcaseWidth { t.Fatalf("the panel is %d cells wide, want the composition's %d:\n%s", got, setupShowcaseWidth, panel[0]) } + // The example's title is on the top edge, after the label, and not inside. + example := setupExamples[a.setup.example] + if !strings.Contains(panel[0], showcaseTitleWord+" · "+example.title) { + t.Fatalf("the top edge does not carry %q after the label:\n%s", example.title, panel[0]) + } + for _, row := range panel[1:] { + if strings.Contains(row, example.title) { + t.Fatalf("the example's title is still inside the panel:\n%s", strings.Join(panel, "\n")) + } + } // The request stands on one row inside it, which is what the width is for. - ask := setupExamples[a.setup.example].ask found := false for _, row := range panel { - if strings.Contains(row, ask) { + if strings.Contains(row, example.ask) { found = true } } if !found { - t.Fatalf("the request %q wrapped inside a %d-cell panel:\n%s", ask, setupShowcaseWidth, strings.Join(panel, "\n")) + t.Fatalf("the request %q wrapped inside a %d-cell panel:\n%s", example.ask, setupShowcaseWidth, strings.Join(panel, "\n")) + } + // And a click still lands on the form's rows where they now stand. + x, y := setupRowOf(t, a, controlModelLabel) + pressSetup(a, clickAt(x+2, y)) + if a.setup.control != controlChatModel || !a.setup.modelOpen { + t.Fatalf("a press on the model row under the panel left the focus on %v with the list open=%v", a.setup.control, a.setup.modelOpen) + } +} + +// THE HAND-OFF EXAMPLE SPELLS /senior-dev AND WEARS ITS CHIP. It stands beside +// `Start a conversation`, and the command in its request is painted the way the +// composer paints a recognised command, so the panel shows the word as one the +// program knows rather than as prose. +func TestTheSeniorDevExampleStandsOnTheLastRowWithItsCommandChipped(t *testing.T) { + // The program's row is on the command table once the engine's list has + // landed (delegate.go), which is what makes `/senior-dev` a word the + // composer chips; the fixture's agent has no list, so the row is installed + // the way the launch installs it. + installDelegateCommands([]session.DelegateRow{{Name: "senior-dev", Description: "an autonomous coding agent"}}) + t.Cleanup(func() { installDelegateCommands(nil) }) + a, _ := controlsApp(t, nil) + walkToControl(t, a, controlStart) + a.settleSetupDemo() + example := setupExamples[a.setup.example] + if !strings.HasPrefix(example.ask, "/senior-dev ") || example.title != "Hand off complex coding tasks" { + t.Fatalf("the last row's example is %q / %q, want the senior-dev hand-off", example.title, example.ask) + } + frame, _, _ := a.frame() + if !strings.Contains(frame, a.pal.chip("/senior-dev")) { + t.Fatalf("the panel does not paint /senior-dev as a command chip:\n%s", plain(frame)) + } + if !strings.Contains(setupScreen(a), example.title) { + t.Fatalf("the panel's edge does not carry %q:\n%s", example.title, setupScreen(a)) } } From 580accbce584a90ebe6bc76f732761a8c241e0b7 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:34:04 -0400 Subject: [PATCH 08/26] chat: the setup is headed `Basic settings` with the keys line under it, the panel loses its foot line, and the account's warning rides the frame's foot `Models and spending` over `Keep these choices or change them.` is one line, `Basic settings`, and the keys line is the row directly under it rather than the foot of the form. The keys line no longer names the example's arrows: the panel's own bottom edge carries them. The panel no longer ends on `An illustration. Nothing here has run.`; its `Example` label says that once. The low-credits and expired-key lines leave the chat-model row and stand right-aligned on the frame's last row, where the keys row carries the same warning outside the setup; the expired-key line is shortened to fit any window the panel fits. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 5 +- internal/e2e/tmux_test.go | 2 +- internal/manual/chat/getting-started.md | 42 ++++--- internal/manual/chat/models-and-cost.md | 2 +- internal/manual/chat/openrouter-credits.md | 9 +- internal/tui3/firstrun.go | 3 - internal/tui3/firstrun_test.go | 2 +- internal/tui3/onboarding.go | 115 ++++++++---------- internal/tui3/onboarding_test.go | 65 ++++++---- 9 files changed, 124 insertions(+), 121 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 7b774c1e63..62c300e32d 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -5,14 +5,15 @@ pr: 1720 surface: [chat, docs] invalidates: - "On the first conversation's screen, enter on a starting point filled the box and the next enter did nothing — the starting point kept taking it. With words in the box, enter now sends them: ↓ enter enter sends the starting point's sentence." + - "The setup's controls screen was headed `Models and spending` over `Keep these choices or change them.`, with the keys line at the foot of the form. Its heading is the one line `Basic settings`, and the keys line is the row directly under it. The example panel no longer carries `An illustration. Nothing here has run.` at its foot (its `Example` label says so), and the keys line no longer says `←→ examples` — the panel's own bottom edge carries the arrows." - "The Models and spending setup screen swallowed every mouse press. A press on a row is now the key that row would take — the limit focuses, the chat model opens its list, a model in the list is taken, the review shows, `Start a conversation` leaves — and the wheel scrolls the open list." - "Enter on the chat-model row left the focus there after a model was taken, so the next enter reopened the list; enter on an open review folded it away. Both now go on to the next row, so enter alone walks the whole form down to `Start a conversation`." - "codeaf printed a six-line anonymous-usage-counts notice once per install — on the first conversation's screen under the starting points, or to stderr ahead of `chat --once` and task commands — and sent nothing until a frame or a terminal had shown it. It prints no notice anywhere now and the gate is gone; the disclosure is the README's Telemetry section and docs/TELEMETRY.md, which a test holds to the switches." - "`codeaf telemetry` (`status`, `info`, `show`, `on`, `off`) existed and `--help` listed it. It does not exist; `codeaf telemetry` is an unknown command. The switches are unchanged: the `telemetry` toggle in /settings, CODEAF_TELEMETRY=off, DO_NOT_TRACK=1, `telemetry = off` in the project file, an empty CODEAF_TELEMETRY_ENDPOINT. tui3.Options no longer has TelemetryNotice or TelemetryNoticeShown, and internal/telemetry no longer has Notice, PrintNotice, NoticeShown or MarkNoticeShown." - - "A balance read that OpenRouter answered with `API key expired` was a failed read: nothing was recorded and nothing was said, and the turn then failed with a plain auth error. It is now a reading of its own (credits.Reading.Expired, stored beside low in credits.json, config.CreditsExpiredAt): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands under the message box in a conversation and on Home for every OpenRouter model, free included, the setup screen says the same under the chat model, a turn refused as expired starts a fresh read, and the free defaults are not used for it." + - "A balance read that OpenRouter answered with `API key expired` was a failed read: nothing was recorded and nothing was said, and the turn then failed with a plain auth error. It is now a reading of its own (credits.Reading.Expired, stored beside low in credits.json, config.CreditsExpiredAt): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands under the message box in a conversation and on Home for every OpenRouter model, free included, the setup screen says the same on its last row, a turn refused as expired starts a fresh read, and the free defaults are not used for it." - "The setup screen's example panel (`○ Example · what you can do`) was a second column to the right of the form, 36 cells wide, drawn only from 112 columns up and level with the first field; on a tall window the lower half of the screen stayed empty while the panel wrapped every sentence. It now stands ABOVE the form, under the header: the panel, two blank rows, the keys line, then the form directly under it, as wide as the window allows up to 92 cells, whole or not at all: a window with no rows to spare (24 rows) draws the keys line and the form alone, and the keys line names `←→ examples` only when the panel is drawn. The panel's top edge carries the example's title (`○ Example · Follow the work and its cost`) instead of `Example · what you can do`, and the title is no longer a row inside the body. There is a fifth example, `Hand off complex coding tasks`, whose request is `/senior-dev Add retries with backoff to the HTTP client, with tests.`; it stands beside `Start a conversation`, and a command in any example's request is painted with the composer's command chip. The form itself is 64 cells wide on every window (it was 54 beside the panel), and setupWideCols, setupFormWidth=54, setupShowGap and setupShowWidth are gone." - "The setup screen's keys line said `enter goes on · tab moves · …` on the limit row. It says `enter sets the limit · ↑↓ moves · …`; `tab` still walks the rows but is no longer named, and the tmux suite's setupMovesWord needle is `↑↓ moves`." - - "The setup screen's chat-model list was the whole catalog whatever the account's balance. While the OpenRouter balance reads low ($0.50 or less, the reading behind the low-credits warning), it offers only the `:free` ids and the catalog rows priced at zero, its count line says `free only`, and `Your OpenRouter account is low on credits · the list shows free models only` stands under the field. The model in use stays on the list." + - "The setup screen's chat-model list was the whole catalog whatever the account's balance. While the OpenRouter balance reads low ($0.50 or less, the reading behind the low-credits warning), it offers only the `:free` ids and the catalog rows priced at zero, its count line says `free only`, and `Your OpenRouter account is low on credits · the list shows free models only` stands right-aligned on the screen's last row, where the keys row carries the warning outside the setup (the expired-key line stands there too). The model in use stays on the list." --- Santosh watched five new people through the first run on 2026-09-30. Four read diff --git a/internal/e2e/tmux_test.go b/internal/e2e/tmux_test.go index 88b668abed..7464ebe30d 100644 --- a/internal/e2e/tmux_test.go +++ b/internal/e2e/tmux_test.go @@ -422,7 +422,7 @@ func startWithEnv(t *testing.T, env []string, name, home, ws string, cols, rows // // THE SETUP IS ONE OF THOSE SURFACES AND IT IS FOUR STEPS, NOT A TITLE. Only // the FIRST step is headed `setting up`; the three after it wear their own - // headings (`Models and spending` is step three), so a list that recognised + // headings (`Basic settings` is step three), so a list that recognised // the flow by its title alone declared a terminal dead the moment the door // opened on a later step — which is exactly what a state root whose profile // is complete does now. The flow's FOOT is on every step of it, and the diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 3be2e818a5..2f88b989f1 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -19,22 +19,22 @@ default provider needs a key and no daily limit is configured, it has **two scre nothing else on the frame: 1. **connect openrouter** — the default provider; `enter` signs in in your browser, and pasting an existing key also works -2. **Models and spending** — one screen with two controls on it, **Daily limit** and +2. **Basic settings** — one screen with two controls on it, **Daily limit** and **Chat model**, each already showing the value that is in force **With a key already found, there is no setup screen.** When a provider key is saved in the profile or set in the environment, such as `OPENROUTER_API_KEY`, a plain launch skips -the connection screen and **Models and spending**. With nothing elsewhere to show, it +the connection screen and **Basic settings**. With nothing elsewhere to show, it opens the chat's greeting, `What would you like to work on?`; when other conversations are available, it opens home. `/budget` sets a daily limit later. A `--no-host` launch on such a profile skips only the connection screen, -and still opens **Models and spending** while no daily limit is set. A resumed conversation, +and still opens **Basic settings** while no daily limit is set. A resumed conversation, or one on another machine, never opens first-run setup. The second screen's way out is **`Start a conversation`**. Every control on it opens on the value you already have, so pressing `enter` there agrees to exactly what is on the -screen. Its heading is `Models and spending` and the line under it is -`Keep these choices or change them.` +screen. Its heading is `Basic settings`, one line, with the keys line directly under it +(until 2026-10-01 it was `Models and spending` over `Keep these choices or change them.`). ## Skipping setup and reading its header @@ -194,13 +194,14 @@ blank row or the example panel does nothing. **With no credit on the OpenRouter account, the list shows free models only.** When the account's balance has read low — $0.50 or less, the same reading that puts the low-credits warning under the message box — the list is cut to the `:free` ids and the catalog rows -priced at zero, its count line says `free only`, and a warning line under the field reads -`Your OpenRouter account is low on credits · the list shows free models only`. The model you +priced at zero, its count line says `free only`, and +`Your OpenRouter account is low on credits · the list shows free models only` stands on the +last row of the screen, right-aligned in the warning colour — where the low-credits warning +stands under the message box once you are in a conversation. The model you are already on stays on the list whatever it costs, so accepting still confirms. The balance is read right after the key lands, so the cut usually arrives a moment after the screen does; a top-up is read on the next launch (*OpenRouter credits and free models*). A key OpenRouter -refuses as **expired** is said the same way — `Your OpenRouter key has expired · make a new -one at openrouter.ai/settings/keys and paste it with esc` — but the list is not cut, because +refuses as **expired** is said the same way, on the last row — `Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys` — but the list is not cut, because free models fail on an expired key too; `esc` goes back to the connect step to paste a new one. **There is no crew question**, because the crew is three seats — the worker, the planner and the @@ -220,31 +221,32 @@ seat, and a screen that did not mention it would be hiding where tasks run. any window with the rows to hold the whole of it — the only bordered surface codeaf draws, so it cannot be read as more form. Its top edge is labelled `○ Example · ` followed by the example's own title (`Understand an unfamiliar project`, `Hand off something longer`, -`Follow the work and its cost`, `Compare the options`, `Hand off complex coding tasks`), and -its foot reads `An illustration. Nothing here has run.` It is as wide as the screen allows, +`Follow the work and its cost`, `Compare the options`, `Hand off complex coding tasks`); its +bottom edge carries `← 3 / 5 →`, the arrows that browse it. It is as wide as the screen allows, up to 92 columns, so the request in it stands on one row. It holds one request you could type and what it leads to, and follows the row you are on: on the review row it shows `/task Fix the failing tests and explain the changes.`, and on `Start a conversation` it shows `/senior-dev Add retries with backoff to the HTTP client, with tests.` — a command in a request is painted as the same chip the message box paints a recognised command with. That request **types itself out once** on arriving and on `←`/`→`, then settles; typing -settles it at once. Two blank rows separate the panel from the keys line, and the form -stands directly under the keys line. On a window too short to hold the form and the whole -panel — 24 rows, say — the panel is not drawn and the keys line does not name the arrows; +settles it at once. Two blank rows separate the panel from the form's heading. On a window +too short to hold the form and the whole panel — 24 rows, say — the panel is not drawn and the form is unchanged. (Until 2026-10-01 the panel was a second column to the right of the -form, drawn only from 112 columns up.) +form, drawn only from 112 columns up, and carried `An illustration. Nothing here has run.` +at its foot; the label on its edge now says that once.) -The keys line reads `enter sets the limit · ↑↓ moves · esc back · type an amount or none · -? detail · ←→ examples` on the limit row; `enter` on the other rows says what it does -there (`opens the list`, `shows them`, `goes on`, `starts`). `↑`/`↓` and `tab` both walk -the rows. +The keys line is the form's second row, directly under `Basic settings`. It reads `enter +sets the limit · ↑↓ moves · esc back · type an amount or none · ? detail` on the limit +row; `enter` on the other rows says what it does there (`opens the list`, `shows them`, +`goes on`, `starts`). `↑`/`↓` and `tab` both walk the rows. It does not name the example's +arrows; the panel's own edge does. The controls screen shows **once, ever**. The default OpenRouter prerequisite above is the only step that may return. ## What appears once — and why the default provider's OpenRouter step can return -The **Models and spending screen** is shown once per profile. When the first-run screen +The **Basic settings screen** is shown once per profile. When the first-run screen closes — finished or skipped — `setup_seen_at` is written into `config.json` with the time, and no later launch asks those preference questions again. Skipping with `esc` counts as shown. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 9f9ae302c2..a40554fdd4 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -3017,7 +3017,7 @@ the same registry row, so what you set through one is what the others show: | the spend place (`alt+5`) | `enter` on its first line, the dim `today $3.42 of $500 · /budget sets the limits`, the same figure the top line of every place draws | | the spend place, from a row | `→` opens the verb strip, where `b` is `the limits` | | a refused turn | the message names the limit that stopped it — `/budget conversation` or `/budget day` | -| the first-run setup | its `Models and spending` screen, whose **Daily limit** row writes this same row. It asks about the day's limit only — `per plan` and `per conversation` keep their defaults there and are changed here | +| the first-run setup | its `Basic settings` screen, whose **Daily limit** row writes this same row. It asks about the day's limit only — `per plan` and `per conversation` keep their defaults there and are changed here | `ctrl+,` opens the panel itself, and `←`/`→` walk to **Spending** from wherever it opened. diff --git a/internal/manual/chat/openrouter-credits.md b/internal/manual/chat/openrouter-credits.md index ac2ff4a544..8c5df04b04 100644 --- a/internal/manual/chat/openrouter-credits.md +++ b/internal/manual/chat/openrouter-credits.md @@ -15,8 +15,8 @@ openrouter` (*Route health* in *Models and cost*). While the balance is low, the first-run setup screen's **chat model list shows free models only** — the `:free` ids and the catalog rows priced at zero, with `free only` on its count -line and `Your OpenRouter account is low on credits · the list shows free models only` under -the field — so a person who has never run codeaf is not handed three hundred paid names to +line and `Your OpenRouter account is low on credits · the list shows free models only` on +the last row of the screen — so a person who has never run codeaf is not handed three hundred paid names to pick the wrong one from. The model already in use stays on that list. Free ids are defaults only. A model you chose with `/model` or the setup screen, @@ -83,9 +83,8 @@ OpenRouter service serves — free or paid — and never on a model served by Co model or another connected service. It wins over the low-credits line, and the free defaults are **not** used, because they would fail on the same key. A turn refused with `API key expired` mid-session starts a fresh read, so the warning arrives without a -relaunch. On the first-run setup screen the same fact stands under the chat model: -`Your OpenRouter key has expired · make a new one at openrouter.ai/settings/keys and -paste it with esc`, and the list is not cut to free models. The fix is a new key at +relaunch. On the first-run setup screen the same fact stands on the last row: +`Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys`, and the list is not cut to free models. The fix is a new key at https://openrouter.ai/settings/keys, pasted on the setup's connect step (`esc` goes back to it), in `/settings`, or in `/connect`; the old key's warning goes the moment the new key is saved, before it has even been read. diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index b5ff50729d..12f262ae50 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -123,9 +123,6 @@ type setupFlow struct { // (onboarding.go's [setupDoors]): a press is answered against the rows that // are on the screen, and nothing else. doors setupDoors - // exampleShown says the last frame drew the example panel under the form, - // which is what decides whether the legend names the arrows that browse it. - exampleShown bool // auth is the default provider's browser trip. Starting covers the short // interval before its listener is handed back; flow and link cover the wait // after that. id names the attempt so a late answer after esc is dropped. diff --git a/internal/tui3/firstrun_test.go b/internal/tui3/firstrun_test.go index 6329cfa34b..f9511bbd41 100644 --- a/internal/tui3/firstrun_test.go +++ b/internal/tui3/firstrun_test.go @@ -264,7 +264,7 @@ func TestTakingTheControlsAsTheyStandLandsTheDefaultsInTheProfile(t *testing.T) } screen := setupScreen(a) for _, want := range []string{ - "Models and spending", "Keep these choices or change them.", + controlsTitle, "enter sets the limit", "Daily limit", "Chat model", "Start a conversation", } { if !strings.Contains(screen, want) { diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index e7cd33b00e..1ec285a852 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -78,13 +78,12 @@ const setupControlCount = int(controlStart) + 1 // ── what the screen says ──────────────────────────────────────────────────── -// The heading and the line under it. The heading names the SUBJECT of the screen -// rather than the act ("setup", "configuration"), because the subject is what a -// person is deciding about and the act is already obvious from being here. -const ( - controlsTitle = "Models and spending" - controlsLead = "Keep these choices or change them." -) +// The heading. It is ONE LINE, and it names what the rows under it are — +// settings, the basic ones — because the keys line stands directly under it +// and a second sentence of lead-in between the two was a sentence nobody read +// on their way to the first row. (It was `Models and spending` over `Keep +// these choices or change them.` until 2026-10-01.) +const controlsTitle = "Basic settings" // The three field labels, laid out in one column so their values line up. const ( @@ -1021,12 +1020,12 @@ func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { // is the only row that has nothing beside it — the example column stops well // above the foot — so budgeting it at the form's fifty-four cells cut // `←→ examples` off the one screen the arrows exist on. - sheet := a.setupControlsForm(form) + sheet := a.setupControlsForm(form, pair) // The rows the window cannot have are given up WHOLE BLOCK AT A TIME, in the // order the form itself ranked them. A wrapped sentence cut off in the middle - // is worse than a sentence that is not there. Three rows are spoken for - // before the form gets any: the header, the blank under it, and the legend. - body, doors, caretRow := sheet.trim(max(height-3, 1)) + // is worse than a sentence that is not there. Two rows are spoken for before + // the form gets any: the header and the blank under it. + body, doors, caretRow := sheet.trim(max(height-2, 1)) // THE DOORS ARE KEPT WITH THE FRAME THAT DREW THEM, so a press reads the // rows that are actually on the screen: body row i is frame row top+i, // where top is settled below once the panel and the legend are placed, and @@ -1034,18 +1033,14 @@ func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { s.doors = setupDoors{rows: doors, left: lead, width: form} // THE EXAMPLE TAKES THE ROWS THE FORM LEAVES, and only the whole of it. - // Three rows are spoken for around the form — the header, the blank under - // it, and the legend — and the panel needs its own rows plus the two blank - // ones under it; a panel that does not fit whole in what is left is not - // drawn, because a cut panel is a panel without the line at its foot that - // says nothing in it has run. The decision is made before the legend is - // written, because the legend names the arrows only when there is a panel - // for them to browse. + // Two rows are spoken for around the form — the header and the blank under + // it — and the panel needs its own rows plus the two blank ones under it; a + // panel that does not fit whole in what is left is not drawn, because half + // a panel is a panel whose request has lost what it leads to. var block []string if show > 0 { - block = a.setupShowcaseBlock(show, height-3-len(body)-setupShowcaseGap) + block = a.setupShowcaseBlock(show, height-2-len(body)-setupShowcaseGap) } - s.exampleShown = len(block) > 0 lines := make([]string, 0, height) lines = append(lines, pad+head, "") @@ -1055,11 +1050,8 @@ func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { for i := 0; len(block) > 0 && i < setupShowcaseGap; i++ { lines = append(lines, "") } - // THE KEYBOARD GUIDANCE STANDS DIRECTLY OVER THE FORM, with nothing between - // them. It is measured against the whole composition rather than the form - // ([app.setupControlsKeys] then cuts by whole clauses, never mid-word). - lines = append(lines, pad+a.pal.dim(a.setupControlsKeys(pair))) - // The form begins here: the row a press is measured from, and the caret's. + // The form begins here — its heading, the keys line under it, then the + // rows: the row a press is measured from, and the caret's. top := len(lines) s.doors.top = top for _, line := range body { @@ -1071,6 +1063,14 @@ func (a *app) setupControlsFrame(width, height int) ([]string, int, int) { if len(lines) > height { lines = lines[:height] } + // THE ACCOUNT'S WARNING RIDES THE FOOT, whole or not at all, and only on a + // foot the form left empty: a window the form fills to its last row keeps + // that row, because a form row is a thing a person acts on and the warning + // is read again under the message box a moment later. + if warning := a.setupFootWarning(); warning != "" && height > 0 && lines[height-1] == "" && + ansi.StringWidth(warning)+3 <= width { + lines[height-1] = withCreditWarning("", 0, width, warning, a.pal) + } caretY := caretRow + top a.caret = caretRow >= 0 && caretY < height if !a.caret { @@ -1175,7 +1175,6 @@ const ( rankSpacer = iota + 1 rankOtherWords rankReviewRest - rankLead rankFocusedWords rankDetail ) @@ -1266,15 +1265,19 @@ func (f *controlsSheet) trim(height int) ([]string, []setupDoor, int) { } // setupControlsForm builds the form: its rows, the blocks a short window gives -// up, and where the caret sits. The legend is NOT one of its rows — it belongs to -// the frame, at the foot of the window ([app.setupControlsFrame]). -func (a *app) setupControlsForm(width int) *controlsSheet { +// up, and where the caret sits. THE KEYS LINE IS ITS SECOND ROW, directly under +// the heading, so what the keys do is read with the rows they do it to; it is +// measured against the whole composition (legend) rather than the form's own +// width, because it is one line of clauses rather than a sentence, and +// [app.setupControlsKeys] cuts it by whole clauses, never mid-word. Neither row +// is ever given up. +func (a *app) setupControlsForm(width, legend int) *controlsSheet { pal := a.pal s := &a.setup f := &controlsSheet{caretAt: -1} f.add(pal.bold(pal.ink(fit(controlsTitle, width)))) - f.soft(rankLead, pal.muted(fit(controlsLead, width))) + f.add(pal.dim(a.setupControlsKeys(legend))) f.soft(rankSpacer, "") // ── the day's limit ── @@ -1290,10 +1293,6 @@ func (a *app) setupControlsForm(width int) *controlsSheet { f.open(setupDoor{kind: doorControl, control: controlChatModel}, a.setupFieldRow(width, controlChatModel, controlModelLabel, modelWord(a.model), a.setupModelSource())) a.addControlWords(f, width, controlChatModel, controlModelWord, a.setupModelDetail()) - // A LOW ACCOUNT IS NOT DETAIL EITHER. It is why the list under this row is - // shorter than the catalog, so it is on the screen whether or not anybody - // asked. - f.add(a.setupLowCreditsRows(width)...) if s.modelOpen { rows, doors := a.setupModelRows(width) for i, row := range rows { @@ -1560,28 +1559,24 @@ func (a *app) setupModelCountWord(count int) string { const ( setupFreeOnlyWord = "free only" setupLowCreditsWord = "Your OpenRouter account is low on credits · the list shows free models only" - setupExpiredKeyWord = "Your OpenRouter key has expired · make a new one at openrouter.ai/settings/keys and paste it with esc" + setupExpiredKeyWord = "Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys" ) -// setupLowCreditsRows is the line under the chat model while the account reads -// low — wrapped at the form's width rather than cut, because every word of it -// is the reason the list is short — and nothing, the emptiness law, when the -// account does not read low. -func (a *app) setupLowCreditsRows(width int) []string { - word := "" +// setupFootWarning is the account's one-line warning for this screen — the key +// expired, or the balance low and the list cut — and "", the emptiness law, +// when the account gives no cause. It is drawn on the frame's LAST ROW, right +// aligned, in the warning colour: where the same warning stands on the keys +// row under a conversation's message box ([withCreditWarning]), so a person +// meets it in the one place it will keep appearing. Until 2026-10-01 it stood +// under the chat-model row, wrapped, where it read as part of the form. +func (a *app) setupFootWarning() string { switch { case a.setupKeyExpired(): - word = setupExpiredKeyWord + return setupExpiredKeyWord case a.setupFreeOnly(): - word = setupLowCreditsWord - default: - return nil + return setupLowCreditsWord } - lines := wrap(word, max(width-4, 1)) - for i, line := range lines { - lines[i] = strings.Repeat(" ", 4) + a.pal.warn(line) - } - return lines + return "" } // setupNoCatalogWord is what the list says where there is no catalog to choose @@ -1735,11 +1730,10 @@ func (a *app) setupControlsKeys(width int) string { if s.control <= controlChatModel { parts = append(parts, "? detail") } - // The arrows are named only on a frame that drew the panel they browse - // ([app.setupControlsFrame] decides that before it writes this row). - if s.exampleShown { - parts = append(parts, "←→ examples") - } + // THE ARROWS ARE NOT NAMED HERE. The example panel's own bottom edge + // carries `← 3 / 5 →`, which is the one place a control for it + // belongs, and a clause about it on the form's keys line was a clause + // about a different object. } return clausesWithin(parts, width) } @@ -1806,8 +1800,7 @@ func (a *app) setupBackWord() string { // something that LOOKS like a run, and the panel says outright that it was not // one. Neither line is decoration and neither is dropped before the leads are. const ( - showcaseTitleWord = "Example" - showcaseHonestWord = "An illustration. Nothing here has run." + showcaseTitleWord = "Example" ) // showcaseCaret is the block that trails the request while it is being typed @@ -1916,10 +1909,10 @@ func (a *app) showcaseBody(example setupExample, inner int) []string { rows = append(rows, pal.dim(mark+line)) } } - rows = append(rows, "") - for _, line := range wrap(showcaseHonestWord, inner) { - rows = append(rows, pal.dim(line)) - } + // THERE IS NO LINE AT THE FOOT SAYING NOTHING HERE HAS RUN. Until + // 2026-10-01 one stood there, dim; the label on the top edge — `Example` + // — says the same thing once, and the panel is now above a form that has + // not been answered yet, where nothing could have run. return rows } diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 985ff0c490..c349930ca8 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -6,6 +6,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/credits" @@ -491,15 +492,12 @@ func lastWords(sentence string) string { func TestTheExampleColumnIsLabelledFollowsTheFocusAndHidesWhenNarrow(t *testing.T) { a, _ := controlsApp(t, nil) screen := setupScreen(a) - // The two sentences that keep the panel from being read as a report about - // this machine: the label on its top edge, and the line at its foot. + // The label on the panel's top edge is what keeps it from being read as a + // report about this machine. if !strings.Contains(screen, showcaseTitleWord) { t.Fatalf("the example panel must be labelled as one; got:\n%s", screen) } a.settleSetupDemo() - if words := panelWords(a); !strings.Contains(words, showcaseHonestWord) { - t.Fatalf("the example panel must say nothing in it has run; got:\n%s", words) - } if want := setupExamples[exampleForControl(controlLimit)].title; !strings.Contains(screen, want) { t.Fatalf("the limit's example is %q; got:\n%s", want, screen) } @@ -533,19 +531,18 @@ func TestTheExampleColumnIsLabelledFollowsTheFocusAndHidesWhenNarrow(t *testing. if strings.Contains(plain(short), showcaseTitleWord) { t.Fatalf("the example panel was drawn on a 24-row window with no room for the whole of it:\n%s", plain(short)) } - if strings.Contains(plain(short), "examples") { - t.Fatalf("the legend names the arrows on a frame with no panel:\n%s", plain(short)) - } } -// THE PANEL STANDS ABOVE THE LEGEND AND THE FORM, AT THE COMPOSITION'S WIDTH. -// It used to be a second column beside the form, drawn from 112 columns up, and -// then for a day it stood under the legend, where the legend read as a caption -// for the wrong object. Now the order is panel, two blank rows, legend, form — -// the legend directly over the form it is about — and the panel's top edge is -// as wide as the composition, so the request stands on one row and the -// example's title rides on the edge instead of spending a row of the body. -func TestTheExamplePanelStandsAboveTheLegendAndTheForm(t *testing.T) { +// THE PANEL STANDS ABOVE THE FORM, AT THE COMPOSITION'S WIDTH, AND THE KEYS +// LINE IS THE FORM'S SECOND ROW. The panel used to be a second column beside +// the form, drawn from 112 columns up, and then for a day it stood under the +// legend, where the legend read as a caption for the wrong object. Now the +// order is panel, two blank rows, the heading, the keys line, the rows — and +// the panel's top edge is as wide as the composition, so the request stands on +// one row and the example's title rides on the edge instead of spending a row +// of the body. The arrows are not on the keys line: the panel's own edge +// carries them. +func TestTheExamplePanelStandsAboveTheFormWithTheKeysLineUnderTheHeading(t *testing.T) { a, _ := controlsApp(t, nil) a.settleSetupDemo() rows := strings.Split(rawSetupFrame(a), "\n") @@ -569,11 +566,19 @@ func TestTheExamplePanelStandsAboveTheLegendAndTheForm(t *testing.T) { if top != 2 { t.Fatalf("the panel's top edge is on row %d, want row 2 under the header and its blank", top) } - if legend != bottom+1+setupShowcaseGap { - t.Fatalf("the legend is on row %d and the panel's bottom edge on row %d; want %d blank rows between them", legend, bottom, setupShowcaseGap) + if title != bottom+1+setupShowcaseGap { + t.Fatalf("the heading is on row %d and the panel's bottom edge on row %d; want %d blank rows between them", title, bottom, setupShowcaseGap) + } + if legend != title+1 { + t.Fatalf("the keys line is on row %d and the heading on row %d; want the keys line directly under the heading", legend, title) } - if title != legend+1 { - t.Fatalf("the form's title is on row %d and the legend on row %d; want the form directly under the legend", title, legend) + if strings.Contains(rows[legend], "examples") { + t.Fatalf("the keys line names the arrows, which the panel's own edge carries: %q", rows[legend]) + } + for _, row := range rows { + if strings.Contains(row, "Nothing here has run") { + t.Fatalf("the panel still carries the foot line:\n%s", rawSetupFrame(a)) + } } panel := showcasePanel(a) if got := len([]rune(panel[0])); got != setupShowcaseWidth { @@ -648,7 +653,7 @@ func TestTheExamplePanelIsFramedAndFallsBackToAscii(t *testing.T) { a.settleSetupDemo() box := framePiecesOf(a.pal) panel := showcasePanel(a) - if len(panel) < 8 { + if len(panel) < 6 { t.Fatalf("ascii=%v: no panel on the frame:\n%s", ascii, rawSetupFrame(a)) } head, foot := panel[0], panel[len(panel)-1] @@ -1123,16 +1128,24 @@ func TestALowAccountOffersOnlyFreeModelsOnTheSetupScreen(t *testing.T) { if got := choices[a.setup.modelAt].ID; got != a.model { t.Fatalf("after the reading the cursor is on %q, want the model in use %q", got, a.model) } - // The sentence is read at a width with no example column beside the form, - // so its wrapped halves are not interleaved with the panel's border. - a.width = 100 - a.touch() screen := setupScreen(a) for _, wanted := range []string{"free only", setupLowCreditsWord} { if !strings.Contains(screen, wanted) { t.Fatalf("the screen must say %q; got:\n%s", wanted, screen) } } + // AND THE WARNING IS ON THE FRAME'S LAST ROW, right aligned, where the keys + // row under a conversation's message box carries it — not under the field. + rows := strings.Split(rawSetupFrame(a), "\n") + foot := strings.TrimRight(rows[len(rows)-1], " ") + if !strings.HasSuffix(foot, setupLowCreditsWord) || ansi.StringWidth(foot) != a.width-1 { + t.Fatalf("the warning is not right-aligned on the last row: %q", foot) + } + for _, row := range rows[:len(rows)-1] { + if strings.Contains(row, setupLowCreditsWord) { + t.Fatalf("the warning is still drawn inside the form:\n%s", rawSetupFrame(a)) + } + } // And typing narrows the cut list, never the whole catalog. pressSetup(a, key("a")) for _, model := range a.setupModelChoices() { @@ -1185,8 +1198,6 @@ func TestAnExpiredKeyIsSaidUnderTheModelRowWithoutCuttingTheList(t *testing.T) { if got := len(a.setupModelChoices()); got != len(catalog) { t.Fatalf("an expired key cut the list to %d rows, want the catalog's %d", got, len(catalog)) } - a.width = 100 - a.touch() screen := setupScreen(a) if !strings.Contains(screen, setupExpiredKeyWord) { t.Fatalf("the screen must say %q; got:\n%s", setupExpiredKeyWord, screen) From f1fdf81e71eeed672873f6f0c3475c994ecc9f75 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:43:59 -0400 Subject: [PATCH 09/26] chat: the setup's examples go round, and a row's name is blue on it, grey once answered, white until then MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `→` on the last example is the first again, and `←` on the first the last. Every row's name — the limit, the chat model, the review, the way out — is painted by one rule: the accent while the focus is on it, dim once enter has acted on it (the limit set, a model taken from the list, the review shown), and the body ink until then. Opening the list and leaving it with esc answers nothing. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 1 + internal/manual/chat/getting-started.md | 8 ++- internal/tui3/firstrun.go | 8 ++- internal/tui3/onboarding.go | 64 ++++++++++------- internal/tui3/onboarding_test.go | 71 +++++++++++++++++++ 5 files changed, 122 insertions(+), 30 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 62c300e32d..6b4932b4ff 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -5,6 +5,7 @@ pr: 1720 surface: [chat, docs] invalidates: - "On the first conversation's screen, enter on a starting point filled the box and the next enter did nothing — the starting point kept taking it. With words in the box, enter now sends them: ↓ enter enter sends the starting point's sentence." + - "On the setup's controls screen a row's name was the body ink when focused and muted otherwise, and `←`/`→` stopped at the first and last example. A name is now the accent while focused, dim once enter has acted on the row (the limit set, a model taken, the review shown; setupFlow.answered), and the body ink until then, on every row including `Start a conversation`; and the examples go round in both directions." - "The setup's controls screen was headed `Models and spending` over `Keep these choices or change them.`, with the keys line at the foot of the form. Its heading is the one line `Basic settings`, and the keys line is the row directly under it. The example panel no longer carries `An illustration. Nothing here has run.` at its foot (its `Example` label says so), and the keys line no longer says `←→ examples` — the panel's own bottom edge carries the arrows." - "The Models and spending setup screen swallowed every mouse press. A press on a row is now the key that row would take — the limit focuses, the chat model opens its list, a model in the list is taken, the review shows, `Start a conversation` leaves — and the wheel scrolls the open list." - "Enter on the chat-model row left the focus there after a model was taken, so the next enter reopened the list; enter on an open review folded it away. Both now go on to the next row, so enter alone walks the whole form down to `Start a conversation`." diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 2f88b989f1..7e69f88d51 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -229,12 +229,18 @@ type and what it leads to, and follows the row you are on: on the review row it shows `/senior-dev Add retries with backoff to the HTTP client, with tests.` — a command in a request is painted as the same chip the message box paints a recognised command with. That request **types itself out once** on arriving and on `←`/`→`, then settles; typing -settles it at once. Two blank rows separate the panel from the form's heading. On a window +settles it at once. `←`/`→` go round: `→` on the last example is the first again. Two blank rows separate the panel from the form's heading. On a window too short to hold the form and the whole panel — 24 rows, say — the panel is not drawn and the form is unchanged. (Until 2026-10-01 the panel was a second column to the right of the form, drawn only from 112 columns up, and carried `An illustration. Nothing here has run.` at its foot; the label on its edge now says that once.) +**A row's name says where you are.** Each name — `Daily limit`, `Chat model`, the review +row, `Start a conversation` — is in the body colour until you have answered it, blue while +you are on it, and grey once `enter` has acted on it: the limit set, a model taken from the +list, the review shown. Walking back onto an answered row makes it blue again while you are +there. Opening the list and leaving it with `esc` answers nothing. + The keys line is the form's second row, directly under `Basic settings`. It reads `enter sets the limit · ↑↓ moves · esc back · type an amount or none · ? detail` on the limit row; `enter` on the other rows says what it does there (`opens the list`, `shows them`, diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index 12f262ae50..6e1f916cf5 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -98,8 +98,12 @@ type setupFlow struct { // right-hand column is showing, and seeded says the screen has already // been read from the profile once — so coming back from the step behind // it does not throw away what was typed. - control setupControl - detail bool + control setupControl + detail bool + // answered marks the rows enter has acted on — the limit committed, a + // model taken, the review shown — which is what paints a row's name dim + // once it is done (onboarding.go's [app.setupLabelInk]). + answered [setupControlCount]bool limitText string limitTyped bool modelOpen bool diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 1ec285a852..59820f4743 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -228,7 +228,10 @@ func (a *app) setupControlsKey(name, text string) bool { if name == "left" { delta = -1 } - s.example = moveCursor(s.example, delta, len(setupExamples)) + // THE EXAMPLES GO ROUND. `→` on the last one is the first again, so a + // person browsing them never hits a wall they cannot see the reason + // for; the count on the panel's edge says where they are. + s.example = wrapCursor(s.example, delta, len(setupExamples)) // One of the two deliberate acts the demonstration plays for. a.restartSetupDemo() return true @@ -290,6 +293,15 @@ func (a *app) setupControlsKey(name, text string) bool { return true } +// wrapCursor walks a ring of count rows by delta, coming round at both ends — +// the examples' walk, where [moveCursor]'s clamp is a list's. +func wrapCursor(cursor, delta, count int) int { + if count <= 0 { + return 0 + } + return ((cursor+delta)%count + count) % count +} + // dropLast takes one rune off the end of what has been typed. func dropLast(text string) string { runes := []rune(text) @@ -390,6 +402,7 @@ func (a *app) setupControlsEnter() bool { if !a.commitSetupLimit() { return false } + s.answered[controlLimit] = true s.focusControl(a, 1) return false @@ -406,6 +419,7 @@ func (a *app) setupControlsEnter() bool { // pressing enter to get through the form never got past this row. // A refusal keeps the focus here so it can be read. if s.refusal == "" { + s.answered[controlChatModel] = true s.focusControl(a, 1) } return false @@ -432,6 +446,7 @@ func (a *app) setupControlsEnter() bool { return false } s.reviewOpen = true + s.answered[controlReview] = true return false } // `Start a conversation` — everything the screen holds is written down, and @@ -1386,22 +1401,28 @@ func (a *app) setupControlLead(control setupControl) string { return " " } +// setupLabelInk is the one rule for how a row's name is painted, on every row +// of the form: the ACCENT while the focus is on it, DIM once enter has answered +// it, and the body INK until then. A name is never decoration — it is what tells +// a person what the figure beside it means — so the three states are the three +// facts about it a person needs at a glance: this one, done, still to do. +func (a *app) setupLabelInk(control setupControl) func(string) string { + s := &a.setup + switch { + case s.control == control: + return a.pal.accent + case s.answered[control]: + return a.pal.dim + } + return a.pal.ink +} + // setupFieldRow is one label-and-value row of the form: the label in its column, // the value in bold, and — where a row has one — a dim word for where the value // came from. func (a *app) setupFieldRow(width int, control setupControl, label, value, source string) string { pal := a.pal - name := padTo(label, controlLabelWidth) - if a.setup.control == control { - name = pal.ink(name) - } else { - // A LABEL IS NEVER DECORATION. `Daily limit` is what tells a person what - // the figure beside it means, and a form whose three labels were all at - // telemetry weight was a form nobody could scan. Dim on this screen is - // kept for where a value came from and for the keyboard legend — the - // metadata around the decision, not the decision. - name = pal.muted(name) - } + name := a.setupLabelInk(control)(padTo(label, controlLabelWidth)) if value == "" { // THE EMPTINESS LAW. A value nobody has resolved yet draws as nothing at // all rather than as a placeholder claiming one. @@ -1486,12 +1507,7 @@ func (a *app) setupLimitRow(width int) (string, int) { } room := max(width-2-controlLabelWidth, 1) shown = fit(shown, room) - name := padTo(controlLimitLabel, controlLabelWidth) - if s.control == controlLimit { - name = pal.ink(name) - } else { - name = pal.dim(name) - } + name := a.setupLabelInk(controlLimit)(padTo(controlLimitLabel, controlLabelWidth)) at := ansi.StringWidth(setupLead) + controlLabelWidth + ansi.StringWidth(shown) return a.setupControlLead(controlLimit) + name + pal.bold(pal.ink(shown)), at } @@ -1592,7 +1608,6 @@ const setupNoMatchWord = "nothing matches · backspace widens it" // and not on a guess: a profile that has written any of the three down is NOT // told they are defaults. func (a *app) setupReviewRow(width int) string { - pal := a.pal long, short := controlReviewDefaults, controlReviewDefaultsShort if a.setupOtherSettingsWritten() { long, short = controlReviewYours, controlReviewYoursShort @@ -1601,10 +1616,7 @@ func (a *app) setupReviewRow(width int) string { if ansi.StringWidth(long) > width-2 { word = short } - if a.setup.control == controlReview { - return a.setupControlLead(controlReview) + pal.ink(fit(word, width-2)) - } - return a.setupControlLead(controlReview) + pal.dim(fit(word, width-2)) + return a.setupControlLead(controlReview) + a.setupLabelInk(controlReview)(fit(word, width-2)) } // setupOtherSettingsWritten reports whether this profile has chosen any of the @@ -1671,11 +1683,9 @@ func (a *app) setupReviewRows(width int) ([]string, []string) { // obvious one. func (a *app) setupStartRow(width int) string { pal := a.pal - word := controlStartWord + word := a.setupLabelInk(controlStart)(controlStartWord) if a.setup.control == controlStart { - word = pal.bold(pal.ink(word)) - } else { - word = pal.dim(word) + word = pal.bold(word) } row := a.setupControlLead(controlStart) + word const cap = "enter" diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index c349930ca8..15a74d83a3 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -1206,3 +1206,74 @@ func TestAnExpiredKeyIsSaidUnderTheModelRowWithoutCuttingTheList(t *testing.T) { t.Fatalf("an expired key was spelled as a low account:\n%s", screen) } } + +// THE EXAMPLES GO ROUND: `→` on the last one is the first, `←` on the first is +// the last, and the count on the edge says where you are. +func TestTheExamplesWrapAroundInBothDirections(t *testing.T) { + a, _ := controlsApp(t, nil) + a.setup.example = len(setupExamples) - 1 + pressSetup(a, key("right")) + if a.setup.example != 0 { + t.Fatalf("→ on the last example landed on %d, want the first", a.setup.example) + } + pressSetup(a, key("left")) + if a.setup.example != len(setupExamples)-1 { + t.Fatalf("← on the first example landed on %d, want the last", a.setup.example) + } + a.settleSetupDemo() + count := strings.Join(strings.Fields(setupShowcaseCount(len(setupExamples)-1, len(setupExamples))), " ") + if !strings.Contains(setupScreen(a), count) { + t.Fatalf("the edge does not say where the walk is:\n%s", setupScreen(a)) + } +} + +// A ROW'S NAME IS PAINTED BY ONE RULE ON EVERY ROW: the accent while the focus +// is on it, dim once enter has answered it, and the body ink until then. +func TestRowNamesAreAccentWhenFocusedDimWhenAnsweredAndInkUntilThen(t *testing.T) { + a, _ := controlsApp(t, nil) + a.models = func() []Model { return []Model{{ID: "openai/gpt-4.1-mini"}, {ID: "b/bravo"}} } + pal := a.pal + limit := padTo(controlLimitLabel, controlLabelWidth) + model := padTo(controlModelLabel, controlLabelWidth) + frame, _, _ := a.frame() + if !strings.Contains(frame, pal.accent(limit)) { + t.Fatalf("the focused limit's name is not the accent:\n%s", plain(frame)) + } + if !strings.Contains(frame, pal.ink(model)) { + t.Fatalf("the untouched model row's name is not the body ink:\n%s", plain(frame)) + } + pressSetup(a, key("enter")) // the limit, answered; the focus moves on + frame, _, _ = a.frame() + if !strings.Contains(frame, pal.dim(limit)) { + t.Fatalf("the answered limit's name is not dim:\n%s", plain(frame)) + } + if !strings.Contains(frame, pal.accent(model)) { + t.Fatalf("the focused model row's name is not the accent:\n%s", plain(frame)) + } + // Opening the list and leaving it with esc answers nothing. + pressSetup(a, key("enter"), key("esc")) + if frame, _, _ = a.frame(); !strings.Contains(frame, pal.accent(model)) { + t.Fatalf("esc out of the list changed the focused row's paint:\n%s", plain(frame)) + } + pressSetup(a, key("enter"), key("enter")) // open, take the model in use; the focus moves on + frame, _, _ = a.frame() + if !strings.Contains(frame, pal.dim(model)) { + t.Fatalf("the answered model row's name is not dim:\n%s", plain(frame)) + } + if !strings.Contains(frame, pal.accent(fit(controlReviewDefaults, setupFormWidth-2))) { + t.Fatalf("the focused review row's name is not the accent:\n%s", plain(frame)) + } + if !strings.Contains(frame, pal.ink(controlStartWord)) { + t.Fatalf("the untouched way out is not the body ink:\n%s", plain(frame)) + } + // Walking back onto an answered row paints it the accent again; leaving + // it, dim again. + pressSetup(a, key("up")) + if frame, _, _ = a.frame(); !strings.Contains(frame, pal.accent(model)) { + t.Fatalf("the answered row under the focus is not the accent:\n%s", plain(frame)) + } + pressSetup(a, key("down")) + if frame, _, _ = a.frame(); !strings.Contains(frame, pal.dim(model)) { + t.Fatalf("the answered row is not dim again once left:\n%s", plain(frame)) + } +} From 0c6c328505eac6515e12045e61a7a9f236c833c1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:11:47 -0400 Subject: [PATCH 10/26] chat: the setup's model list is a flat list of exact ids Each row was the catalog's friendly name with the exact id drawn under the cursor's row, which read as a heading with a subheading and made the one row a person could act on two rows tall. Each row is the id now, one row per model; typing still finds a model by its friendly name and the field above still shows it. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 1 + internal/manual/chat/getting-started.md | 3 ++- internal/tui3/onboarding.go | 22 ++++++++++-------- internal/tui3/onboarding_test.go | 23 ++++++++++++++++++- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 6b4932b4ff..b11c6107de 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -5,6 +5,7 @@ pr: 1720 surface: [chat, docs] invalidates: - "On the first conversation's screen, enter on a starting point filled the box and the next enter did nothing — the starting point kept taking it. With words in the box, enter now sends them: ↓ enter enter sends the starting point's sentence." + - "The setup's model list drew each model's friendly name with the exact id on a second row under the cursor's model. It is a flat list of exact ids now, one row per model; the friendly name is still searched by typing and still shown on the Chat model field." - "On the setup's controls screen a row's name was the body ink when focused and muted otherwise, and `←`/`→` stopped at the first and last example. A name is now the accent while focused, dim once enter has acted on the row (the limit set, a model taken, the review shown; setupFlow.answered), and the body ink until then, on every row including `Start a conversation`; and the examples go round in both directions." - "The setup's controls screen was headed `Models and spending` over `Keep these choices or change them.`, with the keys line at the foot of the form. Its heading is the one line `Basic settings`, and the keys line is the row directly under it. The example panel no longer carries `An illustration. Nothing here has run.` at its foot (its `Example` label says so), and the keys line no longer says `←→ examples` — the panel's own bottom edge carries the arrows." - "The Models and spending setup screen swallowed every mouse press. A press on a row is now the key that row would take — the limit focuses, the chat model opens its list, a model in the list is taken, the review shows, `Start a conversation` leaves — and the wheel scrolls the open list." diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 7e69f88d51..b967e4d9b9 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -179,7 +179,8 @@ conversation.*, and `?` adds the exact catalog id, that it also handles this conversation's tool use, and that tasks get their own crew, picked per task. Opening the row draws the real catalog: five rows at a time, `↑`/`↓` scroll the rest past, and **typing narrows it**, so two hundred models are reachable from a form with five rows on -it. The row under the cursor shows its exact id. The model you are already on is always on +it. Each row is the model's exact catalog id — `qwen/qwen3.8-27b:free` — one flat list, no +friendly names (typing still finds a model by its friendly name). The model you are already on is always on that list and the cursor opens on it, even with no catalog yet, so accepting confirms rather than changes. Choosing one goes through the same settings row `/model` writes and is kept for the next launch, and `enter` then goes on to the next row, as it does on the diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 59820f4743..fa76202de3 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -1513,13 +1513,19 @@ func (a *app) setupLimitRow(width int) (string, int) { } // setupModelRows is the list standing under the chat-model row: five rows of the -// catalog with the cursor's exact id under it, and a count that says how much +// catalog, EACH ITS EXACT ID AND NOTHING ELSE, and a count that says how much // more there is and how to reach it. A machine with no catalog at all still shows // the model in use, so enter confirms rather than changes. // +// IT IS A FLAT LIST OF IDS. Until 2026-10-01 each row was the catalog's friendly +// name (`Qwen3.8 27b:free`) with the exact id drawn under the cursor's row only, +// which read as a heading with a subheading and made the one row a person could +// act on two rows tall. The id is the name a person pastes, types after /model +// and sees in the catalog; the friendly name is still what the filter searches +// ([app.setupModelChoices]) and what the field above shows. +// // It answers the rows and, row for row, what each is a door onto for a press: -// a model's name row and the id row under the cursor's model both choose that -// model, and the count line chooses nothing. +// a model's row chooses that model, and the count line chooses nothing. func (a *app) setupModelRows(width int) ([]string, []setupDoor) { pal := a.pal s := &a.setup @@ -1534,17 +1540,13 @@ func (a *app) setupModelRows(width int) ([]string, []setupDoor) { out := make([]string, 0, setupModelSlots+2) doors := make([]setupDoor, 0, setupModelSlots+2) for i := top; i < len(models) && i < top+setupModelSlots; i++ { - name := modelWord(models[i].ID) door := setupDoor{kind: doorModel, control: controlChatModel, model: i} if i == s.modelAt { - out = append(out, " "+pal.accent(setupLead)+pal.bold(pal.ink(fit(name, width-6)))) - // THE EXACT ID, UNDER THE ONE ROW IT IS ABOUT. The list reads as names - // and the address is still on the screen for whoever needs it. - out = append(out, strings.Repeat(" ", 6)+pal.dim(fit(models[i].ID, width-6))) - doors = append(doors, door, door) + out = append(out, " "+pal.accent(setupLead)+pal.bold(pal.ink(fit(models[i].ID, width-6)))) + doors = append(doors, door) continue } - out = append(out, " "+pal.dim(fit(name, width-6))) + out = append(out, " "+pal.dim(fit(models[i].ID, width-6))) doors = append(doors, door) } out = append(out, strings.Repeat(" ", 4)+pal.dim(fit(a.setupModelCountWord(len(models)), width-4))) diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 15a74d83a3..3906ebce4f 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -1017,7 +1017,7 @@ func TestAClickOnTheControlsScreenActsLikeTheKeyOnThatRow(t *testing.T) { t.Fatalf("a press on the model row left the focus on %v with the list open=%v", a.setup.control, a.setup.modelOpen) } // A press on one row of the list takes that model and goes on. - x, y = setupRowOf(t, a, modelWord("c/charlie")) + x, y = setupRowOf(t, a, "c/charlie") pressSetup(a, clickAt(x, y)) if a.model != "c/charlie" { t.Fatalf("a press on a model row put the conversation on %q, want c/charlie", a.model) @@ -1277,3 +1277,24 @@ func TestRowNamesAreAccentWhenFocusedDimWhenAnsweredAndInkUntilThen(t *testing.T t.Fatalf("the answered row is not dim again once left:\n%s", plain(frame)) } } + +// THE LIST IS FLAT: one row per model, the exact id on it, and no name row over +// it. The cursor's row is not two rows tall. +func TestTheModelListIsAFlatListOfExactIds(t *testing.T) { + a, _ := controlsApp(t, nil) + a.models = func() []Model { return []Model{{ID: "openai/gpt-4.1-mini"}, {ID: "qwen/qwen3.8-27b:free"}} } + walkToControl(t, a, controlChatModel) + pressSetup(a, key("enter")) + rows, _ := a.setupModelRows(setupFormWidth) + if len(rows) != 3 { + t.Fatalf("two models drew %d rows, want one each and the count line:\n%s", len(rows), plain(strings.Join(rows, "\n"))) + } + for i, id := range []string{"openai/gpt-4.1-mini", "qwen/qwen3.8-27b:free"} { + if row := plain(rows[i]); !strings.Contains(row, id) || strings.Contains(row, modelWord(id)+" ") { + t.Fatalf("row %d is %q, want the exact id %q alone", i, row, id) + } + } + if screen := setupScreen(a); strings.Contains(screen, "Qwen3.8 27b:free") && !strings.Contains(screen, "Chat model Qwen3.8 27b:free") { + t.Fatalf("a friendly name is still drawn in the list:\n%s", screen) + } +} From b4ab76f2cbd084c30ab33e6c2da439d681ad986c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:38:05 -0400 Subject: [PATCH 11/26] chat: the setup's review row is gone, and a dim note under the way out points at /settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Other settings use defaults · review` showed three settings and let nobody change them — a door painted on a wall, and people pressed on it. The form is three rows now: the limit, the chat model, the way out. Under `Start a conversation` one dim line reads `Everything else is in /settings`, with the command painted as the composer's chip. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 1 + internal/manual/chat/getting-started.md | 36 ++-- internal/tui3/firstrun.go | 6 +- internal/tui3/onboarding.go | 157 +++--------------- internal/tui3/onboarding_test.go | 103 +++++------- 5 files changed, 87 insertions(+), 216 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index b11c6107de..775c2bd219 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -5,6 +5,7 @@ pr: 1720 surface: [chat, docs] invalidates: - "On the first conversation's screen, enter on a starting point filled the box and the next enter did nothing — the starting point kept taking it. With words in the box, enter now sends them: ↓ enter enter sends the starting point's sentence." + - "The setup's controls screen had a row `Other settings use defaults · review` (or `Review other settings`) between the chat model and `Start a conversation`, which enter opened into three read-only rows (memory, ask before running, task countdown) and the line `/settings changes these and every other one.`. The row is gone — the form is three rows: the limit, the chat model, the way out — and one dim line under `Start a conversation` reads `Everything else is in /settings`, with `/settings` painted as the composer's command chip. controlReview, setupFlow.reviewOpen, setupReviewRow, setupReviewRows, setupReviewKeys and setupOtherSettingsWritten are gone." - "The setup's model list drew each model's friendly name with the exact id on a second row under the cursor's model. It is a flat list of exact ids now, one row per model; the friendly name is still searched by typing and still shown on the Chat model field." - "On the setup's controls screen a row's name was the body ink when focused and muted otherwise, and `←`/`→` stopped at the first and last example. A name is now the accent while focused, dim once enter has acted on the row (the limit set, a model taken, the review shown; setupFlow.answered), and the body ink until then, on every row including `Start a conversation`; and the examples go round in both directions." - "The setup's controls screen was headed `Models and spending` over `Keep these choices or change them.`, with the keys line at the foot of the form. Its heading is the one line `Basic settings`, and the keys line is the row directly under it. The example panel no longer carries `An illustration. Nothing here has run.` at its foot (its `Example` label says so), and the keys line no longer says `←→ examples` — the panel's own bottom edge carries the arrows." diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index b967e4d9b9..8cd6117139 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -188,8 +188,7 @@ limit — so pressing `enter` alone walks the whole form down to `Start a conver **The rows take the mouse too.** A click on a row is the key that row would have taken: a click on the limit focuses it to type into, a click on the chat model opens its list, a -click on one model of the list takes it, a click on the review shows it, and a click on -`Start a conversation` leaves. The wheel scrolls the open list. A click on a sentence, a +click on one model of the list takes it, and a click on `Start a conversation` leaves. The wheel scrolls the open list. A click on a sentence, a blank row or the example panel does nothing. **With no credit on the OpenRouter account, the list shows free models only.** When the @@ -225,10 +224,12 @@ example's own title (`Understand an unfamiliar project`, `Hand off something lon `Follow the work and its cost`, `Compare the options`, `Hand off complex coding tasks`); its bottom edge carries `← 3 / 5 →`, the arrows that browse it. It is as wide as the screen allows, up to 92 columns, so the request in it stands on one row. It holds one request you could -type and what it leads to, and follows the row you are on: on the review row it shows -`/task Fix the failing tests and explain the changes.`, and on `Start a conversation` it -shows `/senior-dev Add retries with backoff to the HTTP client, with tests.` — a command -in a request is painted as the same chip the message box paints a recognised command with. +type and what it leads to, and follows the row you are on: beside the limit it shows +`What has this cost me so far today?`, and on `Start a conversation` it shows +`/senior-dev Add retries with backoff to the HTTP client, with tests.` — a command in a +request is painted as the same chip the message box paints a recognised command with. The +other three (`Understand an unfamiliar project`, `Hand off something longer` with +`/task Fix the failing tests and explain the changes.`, `Compare the options`) are a `→` away. That request **types itself out once** on arriving and on `←`/`→`, then settles; typing settles it at once. `←`/`→` go round: `→` on the last example is the first again. Two blank rows separate the panel from the form's heading. On a window too short to hold the form and the whole panel — 24 rows, say — the panel is not drawn and @@ -236,10 +237,10 @@ the form is unchanged. (Until 2026-10-01 the panel was a second column to the ri form, drawn only from 112 columns up, and carried `An illustration. Nothing here has run.` at its foot; the label on its edge now says that once.) -**A row's name says where you are.** Each name — `Daily limit`, `Chat model`, the review -row, `Start a conversation` — is in the body colour until you have answered it, blue while +**A row's name says where you are.** Each name — `Daily limit`, `Chat model`, +`Start a conversation` — is in the body colour until you have answered it, blue while you are on it, and grey once `enter` has acted on it: the limit set, a model taken from the -list, the review shown. Walking back onto an answered row makes it blue again while you are +list. Walking back onto an answered row makes it blue again while you are there. Opening the list and leaving it with `esc` answers nothing. The keys line is the form's second row, directly under `Basic settings`. It reads `enter @@ -306,17 +307,12 @@ question there, because none can be answered usefully before you have seen codea anything. They are taught where they happen: the countdown is on the task card, and the first permission question explains the actual tool that asked for something. -Under the two fields is one row that shows them: **`Other settings use defaults · -review`** on a fresh profile, and **`Review other settings`** on a profile that has -already written any of them down — it never claims your own settings are defaults. -`enter` on that row opens three read-only rows straight off the settings registry: - -``` - memory on - ask before running prompt - task countdown 15s - /settings changes these and every other one. -``` +Under `Start a conversation` one dim line reads **`Everything else is in /settings`**, with +`/settings` painted as a command chip; that is the whole of what the screen says about the +settings it does not ask about. (Until 2026-10-01 a row `Other settings use defaults · +review` stood between the chat model and the way out and opened three read-only rows — +memory, ask before running, task countdown; a row that showed settings and let nobody change +them was removed.) Per-plan approval, the per-conversation ceiling, individual crew seats, reasoning, routing, extra provider keys, concurrency and appearance are all deliberately absent from diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index 6e1f916cf5..0eb007ecb6 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -93,9 +93,8 @@ type setupFlow struct { // viewport and a filter over the WHOLE catalog rather than a truncation // of it, because a form with five rows must still reach two hundred // models. - // - reviewOpen is the optional reading of the settings this screen - // deliberately does not ask about, example is which illustration the - // right-hand column is showing, and seeded says the screen has already + // - example is which illustration the panel is showing, and seeded says + // the screen has already // been read from the profile once — so coming back from the step behind // it does not throw away what was typed. control setupControl @@ -110,7 +109,6 @@ type setupFlow struct { modelAt int modelTop int modelFind string - reviewOpen bool example int seeded bool // The example panel's one-shot demonstration (onboarding.go): demoAt is diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index fa76202de3..7f0b2b05dd 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -68,7 +68,6 @@ type setupControl int const ( controlLimit setupControl = iota controlChatModel - controlReview controlStart ) @@ -123,29 +122,22 @@ const ( " Tasks get their own crew, picked per task · /crew shows it." ) -// The two spellings of the row under the fields. A fresh profile is told these -// are defaults, because they are; a profile that has written any of them down is -// NOT told that, because it would be false — and the review then shows the actual -// values either way. -// AND EACH HAS A SHORT SPELLING FOR A NARROW WINDOW. `Other settings use -// defaults · r…` is a row that has stopped saying anything; a shorter true -// sentence is always better than a longer one with its end cut off. -const ( - controlReviewDefaults = "Other settings use defaults · review" - controlReviewDefaultsShort = "Other settings · review" - controlReviewYours = "Review other settings" - controlReviewYoursShort = "Other settings" -) - // controlStartWord is the way out, and it is named for what happens next rather // than for the form being finished. "Done" and "Save" describe the screen; // this describes the person's day. const controlStartWord = "Start a conversation" -// controlReviewRest is the line under the optional review: where these three -// live afterwards. It is the one door this screen offers onto everything it -// deliberately does not ask about. -const controlReviewRest = "/settings changes these and every other one." +// The note under the way out: where everything this screen does not ask about +// lives. It is one dim sentence with the command in it painted as the +// composer's chip, so the one word a person can act on is the one word that +// stands out. IT REPLACED A ROW. Until 2026-10-01 `Other settings use defaults +// · review` stood between the chat model and the way out and opened three +// read-only rows; a row that shows settings and lets nobody change them was a +// door painted on a wall, and people pressed on it. +const ( + controlSettingsNoteLead = "Everything else is in " + controlSettingsNoteCommand = "/settings" +) // controlPinnedLead prefixes a value an environment variable owns, and // controlFromLead a value one merely SEEDED. The two are different facts and the @@ -190,10 +182,6 @@ func (a *app) setupControlsKey(name, text string) bool { s.detail = false return true } - if s.reviewOpen { - s.reviewOpen = false - return true - } return false case "tab", "down", "ctrl+n": @@ -436,18 +424,6 @@ func (a *app) setupControlsEnter() bool { s.modelOpen = true a.filterSetupModels("") return false - - case controlReview: - // Enter shows the review, and enter on a review already showing goes - // on to the way out rather than folding it away: the rows stay readable - // and the form keeps moving under repeated enters. - if s.reviewOpen { - s.focusControl(a, 1) - return false - } - s.reviewOpen = true - s.answered[controlReview] = true - return false } // `Start a conversation` — everything the screen holds is written down, and // a row that refused keeps the screen up with its refusal on it. @@ -476,7 +452,7 @@ type setupDoors struct { // three keystrokes. It is a form now, with rows that look like rows and a list // that looks like a list, and the first thing a person who sees a list does is // click on it. So a press on a control is a tab to it, and a press on a row that -// enter would act on — the model field, the review, the way out, or one model of +// enter would act on — the model field, the way out, or one model of // the open list — is that enter. The limit row is only focused, because what a // press on an amount means is "I want to type here". A press anywhere else on // the screen — a sentence, a blank, the example — does nothing, which is what a @@ -761,7 +737,7 @@ func (a *app) startSetupControls() { s.control = controlLimit s.limitText, s.limitTyped = "", false s.closeChoosers() - s.reviewOpen, s.detail = false, false + s.detail = false s.example = exampleForControl(controlLimit) // ARRIVING ON THE SCREEN IS THE FIRST OF THE TWO DELIBERATE ACTS, so the // panel plays once here. Coming BACK from the step behind this one does not @@ -874,12 +850,6 @@ func exampleForControl(control setupControl) int { // to start a conversation is shown that a whole change can be handed // off, which is the one capability nothing above has hinted at. return 4 - case controlReview: - // THE HAND-OFF EXAMPLE stood beside the crew row, which is gone: a task's - // crew is picked per task now and asks nothing here. The review row is - // the door to every setting this screen does not ask about, the crew's - // among them, so the hand-off stands beside it. - return 1 } return 0 } @@ -1188,8 +1158,11 @@ type controlsRank struct{ rank, id int } // screen because somebody pressed `?` for it. const ( rankSpacer = iota + 1 + // rankNote is the sentence under the way out, which a short window gives + // up right after the blank rows: it points somewhere else, and a person on + // a short window has the rest of the form to read first. + rankNote rankOtherWords - rankReviewRest rankFocusedWords rankDetail ) @@ -1323,15 +1296,6 @@ func (a *app) setupControlsForm(width, legend int) *controlsSheet { } f.soft(rankSpacer, "") - // ── the optional review ── - f.open(setupDoor{kind: doorControl, control: controlReview}, a.setupReviewRow(width)) - if s.reviewOpen { - rows, rest := a.setupReviewRows(width) - f.add(rows...) - f.soft(rankReviewRest, rest...) - } - f.soft(rankSpacer, "") - // ── the way out ── f.open(setupDoor{kind: doorControl, control: controlStart}, a.setupStartRow(width)) if s.refusal != "" { @@ -1343,9 +1307,19 @@ func (a *app) setupControlsForm(width, legend int) *controlsSheet { } } f.soft(rankSpacer, "") + f.soft(rankNote, a.setupSettingsNote(width)) return f } +// setupSettingsNote is the dim sentence under the way out, with the command in +// it wearing the chip the message box paints a recognised command with — the +// same paint, so the word reads as something to type rather than as prose. +func (a *app) setupSettingsNote(width int) string { + pal := a.pal + lead := fit(controlSettingsNoteLead, max(width-2-len(controlSettingsNoteCommand), 1)) + return " " + pal.dim(lead) + pal.chip(controlSettingsNoteCommand) +} + // addControlWords adds a field's one-line explanation, and — only where `?` asked // for it on the field with the focus — the detail under it. func (a *app) addControlWords(f *controlsSheet, width int, control setupControl, word, detail string) { @@ -1606,78 +1580,6 @@ const setupNoCatalogWord = "no model list on this machine yet · /model finds on // setupNoMatchWord is a filter that matched nothing. const setupNoMatchWord = "nothing matches · backspace widens it" -// setupReviewRow is the row under the fields. Its wording depends on the profile -// and not on a guess: a profile that has written any of the three down is NOT -// told they are defaults. -func (a *app) setupReviewRow(width int) string { - long, short := controlReviewDefaults, controlReviewDefaultsShort - if a.setupOtherSettingsWritten() { - long, short = controlReviewYours, controlReviewYoursShort - } - word := long - if ansi.StringWidth(long) > width-2 { - word = short - } - return a.setupControlLead(controlReview) + a.setupLabelInk(controlReview)(fit(word, width-2)) -} - -// setupOtherSettingsWritten reports whether this profile has chosen any of the -// three the review shows. It reads the registry's own record of what is written -// down ([config.Settings.PersistedKeys]) rather than comparing values to -// defaults, because a person who deliberately set a row to its default has still -// chosen it. -func (a *app) setupOtherSettingsWritten() bool { - written := a.registry().PersistedKeys() - for _, key := range setupReviewKeys { - for _, have := range written { - if have == key { - return true - } - } - } - return false -} - -// setupReviewKeys are the three the optional review shows, in the order it shows -// them. They are the three the design deliberately does NOT make into questions: -// memory is on and useful before anybody has an opinion, permissions cannot be -// configured before a person has seen a tool ask for something, and the countdown -// belongs beside the actual countdown. -var setupReviewKeys = []string{ - config.KeyMemoryEnabled, - config.KeyToolApprovalMode, - config.KeyTaskAutoApprove, -} - -// setupReviewRows is the review itself: three rows read straight off the -// registry, so what it shows is what /settings shows and never a claim that a -// configured profile is on defaults. It answers the rows and the closing line -// separately, because the line is the one part a short window may give up. -func (a *app) setupReviewRows(width int) ([]string, []string) { - pal := a.pal - out := make([]string, 0, len(setupReviewKeys)) - // The label column is measured against the labels themselves rather than - // borrowed from the fields above: `ask before running` is eighteen cells and - // ran straight into its own value at the field column's seventeen, which is - // how `ask before runningprompt` reached a screen. - names := 0 - for _, key := range setupReviewKeys { - if row, ok := a.registry().Row(key); ok { - names = max(names, ansi.StringWidth(row.Label)+2) - } - } - for _, key := range setupReviewKeys { - row, ok := a.registry().Row(key) - if !ok { - continue - } - out = append(out, strings.Repeat(" ", 4)+pal.dim(padTo(row.Label, names))+ - pal.ink(fit(row.Reading(), max(width-4-names, 1)))) - } - rest := []string{strings.Repeat(" ", 4) + pal.dim(fit(controlReviewRest, width-4))} - return out, rest -} - // setupStartRow is the primary action, with the key that takes it on the right. // It is a row of the same form rather than a bright panel: the accent on this // screen belongs to whatever the person is standing on, and an action that @@ -1731,11 +1633,6 @@ func (a *app) setupControlsKeys(width int) string { parts = []string{"enter sets the limit", setupMovesWord, a.setupBackWord(), "type an amount or none"} case controlChatModel: parts = []string{"enter opens the list", setupMovesWord, a.setupBackWord()} - case controlReview: - parts = []string{"enter shows them", setupMovesWord, a.setupBackWord()} - if s.reviewOpen { - parts[0] = "enter goes on" - } case controlStart: parts = []string{"enter starts", setupMovesWord, a.setupBackWord()} } diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 3906ebce4f..a03b6ba760 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -107,8 +107,8 @@ func TestTheModelListReachesTheWholeCatalogAndOpensOnTheModelInUse(t *testing.T) if a.model != was { t.Fatalf("enter on the model in use switched to %q", a.model) } - if a.setup.modelOpen || a.setup.control != controlReview { - t.Fatalf("after taking a model the focus is on control %v with the list open=%v, want the review row and the list closed", a.setup.control, a.setup.modelOpen) + if a.setup.modelOpen || a.setup.control != controlStart { + t.Fatalf("after taking a model the focus is on control %v with the list open=%v, want the way out and the list closed", a.setup.control, a.setup.modelOpen) } // The last row of the catalog is reachable by walking, and the count says // how far there is to go. @@ -322,39 +322,6 @@ func TestATaskModelOverrideIsSaid(t *testing.T) { } } -// THE REVIEW ROW NEVER CALLS SOMEBODY'S OWN SETTINGS DEFAULTS, and what it opens -// is read off the registry rather than written here twice. -func TestTheReviewRowTellsDefaultsFromChoices(t *testing.T) { - a, _ := controlsApp(t, nil) - if screen := setupScreen(a); !strings.Contains(screen, controlReviewDefaults) { - t.Fatalf("a fresh profile must be told these are defaults; got:\n%s", screen) - } - b, dir := controlsApp(t, func(dir string) { - seedRow(t, dir, config.KeyMemoryEnabled, "off") - }) - screen := setupScreen(b) - if strings.Contains(screen, controlReviewDefaults) { - t.Fatalf("a configured profile was told its settings are defaults:\n%s", screen) - } - if !strings.Contains(screen, controlReviewYours) { - t.Fatalf("a configured profile must be offered the review; got:\n%s", screen) - } - walkToControl(t, b, controlReview) - pressSetup(b, key("enter")) - // The value it shows is the registry's own reading of the row, which is what - // /settings shows for the same profile. - row, ok := b.registry().Row(config.KeyMemoryEnabled) - if !ok { - t.Fatal("the registry lost the memory row") - } - if got := row.Reading(); got != config.MemoryAt(dir) { - t.Fatalf("the row reads %q and the profile says %q", got, config.MemoryAt(dir)) - } - if !strings.Contains(setupScreen(b), row.Reading()) { - t.Fatalf("the review must show the actual value %q; got:\n%s", row.Reading(), setupScreen(b)) - } -} - // THE SCREEN IS USABLE AT EVERY WIDTH THE DESIGN NAMES, and "usable" is three // concrete things: the values are legible, the way out is on the frame, and the // keyboard line is there and is not cut in the middle of a word. @@ -510,14 +477,14 @@ func TestTheExampleColumnIsLabelledFollowsTheFocusAndHidesWhenNarrow(t *testing. if again := setupScreen(a); again != screen { t.Fatal("the example moved between two frames with no keypress") } - walkToControl(t, a, controlReview) - if want := setupExamples[exampleForControl(controlReview)].title; !strings.Contains(setupScreen(a), want) { - t.Fatalf("the review row's example is %q; got:\n%s", want, setupScreen(a)) + walkToControl(t, a, controlStart) + if want := setupExamples[exampleForControl(controlStart)].title; !strings.Contains(setupScreen(a), want) { + t.Fatalf("the way out's example is %q; got:\n%s", want, setupScreen(a)) } // ←/→ browse without touching anything. limit := a.setup.limitText pressSetup(a, key("right")) - if a.setup.example == exampleForControl(controlReview) { + if a.setup.example == exampleForControl(controlStart) { t.Fatal("→ did not move the example") } if a.setup.limitText != limit { @@ -949,7 +916,7 @@ func TestAFolderWithEarlierConversationsGetsTheOrdinaryGreeting(t *testing.T) { // ENTER GETS A PERSON THROUGH THE WHOLE FORM. Each row answers enter by going on // to the next: the limit commits and moves, the model row opens its list and a -// taken model moves, the review opens and then moves, and the way out starts. +// taken model moves, and the way out starts. // An earlier build left the focus on the model row after a choice, so the next // enter opened the list again and nothing a person did with enter alone ever // reached `Start a conversation`. @@ -962,19 +929,8 @@ func TestEnterAloneWalksTheWholeControlsScreen(t *testing.T) { } pressSetup(a, key("enter")) // opens the list pressSetup(a, key("enter")) // takes the model in use and goes on - if a.setup.control != controlReview || a.setup.modelOpen { - t.Fatalf("after the model the focus is on %v (list open=%v), want the review", a.setup.control, a.setup.modelOpen) - } - pressSetup(a, key("enter")) // shows the review - if !a.setup.reviewOpen || a.setup.control != controlReview { - t.Fatal("enter on the review row did not show it") - } - if screen := setupScreen(a); !strings.Contains(screen, "enter goes on") { - t.Fatalf("the legend on an open review must say enter goes on; got:\n%s", screen) - } - pressSetup(a, key("enter")) // goes on, leaving the review readable - if a.setup.control != controlStart || !a.setup.reviewOpen { - t.Fatalf("after the review the focus is on %v (review open=%v), want the way out with the review still showing", a.setup.control, a.setup.reviewOpen) + if a.setup.control != controlStart || a.setup.modelOpen { + t.Fatalf("after the model the focus is on %v (list open=%v), want the way out", a.setup.control, a.setup.modelOpen) } pressSetup(a, key("enter")) // starts if a.setup.open { @@ -1022,8 +978,8 @@ func TestAClickOnTheControlsScreenActsLikeTheKeyOnThatRow(t *testing.T) { if a.model != "c/charlie" { t.Fatalf("a press on a model row put the conversation on %q, want c/charlie", a.model) } - if a.setup.modelOpen || a.setup.control != controlReview { - t.Fatalf("after the press the focus is on %v (list open=%v), want the review", a.setup.control, a.setup.modelOpen) + if a.setup.modelOpen || a.setup.control != controlStart { + t.Fatalf("after the press the focus is on %v (list open=%v), want the way out", a.setup.control, a.setup.modelOpen) } // A press on the limit only focuses it: what a press on an amount means is // "I want to type here". @@ -1239,8 +1195,8 @@ func TestRowNamesAreAccentWhenFocusedDimWhenAnsweredAndInkUntilThen(t *testing.T if !strings.Contains(frame, pal.accent(limit)) { t.Fatalf("the focused limit's name is not the accent:\n%s", plain(frame)) } - if !strings.Contains(frame, pal.ink(model)) { - t.Fatalf("the untouched model row's name is not the body ink:\n%s", plain(frame)) + if !strings.Contains(frame, pal.ink(model)) || !strings.Contains(frame, pal.ink(controlStartWord)) { + t.Fatalf("an untouched row's name is not the body ink:\n%s", plain(frame)) } pressSetup(a, key("enter")) // the limit, answered; the focus moves on frame, _, _ = a.frame() @@ -1260,11 +1216,8 @@ func TestRowNamesAreAccentWhenFocusedDimWhenAnsweredAndInkUntilThen(t *testing.T if !strings.Contains(frame, pal.dim(model)) { t.Fatalf("the answered model row's name is not dim:\n%s", plain(frame)) } - if !strings.Contains(frame, pal.accent(fit(controlReviewDefaults, setupFormWidth-2))) { - t.Fatalf("the focused review row's name is not the accent:\n%s", plain(frame)) - } - if !strings.Contains(frame, pal.ink(controlStartWord)) { - t.Fatalf("the untouched way out is not the body ink:\n%s", plain(frame)) + if !strings.Contains(frame, pal.accent(controlStartWord)) { + t.Fatalf("the focused way out is not the accent:\n%s", plain(frame)) } // Walking back onto an answered row paints it the accent again; leaving // it, dim again. @@ -1298,3 +1251,29 @@ func TestTheModelListIsAFlatListOfExactIds(t *testing.T) { t.Fatalf("a friendly name is still drawn in the list:\n%s", screen) } } + +// THE NOTE UNDER THE WAY OUT POINTS AT /settings, with the command painted as +// the composer's chip, and nothing on the form claims to show the other +// settings: the review row that did is gone, because a row that shows +// settings and lets nobody change them is a door painted on a wall. +func TestTheNoteUnderTheWayOutPointsAtSettingsWithItsChip(t *testing.T) { + a, _ := controlsApp(t, nil) + frame, _, _ := a.frame() + screen := plain(frame) + note := controlSettingsNoteLead + controlSettingsNoteCommand + start, at := strings.Index(screen, controlStartWord), strings.Index(screen, note) + if start < 0 || at < 0 || at < start { + t.Fatalf("the note %q must stand under %q; got:\n%s", note, controlStartWord, screen) + } + if !strings.Contains(frame, a.pal.chip(controlSettingsNoteCommand)) { + t.Fatalf("the note does not paint %s as a command chip:\n%s", controlSettingsNoteCommand, screen) + } + for _, gone := range []string{"Other settings", "review"} { + if strings.Contains(screen, gone) { + t.Fatalf("the form still carries %q:\n%s", gone, screen) + } + } + if setupControlCount != 3 { + t.Fatalf("the form walks %d rows, want three: the limit, the chat model, the way out", setupControlCount) + } +} From adbd11f1dff028eaebe5a1dc38a998cbe69055ff Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:44:14 -0400 Subject: [PATCH 12/26] chat: the setup's explanations name /budget and /model, and the way out carries no loose enter The limit's line ends `/budget changes it later.` and the chat model's `/model changes it later.`, each command painted as the composer's chip the way the note's /settings is. The dim `enter` at the right of `Start a conversation` is gone: the keys line under the heading already says `enter starts` when the focus is there. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 1 + internal/manual/chat/getting-started.md | 6 ++-- internal/tui3/onboarding.go | 31 +++++++++---------- internal/tui3/onboarding_test.go | 24 ++++++++++++++ 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 775c2bd219..3582864296 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -5,6 +5,7 @@ pr: 1720 surface: [chat, docs] invalidates: - "On the first conversation's screen, enter on a starting point filled the box and the next enter did nothing — the starting point kept taking it. With words in the box, enter now sends them: ↓ enter enter sends the starting point's sentence." + - "On the setup's controls screen `Start a conversation` carried a dim `enter` at its right, and the two explanations did not name a command. The loose `enter` is gone (the keys line says `enter starts` there); the limit's line ends `/budget changes it later.` and the chat model's `/model changes it later.`, each command painted as the composer's chip." - "The setup's controls screen had a row `Other settings use defaults · review` (or `Review other settings`) between the chat model and `Start a conversation`, which enter opened into three read-only rows (memory, ask before running, task countdown) and the line `/settings changes these and every other one.`. The row is gone — the form is three rows: the limit, the chat model, the way out — and one dim line under `Start a conversation` reads `Everything else is in /settings`, with `/settings` painted as the composer's command chip. controlReview, setupFlow.reviewOpen, setupReviewRow, setupReviewRows, setupReviewKeys and setupOtherSettingsWritten are gone." - "The setup's model list drew each model's friendly name with the exact id on a second row under the cursor's model. It is a flat list of exact ids now, one row per model; the friendly name is still searched by typing and still shown on the Chat model field." - "On the setup's controls screen a row's name was the body ink when focused and muted otherwise, and `←`/`→` stopped at the first and last example. A name is now the accent while focused, dim once enter has acted on the row (the limit set, a model taken, the review shown; setupFlow.answered), and the body ink until then, on every row including `Start a conversation`; and the examples go round in both directions." diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 8cd6117139..1b13626d37 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -145,7 +145,9 @@ force — `$500` on a profile that has never chosen one, or your own figure if y Its one line reads: > When all codeaf spends today reaches this amount, new work waits until midnight or you -> raise it. +> raise it. /budget changes it later. + +`/budget` in the line is painted as a command chip, the way the message box paints one. Type a number to change it — the `$` is drawn for you rather than typed — or type **`none`** for no limit, which is a first-class answer and makes the row read `no limit`. @@ -175,7 +177,7 @@ when they start to matter. **Chat model** is the model you talk to, shown by name — `DeepSeek V4 Flash` rather than `deepseek/deepseek-v4-flash`. Its line reads *The model you talk to in this -conversation.*, and `?` adds the exact catalog id, that it also handles this +conversation. /model changes it later.* (`/model` worn as a command chip), and `?` adds the exact catalog id, that it also handles this conversation's tool use, and that tasks get their own crew, picked per task. Opening the row draws the real catalog: five rows at a time, `↑`/`↓` scroll the rest past, and **typing narrows it**, so two hundred models are reachable from a form with five rows on diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 7f0b2b05dd..31b46dc5e1 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -102,8 +102,8 @@ const controlLabelWidth = 19 // behind `?` on that control. const ( controlLimitWord = "When all " + product + " spends today reaches this amount, " + - "new work waits until midnight or you raise it." - controlModelWord = "The model you talk to in this conversation." + "new work waits until midnight or you raise it. /budget changes it later." + controlModelWord = "The model you talk to in this conversation. /model changes it later." ) // The detail behind `?`, on the field with the focus. @@ -1325,10 +1325,13 @@ func (a *app) setupSettingsNote(width int) string { func (a *app) addControlWords(f *controlsSheet, width int, control setupControl, word, detail string) { pal := a.pal indent := strings.Repeat(" ", 4) + // A COMMAND IN THE SENTENCE WEARS ITS CHIP — `/budget`, `/model` — the + // paint the message box gives a recognised command, so the one word a + // person can act on later is the one word that stands out in a dim line. paint := func(text string, ink func(string) string) []string { lines := wrap(text, width-4) for i, line := range lines { - lines[i] = indent + ink(line) + lines[i] = indent + paintCommandSpans(line, recognizedCommandSpans([]rune(line), true), pal, ink) } return lines } @@ -1580,23 +1583,19 @@ const setupNoCatalogWord = "no model list on this machine yet · /model finds on // setupNoMatchWord is a filter that matched nothing. const setupNoMatchWord = "nothing matches · backspace widens it" -// setupStartRow is the primary action, with the key that takes it on the right. -// It is a row of the same form rather than a bright panel: the accent on this -// screen belongs to whatever the person is standing on, and an action that -// glowed whether or not it had the focus would be two things competing to be the -// obvious one. +// setupStartRow is the primary action. It is a row of the same form rather than +// a bright panel: the accent on this screen belongs to whatever the person is +// standing on, and an action that glowed whether or not it had the focus would +// be two things competing to be the obvious one. The key that takes it is NOT +// written at its right any more: the keys line under the heading already says +// `enter starts` when the focus is here, and a second `enter` on the row was +// the same fact twice. func (a *app) setupStartRow(width int) string { - pal := a.pal word := a.setupLabelInk(controlStart)(controlStartWord) if a.setup.control == controlStart { - word = pal.bold(word) - } - row := a.setupControlLead(controlStart) + word - const cap = "enter" - if width > ansi.StringWidth(controlStartWord)+len(cap)+6 { - row = padTo(row, width-len(cap)) + pal.dim(cap) + word = a.pal.bold(word) } - return row + return a.setupControlLead(controlStart) + word } // setupControlsKeys is the legend at the foot: what the keys do RIGHT HERE, in diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index a03b6ba760..5a4c92811b 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -1277,3 +1277,27 @@ func TestTheNoteUnderTheWayOutPointsAtSettingsWithItsChip(t *testing.T) { t.Fatalf("the form walks %d rows, want three: the limit, the chat model, the way out", setupControlCount) } } + +// THE TWO EXPLANATIONS NAME THE COMMAND THAT CHANGES THE ROW LATER, each worn +// as the composer's chip, and the way out carries no loose `enter` at its right: +// the keys line already says `enter starts` there. +func TestExplanationsNameTheirCommandsAndTheWayOutHasNoLooseEnter(t *testing.T) { + a, _ := controlsApp(t, nil) + frame, _, _ := a.frame() + screen := plain(frame) + for _, want := range []string{"/budget changes it later", "/model changes it later"} { + if !strings.Contains(strings.Join(strings.Fields(screen), " "), want) { + t.Fatalf("the form does not say %q:\n%s", want, screen) + } + } + for _, cmd := range []string{"/budget", "/model"} { + if !strings.Contains(frame, a.pal.chip(cmd)) { + t.Fatalf("%s is not painted as a command chip:\n%s", cmd, screen) + } + } + for _, row := range strings.Split(screen, "\n") { + if strings.Contains(row, controlStartWord) && strings.Contains(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(row), controlStartWord)), "enter") { + t.Fatalf("the way out still carries a loose enter: %q", row) + } + } +} From b4dfddfd7deeab7ae0557eae83e5b62e6fdf6ef2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:52:27 -0400 Subject: [PATCH 13/26] chat: the setup's examples turn on their own clock, held by any key, and never follow the focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel followed the focused row, which read as it jumping about under a person walking the form. It opens on the first example and turns to the next every three seconds, round the ring; ←/→ browse by hand; any key — typing, walking the rows, browsing, a press — holds the clock for a full three seconds from that key; the screen-reader tier never turns by itself. Each arriving example plays its demonstration once. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 1 + internal/manual/chat/getting-started.md | 19 ++- internal/tui3/app.go | 9 +- internal/tui3/firstrun.go | 23 +-- internal/tui3/firstrun_test.go | 21 +-- internal/tui3/onboarding.go | 153 +++++++++++------- internal/tui3/onboarding_test.go | 112 ++++++++++--- 7 files changed, 226 insertions(+), 112 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 3582864296..1888de15e7 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -5,6 +5,7 @@ pr: 1720 surface: [chat, docs] invalidates: - "On the first conversation's screen, enter on a starting point filled the box and the next enter did nothing — the starting point kept taking it. With words in the box, enter now sends them: ↓ enter enter sends the starting point's sentence." + - "The setup's example panel followed the focused row (the limit showed `Follow the work and its cost`, `Start a conversation` the senior-dev hand-off) and never turned by itself. It opens on the first example and turns to the next every 3 seconds, round the ring; `←`/`→` browse by hand; any key — typing, walking the rows, browsing, a press — holds the clock for 3 seconds from that key; the focus never moves it; the screen-reader tier never turns by itself. exampleForControl is gone; setupFlow has turnGen and turnTicking, and setupTurnMsg is the clock." - "On the setup's controls screen `Start a conversation` carried a dim `enter` at its right, and the two explanations did not name a command. The loose `enter` is gone (the keys line says `enter starts` there); the limit's line ends `/budget changes it later.` and the chat model's `/model changes it later.`, each command painted as the composer's chip." - "The setup's controls screen had a row `Other settings use defaults · review` (or `Review other settings`) between the chat model and `Start a conversation`, which enter opened into three read-only rows (memory, ask before running, task countdown) and the line `/settings changes these and every other one.`. The row is gone — the form is three rows: the limit, the chat model, the way out — and one dim line under `Start a conversation` reads `Everything else is in /settings`, with `/settings` painted as the composer's command chip. controlReview, setupFlow.reviewOpen, setupReviewRow, setupReviewRows, setupReviewKeys and setupOtherSettingsWritten are gone." - "The setup's model list drew each model's friendly name with the exact id on a second row under the cursor's model. It is a flat list of exact ids now, one row per model; the friendly name is still searched by typing and still shown on the Chat model field." diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 1b13626d37..2eebb90c90 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -226,14 +226,17 @@ example's own title (`Understand an unfamiliar project`, `Hand off something lon `Follow the work and its cost`, `Compare the options`, `Hand off complex coding tasks`); its bottom edge carries `← 3 / 5 →`, the arrows that browse it. It is as wide as the screen allows, up to 92 columns, so the request in it stands on one row. It holds one request you could -type and what it leads to, and follows the row you are on: beside the limit it shows -`What has this cost me so far today?`, and on `Start a conversation` it shows -`/senior-dev Add retries with backoff to the HTTP client, with tests.` — a command in a -request is painted as the same chip the message box paints a recognised command with. The -other three (`Understand an unfamiliar project`, `Hand off something longer` with -`/task Fix the failing tests and explain the changes.`, `Compare the options`) are a `→` away. -That request **types itself out once** on arriving and on `←`/`→`, then settles; typing -settles it at once. `←`/`→` go round: `→` on the last example is the first again. Two blank rows separate the panel from the form's heading. On a window +type and what it leads to. It opens on the first (`Understand an unfamiliar project`) and +**turns to the next by itself every 3 seconds**, round and round through the five +(`Hand off something longer` with `/task Fix the failing tests and explain the changes.`, +`Follow the work and its cost`, `Compare the options`, `Hand off complex coding tasks` +with `/senior-dev Add retries with backoff to the HTTP client, with tests.`); a command in +a request is painted as the same chip the message box paints a recognised command with. +`←`/`→` browse by hand and go round the same ring. **Any key holds the clock** for 3 +seconds from that key — typing an amount, walking the rows, browsing — so the panel never +turns under your hands. Walking the rows does not move it (until 2026-10-01 it followed the +row you were on). Each example **types itself out once** on arriving, then settles; typing +settles it at once. Two blank rows separate the panel from the form's heading. On a window too short to hold the form and the whole panel — 24 rows, say — the panel is not drawn and the form is unchanged. (Until 2026-10-01 the panel was a second column to the right of the form, drawn only from 112 columns up, and carried `An illustration. Nothing here has run.` diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 59596fe72c..cccab757fb 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3423,7 +3423,7 @@ func (a *app) Init() tea.Cmd { // AND THE SETUP SCREEN'S EXAMPLE PANEL, when the setup is the first frame // and the controls screen is its first step. It answers nil in every other // case, which is most launches (onboarding.go). - a.setupDemoCmd(), a.checkForUpdate(), a.launchCredits(), a.creditWake.waitRing(), titleSend(a.titleSent), + a.setupDemoCmd(), a.setupTurnCmd(), a.checkForUpdate(), a.launchCredits(), a.creditWake.waitRing(), titleSend(a.titleSent), // AND THE TWO DOORS INTO THE LOOP FROM ELSEWHERE, each with its one // command parked on it (doorbell.go). a.news.waitRing(), a.leaving.waitRing(), a.landedBell.waitRing(), @@ -4360,7 +4360,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Mouse().Button == tea.MouseLeft && a.setupPress(msg.Mouse().X, msg.Mouse().Y) { return a, a.endSetup(false) } - return a, nil + return a, a.holdSetupTurn() } if msg.Mouse().Button == tea.MouseLeft { // THE NAV IS READ BEFORE EVERY PAGE'S OWN ROWS, because it is the @@ -5169,6 +5169,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // field on the left (onboarding.go). return a, a.setupDemoBeatAt(msg.gen) + case setupTurnMsg: + // The setup panel's turn: the next example arrives and plays, and the + // clock is armed again — unless a key retired this tick (onboarding.go). + return a, a.setupTurnAt(msg.gen) + case taskPilotMsg: return a, a.pilotEvent(msg) diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index 0eb007ecb6..21b588c07f 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -118,6 +118,11 @@ type setupFlow struct { demoAt int demoGen int demoTicking bool + // The turn's clock (onboarding.go's [app.setupTurnCmd]): turnGen stamps + // the ticks so one armed before a key is dropped, and turnTicking says one + // is in flight. + turnGen int + turnTicking bool // refusal is the one line the screen says under the box when enter was // pressed on something it will not write. Any other key clears it. refusal string @@ -410,11 +415,11 @@ func (a *app) setupControlsPress(name, text string) (tea.Cmd, bool) { return a.endSetup(false), true } a.touch() - return nil, true + return a.holdSetupTurn(), true case "esc": if a.setupControlsKey(name, text) { a.touch() - return nil, true + return a.holdSetupTurn(), true } if s.at > 0 { s.at-- @@ -427,12 +432,12 @@ func (a *app) setupControlsPress(name, text string) (tea.Cmd, bool) { } a.setupControlsKey(name, text) a.touch() - // AND THE EXAMPLE PANEL'S CLOCK IS ARMED FROM HERE, once, after the key has - // been dealt with. [app.setupDemoCmd] answers nil in every state that should - // not have a beat — finished, already ticking, off this screen, or the - // screen-reader tier — so this line is safe on every key rather than only on - // the two that start it (onboarding.go). - return a.setupDemoCmd(), true + // AND THE PANEL'S TWO CLOCKS ARE DEALT WITH FROM HERE, once, after the key + // has been handled: the demonstration's beat is armed where one is owed + // ([app.setupDemoCmd] answers nil everywhere else), and the turn is held + // for a full interval from this key ([app.holdSetupTurn]), so the panel + // never turns under a person's hands (onboarding.go). + return tea.Batch(a.setupDemoCmd(), a.holdSetupTurn()), true } // advanceSetup moves past one answered step and closes the screen after the @@ -452,7 +457,7 @@ func (a *app) advanceSetup() tea.Cmd { if s.step() == setupControls { a.startSetupControls() a.touch() - return a.setupDemoCmd() + return tea.Batch(a.setupDemoCmd(), a.setupTurnCmd()) } a.touch() return nil diff --git a/internal/tui3/firstrun_test.go b/internal/tui3/firstrun_test.go index f9511bbd41..84dded1d1a 100644 --- a/internal/tui3/firstrun_test.go +++ b/internal/tui3/firstrun_test.go @@ -160,22 +160,23 @@ func TestEnterConnectsOpenRouterInTheBrowserAndHandsTheKeyToThisProcess(t *testi if screen := setupScreen(a); !strings.Contains(screen, "finish connecting openrouter") || !strings.Contains(screen, flow.url) { t.Fatalf("the wait must carry the browser address; got:\n%s", screen) } - // A key landing here goes on to the controls screen, and the ONE command that - // comes back is the example panel's first beat: the arrival is one of the two - // deliberate acts its demonstration plays for (onboarding.go). Nothing else is - // started — no fetch, no second listener, no clock that keeps running. + // A key landing here goes on to the controls screen, and what comes back is + // the example panel's two clocks and nothing else — its first beat and its + // first turn (onboarding.go): no fetch, no second listener. They are read + // as the armed flags and the batch's size rather than waited out, because + // the turn is three seconds long. _, next := a.Update(wait()) if a.setup.step() != setupControls { t.Fatalf("the key must go on to the controls; step = %v", a.setup.step()) } if next == nil { - t.Fatal("arriving on the controls armed no beat for the example panel") + t.Fatal("arriving on the controls armed no clock for the example panel") } - // The beat is longer than the harness clock's budget, so it is waited out - // deliberately rather than asked of a clock that answers polls with nothing. - beat := waitOut(next) - if _, ok := beat.(setupDemoMsg); !ok { - t.Fatalf("the key started something other than the panel's beat: %T", beat) + if !a.setup.demoTicking || !a.setup.turnTicking { + t.Fatalf("arriving on the controls armed beat=%v turn=%v, want both", a.setup.demoTicking, a.setup.turnTicking) + } + if batch, ok := next().(tea.BatchMsg); !ok || len(batch) != 2 { + t.Fatalf("the key started %T with %d commands, want the panel's two clocks", next(), len(batch)) } if got := config.PersistedAPIKey(dir); got != flow.key { t.Fatalf("profile key = %q, want browser key", got) diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 31b46dc5e1..e11587776f 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -201,14 +201,13 @@ func (a *app) setupControlsKey(name, text string) bool { return true case "left", "right": - // THE EXAMPLES ARE BROWSED AND NEVER CYCLED. No clock on this screen moves - // between them; these two keys are the only way the right-hand panel - // changes other than following the focus, and they change nothing about - // the profile. Each such move plays the panel's own one-shot - // demonstration once and then it is still — a browse is a deliberate act, - // which is exactly the condition that motion here is allowed under. Inside - // the model list they are not free — the filter box has the keyboard — so - // the list keeps them and does nothing. + // THE EXAMPLES ARE BROWSED WITH THESE TWO KEYS AND TURN BY THEMSELVES + // OTHERWISE ([app.setupTurnAt]). They change nothing about the profile, + // and they are the panel's own keys and nothing else's: the focus never + // moves the example (it did until 2026-10-01, which read as the panel + // jumping about under a person walking the rows). Inside the model list + // they are not free — the filter box has the keyboard — so the list + // keeps them and does nothing. if s.modelOpen { return true } @@ -216,12 +215,7 @@ func (a *app) setupControlsKey(name, text string) bool { if name == "left" { delta = -1 } - // THE EXAMPLES GO ROUND. `→` on the last one is the first again, so a - // person browsing them never hits a wall they cannot see the reason - // for; the count on the panel's edge says where they are. - s.example = wrapCursor(s.example, delta, len(setupExamples)) - // One of the two deliberate acts the demonstration plays for. - a.restartSetupDemo() + a.turnSetupExample(delta) return true case "backspace": @@ -312,22 +306,14 @@ func (s *setupFlow) closeChoosers() { } // focusControl walks the rows, closing whatever was open on the way. THE -// EXAMPLE FOLLOWS THE FOCUS because a focus change is a deliberate act — which -// is the whole rule the right-hand column is built on (docs/design/onboarding). +// EXAMPLE DOES NOT FOLLOW THE FOCUS: it turns on its own clock and on ←/→, and +// nothing a person does to the rows moves it ([app.setupTurnAt]). func (s *setupFlow) focusControl(a *app, delta int) { s.closeChoosers() // The detail belongs to the field it was asked about and goes with it. s.detail = false s.control = setupControl(moveCursor(int(s.control), delta, setupControlCount)) - was := s.example - s.example = exampleForControl(s.control) s.refusal = "" - // The other deliberate act. A row that keeps the example the last row had - // does not replay it: the panel would restart under a person who never - // changed what it was showing. - if s.example != was { - a.restartSetupDemo() - } a.touch() } @@ -738,7 +724,8 @@ func (a *app) startSetupControls() { s.limitText, s.limitTyped = "", false s.closeChoosers() s.detail = false - s.example = exampleForControl(controlLimit) + // The panel opens on the first example and turns from there. + s.example = 0 // ARRIVING ON THE SCREEN IS THE FIRST OF THE TWO DELIBERATE ACTS, so the // panel plays once here. Coming BACK from the step behind this one does not // reach this line at all — the seeded guard above returns first — which is @@ -768,10 +755,9 @@ type setupExample struct { leads []string } -// setupExamples are the four, in the order ←/→ walks them. Each one is tied to a -// control by [exampleForControl] except the last, which belongs to no field and -// is reachable by browsing — the design's own point that the column is an -// invitation rather than a caption. +// setupExamples are the five, in the order the panel turns through them and +// ←/→ walk them. None is tied to a row: the panel is an invitation rather than +// a caption, and it turns on its own clock ([app.setupTurnAt]). var setupExamples = []setupExample{ { title: "Understand an unfamiliar project", @@ -836,24 +822,6 @@ var setupExamples = []setupExample{ }, } -// exampleForControl is which example accompanies which field, and it is the -// design's own mapping: the money row is accompanied by following the work and -// its cost, the crew by handing something off, and the model row — with the -// connection that precedes it — by understanding a project, which is the first -// thing most people actually type. -func exampleForControl(control setupControl) int { - switch control { - case controlLimit: - return 2 - case controlStart: - // THE LAST ROW SHOWS THE BIGGEST THING THE PROGRAM DOES: a person about - // to start a conversation is shown that a whole change can be handed - // off, which is the one capability nothing above has hinted at. - return 4 - } - return 0 -} - // The two labels that keep the column honest. They are the whole reason a person // does not read the right-hand side as a report about their own machine. const ( @@ -1845,17 +1813,24 @@ func setupShowcaseCount(at, count int) string { return "← " + itoa(at+1) + " / " + itoa(count) + " →" } -// ── the one-shot demonstration ────────────────────────────────────────────── +// ── the demonstration, and the clock that turns the examples ──────────────── // -// THE PANEL PLAYS ONCE, ON A DELIBERATE ACT, AND THEN IT IS STILL. +// THE PANEL PLAYS EACH EXAMPLE ONCE AS IT ARRIVES, AND THE EXAMPLES TURN. // // The request types itself out and the three lines under it arrive in order, -// which is the whole of it: about a second and a third, six beats of typing and -// one per line. It is armed by exactly two things — arriving on this screen, and -// a person moving the focus or browsing the examples with ←/→. Nothing else -// starts it, nothing repeats it, and there is no clock anywhere on this screen -// that switches examples by itself: a carousel on a setup screen is motion -// competing with the decision a person is trying to make. +// which is the whole of the demonstration: about a second and a third, six +// beats of typing and one per line. It plays when an example arrives — on +// reaching this screen, on ←/→, and on the turn — and then it is still. +// +// THE TURN is the second clock. Left alone, the panel shows the next example +// every [setupTurnEvery], round and round, so a person reading the form sees +// all five without touching anything; ←/→ browse them by hand. ANY KEY HOLDS +// THE CLOCK for a full [setupTurnEvery] from that key, so the panel never +// turns under a person who is typing an amount or walking the rows, and +// browsing by hand is not raced by the clock. Until 2026-10-01 the design +// refused a carousel here and moved the example with the focus instead; new +// people read that as the panel jumping about as they walked the rows, and +// the owner asked for the clock. // // AND ANY OTHER KEY SETTLES IT AT ONCE. Typing an amount, narrowing the model // list, opening a chooser — every one of those jumps the panel straight to its @@ -1917,8 +1892,8 @@ func (a *app) showcaseRevealed(example setupExample) int { return min(a.setup.demoAt-setupDemoTypeBeats+1, len(example.leads)) } -// restartSetupDemo plays the panel from the top. It is called on the two -// deliberate acts and nowhere else. +// restartSetupDemo plays the panel from the top: on arriving, on ←/→, and on +// the turn. func (a *app) restartSetupDemo() { s := &a.setup s.demoGen++ @@ -1931,7 +1906,7 @@ func (a *app) restartSetupDemo() { } // settleSetupDemo puts the panel straight into its finished state and stops the -// clock. Any key that is not one of the two deliberate acts lands here. +// demonstration's clock. Any key that is not ←/→ lands here. func (a *app) settleSetupDemo() { s := &a.setup if last := a.setupDemoLast(); s.demoAt < last { @@ -1977,6 +1952,68 @@ func (a *app) setupDemoBeatAt(gen int) tea.Cmd { return a.setupDemoCmd() } +// setupTurnEvery is how long the panel holds one example before showing the +// next, and how long any key holds the clock. +const setupTurnEvery = 3 * time.Second + +// setupTurnMsg is the turn's clock arriving, stamped with the generation that +// armed it so a tick left over from before a key is dropped. +type setupTurnMsg struct{ gen int } + +// turnSetupExample moves the panel by delta, round the ring, and plays the +// example that arrives. It is the one way the example changes — ←/→ and the +// clock both come through it. +func (a *app) turnSetupExample(delta int) { + s := &a.setup + // THE EXAMPLES GO ROUND. `→` on the last one is the first again, so a + // person browsing them never hits a wall they cannot see the reason for; + // the count on the panel's edge says where they are. + s.example = wrapCursor(s.example, delta, len(setupExamples)) + a.restartSetupDemo() + a.touch() +} + +// setupTurnCmd arms the turn's clock, and answers nil in every state where +// there should not be one: off this screen, already armed, or in the +// screen-reader tier, where a panel that changed by itself would be the same +// illustration announced over and over. It is the ONE place the clock is +// armed, so a second cannot be started beside the first. +func (a *app) setupTurnCmd() tea.Cmd { + s := &a.setup + if !s.open || len(s.steps) == 0 || s.step() != setupControls || a.linear || s.turnTicking { + return nil + } + s.turnTicking = true + gen := s.turnGen + return surfaceTick(setupTurnEvery, func(time.Time) tea.Msg { return setupTurnMsg{gen: gen} }) +} + +// holdSetupTurn is any key on this screen: the clock in flight is retired and +// a fresh one armed, so the next turn is a full [setupTurnEvery] after the key. +func (a *app) holdSetupTurn() tea.Cmd { + s := &a.setup + s.turnGen++ + s.turnTicking = false + return a.setupTurnCmd() +} + +// setupTurnAt is the clock arriving. A tick from a retired generation is +// dropped whole, and so is one that finds the screen gone or stepped back to +// the key; otherwise the next example arrives, plays, and the clock is armed +// again. +func (a *app) setupTurnAt(gen int) tea.Cmd { + s := &a.setup + if gen != s.turnGen { + return nil + } + s.turnTicking = false + if !s.open || len(s.steps) == 0 || s.step() != setupControls { + return nil + } + a.turnSetupExample(1) + return tea.Batch(a.setupDemoCmd(), a.setupTurnCmd()) +} + // sortStrings is the one small thing the crew detail needs and nothing else here // does. It is written out rather than reached for because the list is five long // and the sort is a stable, obvious one. diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 5a4c92811b..10b1145fb2 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -465,27 +465,27 @@ func TestTheExampleColumnIsLabelledFollowsTheFocusAndHidesWhenNarrow(t *testing. t.Fatalf("the example panel must be labelled as one; got:\n%s", screen) } a.settleSetupDemo() - if want := setupExamples[exampleForControl(controlLimit)].title; !strings.Contains(screen, want) { - t.Fatalf("the limit's example is %q; got:\n%s", want, screen) + if want := setupExamples[0].title; !strings.Contains(screen, want) { + t.Fatalf("the panel opens on the first example %q; got:\n%s", want, screen) } - // A deliberate focus change moves it, and nothing else does: a frame drawn - // again with no key pressed is the same frame. (The demonstration inside the - // panel is driven by beats that ARRIVE, never by drawing — settling it first - // is what makes this a question about the example and not about the clock.) + // Nothing a person does to the rows moves it, and a frame drawn again with + // no key pressed is the same frame. (The demonstration inside the panel is + // driven by beats that ARRIVE, never by drawing — settling it first is what + // makes this a question about the example and not about the clock.) a.settleSetupDemo() screen = setupScreen(a) if again := setupScreen(a); again != screen { t.Fatal("the example moved between two frames with no keypress") } walkToControl(t, a, controlStart) - if want := setupExamples[exampleForControl(controlStart)].title; !strings.Contains(setupScreen(a), want) { - t.Fatalf("the way out's example is %q; got:\n%s", want, setupScreen(a)) + if a.setup.example != 0 { + t.Fatalf("walking the rows moved the example to %d; the focus must not move it", a.setup.example) } // ←/→ browse without touching anything. limit := a.setup.limitText pressSetup(a, key("right")) - if a.setup.example == exampleForControl(controlStart) { - t.Fatal("→ did not move the example") + if a.setup.example != 1 { + t.Fatalf("→ moved the example to %d, want the second", a.setup.example) } if a.setup.limitText != limit { t.Fatal("browsing the examples changed a control") @@ -579,11 +579,11 @@ func TestTheExamplePanelStandsAboveTheFormWithTheKeysLineUnderTheHeading(t *test } } -// THE HAND-OFF EXAMPLE SPELLS /senior-dev AND WEARS ITS CHIP. It stands beside -// `Start a conversation`, and the command in its request is painted the way the -// composer paints a recognised command, so the panel shows the word as one the -// program knows rather than as prose. -func TestTheSeniorDevExampleStandsOnTheLastRowWithItsCommandChipped(t *testing.T) { +// THE HAND-OFF EXAMPLE SPELLS /senior-dev AND WEARS ITS CHIP. It is the last of +// the ring, and the command in its request is painted the way the composer +// paints a recognised command, so the panel shows the word as one the program +// knows rather than as prose. +func TestTheSeniorDevExampleIsTheLastOfTheRingWithItsCommandChipped(t *testing.T) { // The program's row is on the command table once the engine's list has // landed (delegate.go), which is what makes `/senior-dev` a word the // composer chips; the fixture's agent has no list, so the row is installed @@ -591,11 +591,11 @@ func TestTheSeniorDevExampleStandsOnTheLastRowWithItsCommandChipped(t *testing.T installDelegateCommands([]session.DelegateRow{{Name: "senior-dev", Description: "an autonomous coding agent"}}) t.Cleanup(func() { installDelegateCommands(nil) }) a, _ := controlsApp(t, nil) - walkToControl(t, a, controlStart) + pressSetup(a, key("left")) // the ring's last example a.settleSetupDemo() example := setupExamples[a.setup.example] if !strings.HasPrefix(example.ask, "/senior-dev ") || example.title != "Hand off complex coding tasks" { - t.Fatalf("the last row's example is %q / %q, want the senior-dev hand-off", example.title, example.ask) + t.Fatalf("the last example is %q / %q, want the senior-dev hand-off", example.title, example.ask) } frame, _, _ := a.frame() if !strings.Contains(frame, a.pal.chip("/senior-dev")) { @@ -646,16 +646,17 @@ func TestTheExamplePanelIsFramedAndFallsBackToAscii(t *testing.T) { } } -// THE DEMONSTRATION PLAYS ONCE, ON A DELIBERATE ACT, AND NOTHING ELSE STARTS IT. +// THE DEMONSTRATION PLAYS ONCE PER EXAMPLE, AND NOTHING ELSE STARTS IT. // -// This is the whole motion contract of the screen, and every clause of it is -// something a person would notice if it broke: a panel that replayed on every -// keystroke, or looped, or restarted while somebody was typing an amount, is a -// screen with something moving in the corner of the eye for no reason. -func TestTheExampleDemonstrationPlaysOnceOnADeliberateAct(t *testing.T) { +// Every clause of this is something a person would notice if it broke: a panel +// that replayed on every keystroke, or looped, or restarted while somebody was +// typing an amount, is a screen with something moving in the corner of the eye +// for no reason. (The examples themselves turn on their own clock — the next +// test — and each turn plays the arriving example once.) +func TestTheExampleDemonstrationPlaysOncePerExample(t *testing.T) { a, _ := controlsApp(t, nil) - // Arriving on the screen is the first deliberate act, and it leaves the panel - // at its first beat with a beat armed. + // Arriving on the screen leaves the panel at its first beat with a beat + // armed. if a.setup.demoAt != 0 { t.Fatalf("the panel did not start from the top; demoAt = %d", a.setup.demoAt) } @@ -1301,3 +1302,64 @@ func TestExplanationsNameTheirCommandsAndTheWayOutHasNoLooseEnter(t *testing.T) } } } + +// THE EXAMPLES TURN ON THEIR OWN CLOCK, AND ANY KEY HOLDS IT. Left alone the +// panel shows the next example every three seconds, round the ring; a key +// retires the tick in flight and arms a fresh one, so the panel never turns +// under a person's hands and a hand-browsed example stays for a full interval. +// The clock is read as commands and generations rather than waited for. +func TestTheExamplesTurnOnTheirOwnClockAndAnyKeyHoldsIt(t *testing.T) { + a, _ := controlsApp(t, nil) + if !a.setup.turnTicking { + t.Fatal("arriving on the controls screen armed no turn") + } + gen := a.setup.turnGen + // The tick arrives: the next example, played from the top, and another tick + // armed. + if cmd := a.setupTurnAt(gen); cmd == nil { + t.Fatal("the turn armed nothing after it") + } + if a.setup.example != 1 || a.setup.demoAt != 0 || !a.setup.turnTicking { + t.Fatalf("after the turn: example %d, demoAt %d, ticking %v; want the second example, played from the top, with a tick armed", a.setup.example, a.setup.demoAt, a.setup.turnTicking) + } + // Round the ring: four more turns are the first example again. + for i := 0; i < len(setupExamples)-1; i++ { + a.setupTurnAt(a.setup.turnGen) + } + if a.setup.example != 0 { + t.Fatalf("after a full ring the example is %d, want the first again", a.setup.example) + } + // A key retires the tick in flight: the old generation is dropped whole. + stale := a.setup.turnGen + pressSetup(a, key("down")) + if a.setup.turnGen == stale || !a.setup.turnTicking { + t.Fatal("a key did not retire the tick in flight and arm a fresh one") + } + at := a.setup.example + if cmd := a.setupTurnAt(stale); cmd != nil || a.setup.example != at { + t.Fatal("a retired tick turned the panel") + } + // Typing an amount holds it too, and browsing by hand is not raced. + pressSetup(a, key("2")) + gen = a.setup.turnGen + pressSetup(a, key("right")) + if a.setup.example != at+1 || a.setup.turnGen == gen { + t.Fatalf("→ moved the example to %d (from %d) and left the clock's generation at %d", a.setup.example, at, a.setup.turnGen) + } + // Back on the key step the tick is dropped and nothing is armed; returning + // arms it again. + pressSetup(a, key("esc")) + if a.setup.step() != setupKey { + t.Fatal("esc did not go back to the key step") + } + if cmd := a.setupTurnAt(a.setup.turnGen); cmd != nil { + t.Fatal("a tick on the key step turned something") + } + // The screen-reader tier never turns by itself. + b, _ := controlsApp(t, nil) + b.linear = true + b.setup.turnTicking = false + if cmd := b.setupTurnCmd(); cmd != nil { + t.Fatal("the screen-reader tier armed a turn") + } +} From 6bb9d93a037350e502c391d281b31140fb582325 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:55:57 -0400 Subject: [PATCH 14/26] chat: the setup's limit detail explains the day's and the conversation's ceilings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `?` on the Daily limit row now says what the two ceilings are and how they meet: /budget 50 changes the day's and /budget none removes it; /budget conversation 20 gives the open conversation a smaller ceiling of its own and keeps it as the default for new ones; both hold at once and whichever is reached first stops the work — the day's everything until midnight, the conversation's just that conversation; the running turn always finishes. Each /budget wears the composer's chip. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 1 + internal/manual/chat/getting-started.md | 18 +++++++------ internal/tui3/onboarding.go | 25 +++++++++++++------ internal/tui3/onboarding_test.go | 6 ++--- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 1888de15e7..5da3c12ae8 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -5,6 +5,7 @@ pr: 1720 surface: [chat, docs] invalidates: - "On the first conversation's screen, enter on a starting point filled the box and the next enter did nothing — the starting point kept taking it. With words in the box, enter now sends them: ↓ enter enter sends the starting point's sentence." + - "The `?` detail on the setup's Daily limit row said it counts recorded spending, running calls can carry it past, and task crews have a cap in /crew. It now explains the two ceilings: `/budget 50` changes the day's limit and `/budget none` removes it; `/budget conversation 20` gives the open conversation a smaller ceiling of its own and keeps it as the default for new ones; both hold at once and whichever is reached first stops the work — the day's everything until midnight, the conversation's just that conversation; the running turn always finishes." - "The setup's example panel followed the focused row (the limit showed `Follow the work and its cost`, `Start a conversation` the senior-dev hand-off) and never turned by itself. It opens on the first example and turns to the next every 3 seconds, round the ring; `←`/`→` browse by hand; any key — typing, walking the rows, browsing, a press — holds the clock for 3 seconds from that key; the focus never moves it; the screen-reader tier never turns by itself. exampleForControl is gone; setupFlow has turnGen and turnTicking, and setupTurnMsg is the clock." - "On the setup's controls screen `Start a conversation` carried a dim `enter` at its right, and the two explanations did not name a command. The loose `enter` is gone (the keys line says `enter starts` there); the limit's line ends `/budget changes it later.` and the chat model's `/model changes it later.`, each command painted as the composer's chip." - "The setup's controls screen had a row `Other settings use defaults · review` (or `Review other settings`) between the chat model and `Start a conversation`, which enter opened into three read-only rows (memory, ask before running, task countdown) and the line `/settings changes these and every other one.`. The row is gone — the form is three rows: the limit, the chat model, the way out — and one dim line under `Start a conversation` reads `Everything else is in /settings`, with `/settings` painted as the composer's command chip. controlReview, setupFlow.reviewOpen, setupReviewRow, setupReviewRows, setupReviewKeys and setupOtherSettingsWritten are gone." diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 2eebb90c90..c1a2d5dee4 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -151,14 +151,16 @@ Its one line reads: Type a number to change it — the `$` is drawn for you rather than typed — or type **`none`** for no limit, which is a first-class answer and makes the row read `no limit`. -`?` on the row adds the part that matters when the bill arrives: *it counts spending -codeaf records here. Calls already running can carry it a little past. Your provider -account has its own controls. Task crews also have a daily cap of their own, set in /crew.* -The two are different limits: this one covers everything codeaf spends, and `/crew`'s -**crew daily cap** covers only what task crews spend. It is a backstop against a runaway, not a promise about -your whole bill. Something that is not a dollar amount is refused in the settings row's -own words — `that's not a dollar amount — a number, or none for no limit` — and the -screen stays. +`?` on the row explains the two ceilings and how they meet: *The day's ceiling for +everything codeaf does: /budget 50 changes it later and /budget none removes it. A +conversation can carry a smaller ceiling of its own: /budget conversation 20 sets one for +the conversation you are in and keeps it as the default for new ones. Both hold at once, +and whichever is reached first stops the work — the day's holds everything until midnight +or you raise it, a conversation's holds just that conversation. The turn already running +always finishes, and your provider account has controls of its own.* Each `/budget` in it is +painted as a command chip. (Until 2026-10-01 the detail said only that the figure counts +recorded spending, that running calls can carry it a little past, and that task crews have +a cap of their own in `/crew`.) `$500` is **the amount codeaf has always shipped** and this screen did not change it. diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index e11587776f..8422564731 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -108,15 +108,24 @@ const ( // The detail behind `?`, on the field with the focus. // -// THE LIMIT'S DETAIL REFUSES TO OVERPROMISE. It is a ceiling on what codeaf -// RECORDS spending, calls already in flight can carry the day a little past it, -// and the provider account has controls of its own that this number knows nothing -// about. A screen that said "you will never be billed more than this" would be -// the product making a promise it cannot keep with somebody else's money. +// THE LIMIT'S DETAIL EXPLAINS THE TWO CEILINGS AND HOW THEY MEET. The day's +// limit is the one this row sets; a conversation can carry a smaller one of its +// own (`/budget conversation`), both hold at once, and whichever is reached +// first stops the work — the day's holds everything until midnight, a +// conversation's holds that conversation. It still refuses to overpromise: the +// turn in flight finishes, and the provider account has controls of its own +// that this number knows nothing about, so a screen that said "you will never +// be billed more than this" would be making a promise with somebody else's +// money. const ( - controlLimitDetail = "It counts spending " + product + " records here. Calls already " + - "running can carry it a little past. Your provider account has its own controls. " + - "Task crews also have a daily cap of their own, set in /crew." + controlLimitDetail = "The day's ceiling for everything " + product + " does: /budget 50 " + + "changes it later and /budget none removes it. A conversation can carry a " + + "smaller ceiling of its own: /budget conversation 20 sets one for the " + + "conversation you are in and keeps it as the default for new ones. Both hold " + + "at once, and whichever is reached first stops the work — the day's holds " + + "everything until midnight or you raise it, a conversation's holds just that " + + "conversation. The turn already running always finishes, and your provider " + + "account has controls of its own." controlModelDetail = "It also handles this conversation's tool use. Changing it here is " + "the same choice /model makes, and it is kept for the next launch." + " Tasks get their own crew, picked per task · /crew shows it." diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 10b1145fb2..9731999b8f 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -291,15 +291,15 @@ func TestTheDetailIsBehindAQuestionMarkAndGoesWithTheFocus(t *testing.T) { // what was drawn next to it. a.width, a.height = 80, 24 first := setupScreen(a) - if strings.Contains(first, "Calls already running") { + if strings.Contains(first, "whichever is reached first") { t.Fatalf("the limit's caveat is on the screen before anybody asked:\n%s", first) } pressSetup(a, key("?")) - if !strings.Contains(setupScreen(a), "Calls already running") { + if !strings.Contains(setupScreen(a), "whichever is reached first") { t.Fatalf("? did not open the limit's detail:\n%s", setupScreen(a)) } pressSetup(a, key("tab")) - if strings.Contains(setupScreen(a), "Calls already running") { + if strings.Contains(setupScreen(a), "whichever is reached first") { t.Fatalf("the detail followed the focus off its own field:\n%s", setupScreen(a)) } } From 4940ad0fcb798ddac6d0b53261441213e474e5b8 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:18:28 -0400 Subject: [PATCH 15/26] chat: the model-name test reads the name on the field row and the id on the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestModelNamesReadAsNamesAndKeepTheirLevel still expected the open list to draw "DeepSeek V4 Flash", which the flat list of exact ids stopped doing on purpose; it now asserts the two halves that hold — the field row and the typed search speak the friendly name, every row of the open list is the exact id — and leaves the flat shape itself to the test that owns it. Co-Authored-By: Claude Fable 5.1 --- internal/tui3/onboarding_test.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 9731999b8f..49469aedfe 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -719,8 +719,12 @@ func TestTheExampleDemonstrationDoesNotAnimateInTheLinearTier(t *testing.T) { } } -// MODEL NAMES ARE READ AS NAMES AND THE ID IS STILL AVAILABLE. The form is a -// place to decide between two models, not to type an address into. +// MODEL NAMES ARE READ AS NAMES WHERE THE FORM SPEAKS, AND THE LIST KEEPS THE +// ID. The field row and the typed search carry the friendly name — the form is +// a place to decide between two models, not to type an address into — while +// each row of the open list is the exact id, one row per model +// ([TestTheModelListIsAFlatListOfExactIds]), because that is what /model and a +// settings file take. func TestModelNamesReadAsNamesAndKeepTheirLevel(t *testing.T) { for _, c := range []struct{ id, want string }{ {"deepseek/deepseek-v4-flash", "DeepSeek V4 Flash"}, @@ -736,15 +740,21 @@ func TestModelNamesReadAsNamesAndKeepTheirLevel(t *testing.T) { a, _ := controlsApp(t, nil) a.models = func() []Model { return []Model{{ID: "deepseek/deepseek-v4-flash"}} } walkToControl(t, a, controlChatModel) - // The model in use heads the list when the catalog does not carry it, so the - // cursor walks one row to reach the catalog's own. + // The field row reads as a name before the list opens... + if screen := setupScreen(a); !strings.Contains(screen, controlModelLabel+" "+modelWord(a.model)) { + t.Fatalf("the chat model field must read as a name; got:\n%s", screen) + } + // ...and the open list carries the exact id on every row, the one in use + // heading it when the catalog does not carry it. pressSetup(a, key("enter"), key("down")) screen := setupScreen(a) - if !strings.Contains(screen, "DeepSeek V4 Flash") { - t.Fatalf("the list must read as names; got:\n%s", screen) + for _, id := range []string{a.model, "deepseek/deepseek-v4-flash"} { + if !strings.Contains(screen, id) { + t.Fatalf("the list must carry the exact id %q; got:\n%s", id, screen) + } } - if !strings.Contains(screen, "deepseek/deepseek-v4-flash") { - t.Fatalf("the row under the cursor must still show its exact id; got:\n%s", screen) + if strings.Contains(screen, "DeepSeek V4 Flash") { + t.Fatalf("the list drew a friendly name over the id; got:\n%s", screen) } } From 1580bb76decf70a9f785dc2d3760bdff55422746 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:38:41 -0400 Subject: [PATCH 16/26] chat: the expired key's line agrees with the keys line about esc, and the setup's prose catches up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup's foot said "esc to paste a new one" on every frame, while the keys line two rows up said "esc skips setup" whenever the controls step stood alone — a key already in the shell or the profile, only the controls asked. The line is now chosen by the same rule as the keys line: with a connect step before it, esc goes back there; standing alone, it names /connect as the door to a new key. A test pins the alone case on the same frame. The manual stops listing the review row's enter verbs (`shows them`, `goes on`) and stops saying the setup shows memory, permissions and the countdown under `Other settings`; both pages that explained esc on an expired key say the conditional thing. The comments the redesign left behind say what is true now: the example turns on a clock and the focus never moves it, the panel stands above the form, the edge label — not a foot line — is what says it is an illustration, and `codeaf telemetry show` is spoken of as the command that went. Co-Authored-By: Claude Fable 5.1 --- internal/config/derivation_test.go | 5 +- internal/manual/chat/getting-started.md | 15 +++-- internal/manual/chat/openrouter-credits.md | 14 ++-- internal/pool/record/record.go | 5 +- internal/pool/record/record_test.go | 5 +- internal/telemetry/doc_test.go | 2 +- internal/telemetry/events.go | 7 +- internal/telemetry/events_test.go | 5 +- internal/telemetry/spool.go | 5 +- internal/tui3/onboarding.go | 76 +++++++++++++--------- internal/tui3/onboarding_test.go | 42 +++++++++++- 11 files changed, 126 insertions(+), 55 deletions(-) diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index 95f5619e0d..0c438a432a 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -130,8 +130,9 @@ var settingReaders = map[string]string{ // the attribution line. KeyAttributionModel: "AssistedByModelAt", // The pool row is read through [ModelPoolAt] since the telemetry off switch - // started capping the pool at read: `codeaf pool` and `codeaf telemetry` - // call [ModelPoolResolved] with their injected environment, and every + // started capping the pool at read: `codeaf pool` calls [ModelPoolResolved] + // with its injected environment (`codeaf telemetry` did too, until the + // command went on 2026-10-01), and every // other verb calls [ModelPoolAt]. Nothing outside this package reads the // stored word directly any more. KeyModelPool: "ModelPoolAt", diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index c1a2d5dee4..3af599b64c 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -205,8 +205,13 @@ stands under the message box once you are in a conversation. The model you are already on stays on the list whatever it costs, so accepting still confirms. The balance is read right after the key lands, so the cut usually arrives a moment after the screen does; a top-up is read on the next launch (*OpenRouter credits and free models*). A key OpenRouter -refuses as **expired** is said the same way, on the last row — `Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys` — but the list is not cut, because -free models fail on an expired key too; `esc` goes back to the connect step to paste a new one. +refuses as **expired** is said the same way, on the last row, but the list is not cut, because +free models fail on an expired key too. The line names the door to a new key, and the door +depends on whether a connect step came before this screen: with one, it reads +`Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys` and +`esc` goes back there; when the key was already in the shell or the profile and only this +screen was asked, `esc` skips the setup instead — the keys line says so — and the line reads +`Your OpenRouter key has expired · /connect takes a new one from openrouter.ai/settings/keys`. **There is no crew question**, because the crew is three seats — the worker, the planner and the checker — and codeaf picks all three for each task from what kind of work it is, so there @@ -252,8 +257,8 @@ there. Opening the list and leaving it with `esc` answers nothing. The keys line is the form's second row, directly under `Basic settings`. It reads `enter sets the limit · ↑↓ moves · esc back · type an amount or none · ? detail` on the limit -row; `enter` on the other rows says what it does there (`opens the list`, `shows them`, -`goes on`, `starts`). `↑`/`↓` and `tab` both walk the rows. It does not name the example's +row; `enter` on the other rows says what it does there (`opens the list`, `starts`). +`↑`/`↓` and `tab` both walk the rows. It does not name the example's arrows; the panel's own edge does. The controls screen shows **once, ever**. The default OpenRouter prerequisite above is the only @@ -302,7 +307,7 @@ Every answer went through a settings row, so every answer has a door: | the crew | nothing was asked — it is auto. `/crew` shows it, and `/crew pin ` pins a seat | | the daily limit | `/budget` (also `/limits`), or `/settings` → **Spending**. `CODEAF_DAILY_BUDGET` in your shell outranks the row | | the model you talk to | `/model`, or the **Chat model** row on the setup screen — the same settings row either way | -| memory, permissions, the task countdown | `/settings`; the setup screen only shows them, under `Other settings` | +| memory, permissions, the task countdown | `/settings` — the setup screen does not show them; the note under **Start a conversation** points there | A credential changed in the settings row reaches the running conversation at once, exactly as the setup's does. A crew pin and the budget are read live too: the next task diff --git a/internal/manual/chat/openrouter-credits.md b/internal/manual/chat/openrouter-credits.md index 8c5df04b04..b6e7db8fef 100644 --- a/internal/manual/chat/openrouter-credits.md +++ b/internal/manual/chat/openrouter-credits.md @@ -83,11 +83,15 @@ OpenRouter service serves — free or paid — and never on a model served by Co model or another connected service. It wins over the low-credits line, and the free defaults are **not** used, because they would fail on the same key. A turn refused with `API key expired` mid-session starts a fresh read, so the warning arrives without a -relaunch. On the first-run setup screen the same fact stands on the last row: -`Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys`, and the list is not cut to free models. The fix is a new key at -https://openrouter.ai/settings/keys, pasted on the setup's connect step (`esc` goes back -to it), in `/settings`, or in `/connect`; the old key's warning goes the moment the new -key is saved, before it has even been read. +relaunch. On the first-run setup screen the same fact stands on the last row, and the list +is not cut to free models. When a connect step came before the screen it reads +`Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys` +and `esc` goes back there; when the key was already in the shell or the profile and only the +settings screen was asked, `esc` skips the setup instead and the line reads +`Your OpenRouter key has expired · /connect takes a new one from openrouter.ai/settings/keys`. +The fix is a new key at https://openrouter.ai/settings/keys, pasted on the connect step, in +`/settings`, or in `/connect`; the old key's warning goes the moment the new key is saved, +before it has even been read. ## Low on credits warning under the message box diff --git a/internal/pool/record/record.go b/internal/pool/record/record.go index 7f84822c49..d1634cc3f6 100644 --- a/internal/pool/record/record.go +++ b/internal/pool/record/record.go @@ -116,8 +116,9 @@ type Row struct { // ExampleRowJSON is one row as the relay would receive it, on the day given, // with placeholder slugs where a real row carries the model that held the -// seat and the model that judged it. `codeaf telemetry show` prints it so a -// person sees the bytes before any row exists. It is marshalled from [Row], +// seat and the model that judged it — the bytes a person can read before any +// row exists. `codeaf telemetry show` printed it until 2026-10-01; its readers +// are the tests and the disclosure now. It is marshalled from [Row], // so it cannot spell a key a real row would not. func ExampleRowJSON(now time.Time) string { row := Row{ diff --git a/internal/pool/record/record_test.go b/internal/pool/record/record_test.go index c7b007b1b0..439aea6f22 100644 --- a/internal/pool/record/record_test.go +++ b/internal/pool/record/record_test.go @@ -328,8 +328,9 @@ func TestCellsAnswerSortedAndCarryTheMeanAndCount(t *testing.T) { } } -// TestExampleRowJSONIsARow holds the example `codeaf telemetry show` prints -// to the row itself: it parses back into a Row, carries every key the row +// TestExampleRowJSONIsARow holds the example row — once what `codeaf +// telemetry show` printed, kept as the disclosure's worked example — to the +// row itself: it parses back into a Row, carries every key the row // spells and no other, and names the day it was asked for. func TestExampleRowJSONIsARow(t *testing.T) { day := time.Date(2026, 9, 19, 23, 59, 0, 0, time.UTC) diff --git a/internal/telemetry/doc_test.go b/internal/telemetry/doc_test.go index 5352a75953..c871ee4f6d 100644 --- a/internal/telemetry/doc_test.go +++ b/internal/telemetry/doc_test.go @@ -69,7 +69,7 @@ func docPropsWithDocs(t *testing.T, body string) (common []string, perEvent map[ } // TestDocPropertyWordsMatchPropDoc holds the doc's third column to the table -// `codeaf telemetry show` prints from, word for word, and holds that table to +// the code describes each prop with, word for word, and holds that table to // the allowlist: every allowlisted prop has a description, and every // description is of an allowlisted prop. func TestDocPropertyWordsMatchPropDoc(t *testing.T) { diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go index fe407b4681..19eed2bb9d 100644 --- a/internal/telemetry/events.go +++ b/internal/telemetry/events.go @@ -336,9 +336,10 @@ var allowedProps = map[string]map[string]bool{ const EveryEvent = "every event" // propDocs is what each allowlisted prop IS, in a person's words: the third -// column of docs/TELEMETRY.md's table, held here so that `codeaf telemetry -// show` and the doc read from one table and the doc test can fail the build -// when the two drift. Every allowlisted prop has a line, and the test holds +// column of docs/TELEMETRY.md's table, held here so that the doc and the code +// read from one table and the doc test can fail the build when the two drift. +// Until 2026-10-01 `codeaf telemetry show` printed it too; the doc is its one +// reader now. Every allowlisted prop has a line, and the test holds // that too. var propDocs = map[string]map[string]string{ EveryEvent: { diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go index 19b0394f3c..752ee92f12 100644 --- a/internal/telemetry/events_test.go +++ b/internal/telemetry/events_test.go @@ -350,8 +350,9 @@ func TestNothingSentinelEverReachesTheWire(t *testing.T) { } } -// TestExamplePropsCoverTheAllowlistExactly holds the example table `codeaf -// telemetry show` prints to the allowlist: every prop an event adds has an +// TestExamplePropsCoverTheAllowlistExactly holds the example table — once what +// `codeaf telemetry show` printed, kept as the doc's worked example — to the +// allowlist: every prop an event adds has an // example, no example names a prop the event cannot carry, and every example // is a value the contract admits where the contract enumerates one. func TestExamplePropsCoverTheAllowlistExactly(t *testing.T) { diff --git a/internal/telemetry/spool.go b/internal/telemetry/spool.go index 1062c7e0df..6ebd4fce8b 100644 --- a/internal/telemetry/spool.go +++ b/internal/telemetry/spool.go @@ -454,8 +454,9 @@ func SpoolContents() []json.RawMessage { return out } -// Show returns the spool as pretty JSON, so a person — or a test — can read -// everything that has not left yet. +// Show returns the spool as pretty JSON: everything that has not left yet. It +// was what `codeaf telemetry show` printed until 2026-10-01; the tests are its +// only readers now, and the spool itself is plain JSON lines a person can open. func Show() string { contents := SpoolContents() if len(contents) == 0 { diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 8422564731..9970df78c1 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -54,11 +54,13 @@ import ( // - IT SPENDS NOTHING. Opening a model list reads the catalog this process // already has; nothing on this screen sends a prompt or calls a model. // -// The example under the form is labelled as an example. It is one request -// somebody could make and the kind of result it leads to, and it moves only when -// the person moves — a deliberate focus change or the arrow keys, never a timer -// and never a keystroke inside a field. No invented cost, no fabricated -// activity, no claim that anything has already run. +// The example above the form is labelled as an example. It is one request +// somebody could make and the kind of result it leads to. It turns on its own +// clock, every [setupTurnEvery], and the arrow keys turn it by hand; any key +// holds the clock for one more interval, and the focus never moves it, so a +// person reading one is not interrupted and a person filling the form is not +// followed. No invented cost, no fabricated activity, no claim that anything +// has already run. // setupControl is one row of the controls screen, in the order tab walks them. // The day's limit comes first because it is the consequential one; the two model @@ -474,8 +476,8 @@ func (a *app) setupPress(x, y int) bool { return a.setupControlsEnter() case doorControl: if s.control != door.control { - // A tab to the row, which closes whatever was open on the way and - // moves the example with it ([setupFlow.focusControl]). + // A tab to the row, which closes whatever was open on the way + // ([setupFlow.focusControl]); the example stays where it is. s.focusControl(a, int(door.control)-int(s.control)) } else if s.modelOpen && door.control == controlChatModel { // A press on the field whose list is open puts the list away and @@ -831,8 +833,8 @@ var setupExamples = []setupExample{ }, } -// The two labels that keep the column honest. They are the whole reason a person -// does not read the right-hand side as a report about their own machine. +// The two labels that keep the panel honest. They are the whole reason a person +// does not read it as a report about their own machine. const ( exampleAskLabel = "Example request" exampleLeadLabel = "What it leads to" @@ -1531,9 +1533,25 @@ func (a *app) setupModelCountWord(count int) string { const ( setupFreeOnlyWord = "free only" setupLowCreditsWord = "Your OpenRouter account is low on credits · the list shows free models only" - setupExpiredKeyWord = "Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys" + // The expired key's line names the door to a new one, and the door depends + // on where this screen stands: with the connect step before it, esc goes + // back there to paste; standing alone — a key already in the shell or the + // profile, only the controls asked — esc skips the setup, the keys line two + // rows up says so, and the door is /connect once the conversation opens. + setupExpiredKeyBackWord = "Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys" + setupExpiredKeyAloneWord = "Your OpenRouter key has expired · /connect takes a new one from openrouter.ai/settings/keys" ) +// setupExpiredKeyWord is the expired key's line for the step this screen is on +// — the one whose way out is the one [app.setupBackWord] names on the same +// frame, so the two lines never disagree about what esc does. +func (a *app) setupExpiredKeyWord() string { + if a.setup.at > 0 { + return setupExpiredKeyBackWord + } + return setupExpiredKeyAloneWord +} + // setupFootWarning is the account's one-line warning for this screen — the key // expired, or the balance low and the list cut — and "", the emptiness law, // when the account gives no cause. It is drawn on the frame's LAST ROW, right @@ -1544,7 +1562,7 @@ const ( func (a *app) setupFootWarning() string { switch { case a.setupKeyExpired(): - return setupExpiredKeyWord + return a.setupExpiredKeyWord() case a.setupFreeOnly(): return setupLowCreditsWord } @@ -1595,15 +1613,15 @@ func (a *app) setupControlsKeys(width int) string { parts = append(parts, "type to narrow") } } else { - // THE TWO KEYS THAT DRIVE THE FORM COME FIRST, and `tab moves` is the - // second of them. At forty columns the legend has room for two clauses, - // and a person who has been told only what enter does and how to go back - // has been told everything except how to reach the other four rows — - // which is the one thing this screen cannot be completed without. The way - // out is third and appears from sixty columns up. + // THE TWO KEYS THAT DRIVE THE FORM COME FIRST: what enter does here, then + // how to reach the other rows. At forty columns the legend has room for + // two clauses, and a person who has been told only what enter does and + // how to go back has been told everything except how to reach the other + // rows — which is the one thing this screen cannot be completed without. + // The way out is third and appears from sixty columns up. // THE ROWS ARE WALKED WITH THE ARROWS, AND THE LEGEND SAYS SO. Tab - // walks them too, and used to be the word here; it was one more key to - // learn on a screen whose list a person already walks with ↑↓. + // walks them too, and `tab moves` used to be the word here; it was one + // more key to learn on a screen whose list a person already walks with ↑↓. switch s.control { case controlLimit: parts = []string{"enter sets the limit", setupMovesWord, a.setupBackWord(), "type an amount or none"} @@ -1656,17 +1674,16 @@ func (a *app) setupBackWord() string { return setupSkipKeysWord } -// ── the demonstration panel on the right ──────────────────────────────────── +// ── the demonstration panel above the form ────────────────────────────────── // // A FRAME THAT IS THERE TO SAY "NOT YOURS". // -// Nothing about the form on the left has an edge. This panel is framed, and the +// Nothing about the form under it has an edge. This panel is framed, and the // reason is the one thing a frame is actually good at: it separates a thing -// from its surroundings. Unframed, the right-hand column read as a SECOND -// COLUMN OF THE FORM — more instructions, in the same voice, about the fields -// on the left. A frame with a label on its top edge cannot be read that way. -// Everything inside it is an illustration, and the frame is what says so before -// a word is read. +// from its surroundings. Unframed, the panel read as MORE OF THE FORM — more +// instructions, in the same voice, about the fields under it. A frame with a +// label on its top edge cannot be read that way. Everything inside it is an +// illustration, and the frame is what says so before a word is read. // // It is drawn by the one frame (frame.go), which is also what a question hangs // in and what the two sheets raised over the page wear — this panel's header @@ -1699,9 +1716,10 @@ const ( // setupShowcaseBlock is the example panel as a framed block at the given width, // or nil when the window cannot hold the whole of it in maxHeight rows. It is -// whole or nothing: the line at its foot — that nothing in it has run — is the -// sentence that keeps the panel from being read as a report about this machine, -// and a panel trimmed from the bottom would lose exactly that line first. +// whole or nothing: the label on its top edge is what keeps the panel from being +// read as a report about this machine, and a panel cut to fit would be an edge +// with half an illustration under it — or, cut from the top, an illustration +// with no edge to say what it is. func (a *app) setupShowcaseBlock(width, maxHeight int) []string { pal := a.pal if len(setupExamples) == 0 || width < setupShowcaseMinWidth { diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 49469aedfe..e7a4bc2433 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -1166,14 +1166,52 @@ func TestAnExpiredKeyIsSaidUnderTheModelRowWithoutCuttingTheList(t *testing.T) { t.Fatalf("an expired key cut the list to %d rows, want the catalog's %d", got, len(catalog)) } screen := setupScreen(a) - if !strings.Contains(screen, setupExpiredKeyWord) { - t.Fatalf("the screen must say %q; got:\n%s", setupExpiredKeyWord, screen) + if !strings.Contains(screen, setupExpiredKeyBackWord) { + t.Fatalf("the screen must say %q; got:\n%s", setupExpiredKeyBackWord, screen) } if strings.Contains(screen, "free only") || strings.Contains(screen, setupLowCreditsWord) { t.Fatalf("an expired key was spelled as a low account:\n%s", screen) } } +// AND THE EXPIRED KEY'S LINE NEVER DISAGREES WITH THE KEYS LINE ABOUT ESC. With +// a key already saved and only the controls asked, the step stands alone: esc +// skips the setup rather than going back to a connect step that is not there, +// the keys line says so, and the foot names /connect as the door to a new key +// instead of an esc that would not take anyone to one. +func TestAnExpiredKeyOnAStandAloneControlsStepNamesConnectNotEsc(t *testing.T) { + a, dir, _ := setupApp(t, func(dir string) { + if err := config.WriteAPIKey(dir, "sk-or-v1-0123456789abcdef"); err != nil { + t.Fatal(err) + } + }) + a.pal = newPalette(tokens.ANSI256, false) + a.width, a.height = 120, 44 + if !a.setup.open || a.setup.step() != setupControls || a.setup.at != 0 { + t.Fatalf("a profile with a key must be asked the controls alone, got open=%v steps=%v at=%d", a.setup.open, a.setup.steps, a.setup.at) + } + a.readCredits = func(context.Context) (credits.Reading, error) { + return credits.Reading{Known: true, Expired: true}, nil + } + if err := config.WriteCreditsReading(dir, config.APIKeyAt(dir), credits.Reading{Known: true, Expired: true}); err != nil { + t.Fatal(err) + } + pressSetup(a, creditReadMsg{reading: credits.Reading{Known: true, Expired: true}}) + if !a.setupKeyExpired() { + t.Fatal("the reading did not mark the key expired") + } + screen := setupScreen(a) + if !strings.Contains(screen, setupExpiredKeyAloneWord) { + t.Fatalf("the screen must say %q; got:\n%s", setupExpiredKeyAloneWord, screen) + } + if strings.Contains(screen, setupExpiredKeyBackWord) { + t.Fatalf("the foot promised an esc that skips the setup; got:\n%s", screen) + } + if got := a.setupBackWord(); got != setupSkipKeysWord || !strings.Contains(screen, setupSkipKeysWord) { + t.Fatalf("the keys line says %q, want %q on the same frame:\n%s", got, setupSkipKeysWord, screen) + } +} + // THE EXAMPLES GO ROUND: `→` on the last one is the first, `←` on the first is // the last, and the count on the edge says where you are. func TestTheExamplesWrapAroundInBothDirections(t *testing.T) { From 876172a919ffca7d0090b2902606ec6d4834e490 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:26:41 -0400 Subject: [PATCH 17/26] chat: the setup's limit detail is four sentences with its commands quoted whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail ran on past "whichever is reached first stops the work" into what each ceiling holds and what the provider does, which is more than a `?` on one row should say; it stops there now. Its commands are quoted whole — '/budget 50', '/budget none', '/budget conversation 20' — so a reader sees the amount is part of what is typed, and the second sentence says a conversation's smaller ceiling is set by e.g. '/budget conversation 20'. A quote is a word boundary for the command chip now, on either side: prose that says '/budget 50' is naming the command and gets the chip on its name, '/settings' is chipped without its closing quote, and a quoted path is still a path. The manual mirrors the new wording. Co-Authored-By: Claude Fable 5.1 --- .../unreleased/1720-onboarding-ux-cleanups.md | 2 +- internal/manual/chat/getting-started.md | 13 +++++------ internal/tui3/onboarding.go | 15 ++++++------ internal/tui3/slashchip.go | 19 ++++++++++++--- internal/tui3/slashchip_test.go | 23 +++++++++++++++++++ 5 files changed, 53 insertions(+), 19 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 5da3c12ae8..0439d075b6 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -5,7 +5,7 @@ pr: 1720 surface: [chat, docs] invalidates: - "On the first conversation's screen, enter on a starting point filled the box and the next enter did nothing — the starting point kept taking it. With words in the box, enter now sends them: ↓ enter enter sends the starting point's sentence." - - "The `?` detail on the setup's Daily limit row said it counts recorded spending, running calls can carry it past, and task crews have a cap in /crew. It now explains the two ceilings: `/budget 50` changes the day's limit and `/budget none` removes it; `/budget conversation 20` gives the open conversation a smaller ceiling of its own and keeps it as the default for new ones; both hold at once and whichever is reached first stops the work — the day's everything until midnight, the conversation's just that conversation; the running turn always finishes." + - "The `?` detail on the setup's Daily limit row said it counts recorded spending, running calls can carry it past, and task crews have a cap in /crew. It now explains the two ceilings in four sentences: `'/budget 50'` changes the day's limit and `'/budget none'` removes it; a conversation can carry a smaller ceiling of its own set by e.g. `'/budget conversation 20'`; both hold at once and whichever is reached first stops the work. The commands are quoted whole, and a quote now counts as a word boundary for the command chip everywhere (`'/settings'` is chipped; a quoted path is not)." - "The setup's example panel followed the focused row (the limit showed `Follow the work and its cost`, `Start a conversation` the senior-dev hand-off) and never turned by itself. It opens on the first example and turns to the next every 3 seconds, round the ring; `←`/`→` browse by hand; any key — typing, walking the rows, browsing, a press — holds the clock for 3 seconds from that key; the focus never moves it; the screen-reader tier never turns by itself. exampleForControl is gone; setupFlow has turnGen and turnTicking, and setupTurnMsg is the clock." - "On the setup's controls screen `Start a conversation` carried a dim `enter` at its right, and the two explanations did not name a command. The loose `enter` is gone (the keys line says `enter starts` there); the limit's line ends `/budget changes it later.` and the chat model's `/model changes it later.`, each command painted as the composer's chip." - "The setup's controls screen had a row `Other settings use defaults · review` (or `Review other settings`) between the chat model and `Start a conversation`, which enter opened into three read-only rows (memory, ask before running, task countdown) and the line `/settings changes these and every other one.`. The row is gone — the form is three rows: the limit, the chat model, the way out — and one dim line under `Start a conversation` reads `Everything else is in /settings`, with `/settings` painted as the composer's command chip. controlReview, setupFlow.reviewOpen, setupReviewRow, setupReviewRows, setupReviewKeys and setupOtherSettingsWritten are gone." diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 3af599b64c..e40ccb38fe 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -152,13 +152,12 @@ Its one line reads: Type a number to change it — the `$` is drawn for you rather than typed — or type **`none`** for no limit, which is a first-class answer and makes the row read `no limit`. `?` on the row explains the two ceilings and how they meet: *The day's ceiling for -everything codeaf does: /budget 50 changes it later and /budget none removes it. A -conversation can carry a smaller ceiling of its own: /budget conversation 20 sets one for -the conversation you are in and keeps it as the default for new ones. Both hold at once, -and whichever is reached first stops the work — the day's holds everything until midnight -or you raise it, a conversation's holds just that conversation. The turn already running -always finishes, and your provider account has controls of its own.* Each `/budget` in it is -painted as a command chip. (Until 2026-10-01 the detail said only that the figure counts +everything codeaf does: '/budget 50' changes it later and '/budget none' removes it. A +conversation can carry a smaller ceiling of its own set by e.g. '/budget conversation 20'. +Both hold at once, and whichever is reached first stops the work.* The commands are quoted +whole, and each `/budget` in it is painted as a command chip. The day's limit holds +everything until midnight or you raise it; a conversation's holds just that conversation; +the turn already running always finishes (*Models and cost* has the rest). (Until 2026-10-01 the detail said only that the figure counts recorded spending, that running calls can carry it a little past, and that task crews have a cap of their own in `/crew`.) diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 9970df78c1..197a3c7200 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -120,14 +120,13 @@ const ( // be billed more than this" would be making a promise with somebody else's // money. const ( - controlLimitDetail = "The day's ceiling for everything " + product + " does: /budget 50 " + - "changes it later and /budget none removes it. A conversation can carry a " + - "smaller ceiling of its own: /budget conversation 20 sets one for the " + - "conversation you are in and keeps it as the default for new ones. Both hold " + - "at once, and whichever is reached first stops the work — the day's holds " + - "everything until midnight or you raise it, a conversation's holds just that " + - "conversation. The turn already running always finishes, and your provider " + - "account has controls of its own." + // The commands are quoted whole, so a reader sees that the amount is part of + // what is typed; the chip painter treats a quote as a word boundary + // (slashchip.go's [recognizedCommandSpans]) and still marks each command. + controlLimitDetail = "The day's ceiling for everything " + product + " does: '/budget 50' " + + "changes it later and '/budget none' removes it. A conversation can carry a " + + "smaller ceiling of its own set by e.g. '/budget conversation 20'. Both hold " + + "at once, and whichever is reached first stops the work." controlModelDetail = "It also handles this conversation's tool use. Changing it here is " + "the same choice /model makes, and it is kept for the next launch." + " Tasks get their own crew, picked per task · /crew shows it." diff --git a/internal/tui3/slashchip.go b/internal/tui3/slashchip.go index e16338cce1..12a776f371 100644 --- a/internal/tui3/slashchip.go +++ b/internal/tui3/slashchip.go @@ -100,7 +100,9 @@ func knownCommand(word string) bool { // That one rule is what keeps a path out of this: "/Users/example" is a single // candidate whose word is "Users/santosh" and matches nothing, rather than two // candidates one of which might. A slash with a letter in front of it — the one -// in "http://", the one in "cmd/codeaf" — is not a candidate at all. +// in "http://", the one in "cmd/codeaf" — is not a candidate at all. A QUOTE IS +// A WORD BOUNDARY TOO, on either side: prose that says '/budget 50' is naming +// the command, and the closing quote of '/settings' is not part of its name. // // boundary says whether position 0 of value counts as a word boundary. The // composer paints one soft-wrapped ROW at a time, and a row that begins in the @@ -116,11 +118,11 @@ func recognizedCommandSpans(value []rune, boundary bool) []segment { if !boundary { continue } - case value[i-1] != ' ' && value[i-1] != '\n': + case !commandBoundary(value[i-1]): continue } end := i + 1 - for end < len(value) && value[end] != ' ' && value[end] != '\n' { + for end < len(value) && !commandBoundary(value[end]) { end++ } if knownCommand(string(value[i+1 : end])) { @@ -134,6 +136,17 @@ func recognizedCommandSpans(value []rune, boundary bool) []segment { return out } +// commandBoundary is a rune a command's name stops at or starts after: the +// spaces and newlines that separate words, and the straight and curly quotes +// that prose wraps a command in. +func commandBoundary(r rune) bool { + switch r { + case ' ', '\n', '\'', '"', '\u2018', '\u2019', '\u201c', '\u201d': + return true + } + return false +} + func commandSpans(value []rune, boundary bool) []segment { if strings.HasPrefix(strings.TrimSpace(string(value)), "!") { return nil diff --git a/internal/tui3/slashchip_test.go b/internal/tui3/slashchip_test.go index 7891f99b44..36d2bedf83 100644 --- a/internal/tui3/slashchip_test.go +++ b/internal/tui3/slashchip_test.go @@ -343,6 +343,29 @@ func TestTheCommandListOpensAtAWordBoundaryAndNotInsideAWord(t *testing.T) { } } +// A QUOTED COMMAND IS STILL THE COMMAND. Prose that names one — the setup's +// detail says '/budget 50' — gets its chip on the name alone, and a closing +// quote right after the name is not part of it. A quoted path stays a path. +func TestAQuoteIsAWordBoundaryForACommandChip(t *testing.T) { + for _, c := range []struct { + text string + want []string + }{ + {"type '/budget 50' to change it", []string{"/budget"}}, + {"\u201c/budget conversation 20\u201d sets one", []string{"/budget"}}, + {"see '/settings'", []string{"/settings"}}, + {"open '/Users/person/notes.md'", nil}, + } { + var got []string + for _, span := range recognizedCommandSpans([]rune(c.text), true) { + got = append(got, string([]rune(c.text)[span.from:span.to])) + } + if strings.Join(got, " ") != strings.Join(c.want, " ") { + t.Errorf("%q chipped %v, want %v", c.text, got, c.want) + } + } +} + func TestAnAbsolutePathDoesNotHoldTheCommandListOpen(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) From bc251605e035b043d21ff5bc9db67eac2ea11206 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 11:40:30 -0400 Subject: [PATCH 18/26] tui3: a command named in quotes is talked about, not run Quote boundaries had reached the send scan, so asking about /task could start work. Keep the original word boundaries for action; resting words and follow-up plain ranges use the transcript's quote-aware painting scan. Leave quoted door names plain after an ordinary send and in guard/revive wrappers, while live tags retain their chips. Scope the bash guard to action and draft/transcript paint; informational lines beginning with ! still chip known commands. Cover straight and curly quotes, whole-message delivery, unchanged unquoted tags, ordinary-send transcript paint, informational ! lines and the manual answer. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/commands.md | 12 +++++ internal/tui3/followup.go | 5 +- internal/tui3/slashchip.go | 69 +++++++++++++++++--------- internal/tui3/slashchip_test.go | 85 ++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 24 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index df1cad7656..8cb8f12f27 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -160,6 +160,18 @@ Other commands remain ordinary prose away from the start. `later I will run /com this` is sent literally, and codeaf still chips `/compact` there — the mark says the word is recognised, not that enter will run it. +## Asking what a quoted /task or /standing command does + +`What does '/task' do?` sends that whole sentence as an ordinary message. A command +named in straight single or double quotes, or curly quotes, never acts as a send-door +tag. `What does “/task” do?` starts no task; quoting `/standing` raises no standing +order. The quoted command still wears the composer's chip in explanations and help, +and a quoted path stays plain. In the sent message, a quoted send-door name is plain +because no door acted on it. + +Without quotes, `What does /task do?` still contains a live task tag: Enter starts a +task with the remaining words. Quote the command when you want to ask about it. + ## Backspace after a slash tag makes it plain words With the caret immediately after a live `/standing`, `/orders`, or `/task` tag, the first diff --git a/internal/tui3/followup.go b/internal/tui3/followup.go index 3a0d9d912a..3862192b5b 100644 --- a/internal/tui3/followup.go +++ b/internal/tui3/followup.go @@ -204,8 +204,9 @@ func (a *app) startFollow() tea.Cmd { plain := restingDoorWords(value) // A DEMOTION TRAVELS WITH THE QUEUED WORDS. The fallback knows only // resting door words, so the queue's own annotations must join it. Match - // against this text's command spans to drop stale ranges and duplicates. - for _, s := range commandSpans(value, true) { + // against the same spans the transcript paints, including quoted names, + // to drop stale ranges and duplicates. + for _, s := range paintedCommandSpans(value, true) { if containsSegment(next.demoted, s) && !containsSegment(plain, s) { plain = append(plain, s) } diff --git a/internal/tui3/slashchip.go b/internal/tui3/slashchip.go index 12a776f371..d084859ed1 100644 --- a/internal/tui3/slashchip.go +++ b/internal/tui3/slashchip.go @@ -93,7 +93,7 @@ func knownCommand(word string) bool { return false } -// commandSpans finds every recognized slash command in value, as rune ranges, +// recognizedCommandSpans finds every recognized slash command in value, as rune ranges, // left to right. // // A CANDIDATE STARTS AT A WORD BOUNDARY and runs to the next space or newline. @@ -108,6 +108,15 @@ func knownCommand(word string) bool { // composer paints one soft-wrapped ROW at a time, and a row that begins in the // middle of a word begins in the middle of a word. func recognizedCommandSpans(value []rune, boundary bool) []segment { + return scanCommandSpans(value, boundary, true) +} + +// scanCommandSpans keeps painting and action on the same command vocabulary. +// QUOTES ARE BOUNDARIES FOR PAINTING ONLY: naming a command must never run it. +func scanCommandSpans(value []rune, boundary, quoted bool) []segment { + isBoundary := func(r rune) bool { + return r == ' ' || r == '\n' || (quoted && commandBoundary(r)) + } var out []segment for i := 0; i < len(value); i++ { if value[i] != '/' { @@ -118,11 +127,11 @@ func recognizedCommandSpans(value []rune, boundary bool) []segment { if !boundary { continue } - case !commandBoundary(value[i-1]): + case !isBoundary(value[i-1]): continue } end := i + 1 - for end < len(value) && !commandBoundary(value[end]) { + for end < len(value) && !isBoundary(value[end]) { end++ } if knownCommand(string(value[i+1 : end])) { @@ -151,11 +160,16 @@ func commandSpans(value []rune, boundary bool) []segment { if strings.HasPrefix(strings.TrimSpace(string(value)), "!") { return nil } - // A CHIP IS A RECOGNITION MARK, NOT A SEND PROMISE: [recognizedCommandSpans] - // already returns only known commands, so the mark travels with the word - // anywhere it stands. The promise law lives on [commandDoor] (liveTags, the - // hint line): send still runs only a leading command, and still acts on a - // send-door tag away from the head. + return scanCommandSpans(value, boundary, false) +} + +// paintedCommandSpans keeps bash input plain while recognizing quoted names. +// THE BASH GUARD BELONGS TO INPUT AND TRANSCRIPT PAINTING: informational prose +// still names commands even when its line begins with an exclamation mark. +func paintedCommandSpans(value []rune, boundary bool) []segment { + if strings.HasPrefix(strings.TrimSpace(string(value)), "!") { + return nil + } return recognizedCommandSpans(value, boundary) } @@ -173,10 +187,11 @@ func containsSegment(list []segment, want segment) bool { // on: a live tag would have taken its own door before this road, or the road // has no tag doors. It is drawn plain, as every mid-sentence door word was // drawn before every recognised command wore a chip. Ordinary commands keep -// their chip. +// their chip. THE SCAN MATCHES TRANSCRIPT PAINTING, including quoted names, +// because every door word the paint recognises needs its resting annotation. func restingDoorWords(value []rune) []segment { var plain []segment - for _, s := range commandSpans(value, true) { + for _, s := range paintedCommandSpans(value, true) { if s.from > 0 && commandDoor(string(value[s.from+1:s.to])) != sendDoorNone { plain = append(plain, s) } @@ -187,22 +202,32 @@ func restingDoorWords(value []rune) []segment { // liveTags returns the actionable send-door words away from the head command. func (a *app) liveTags() []segment { return a.input.liveTags() } -// plainTags returns the demoted ranges as rune offsets into the trimmed line a -// send will display. The editor's ranges are offsets into its raw value, and -// the displayed line drops the leading whitespace ([strings.TrimSpace] in -// [app.enterLine]); every range is shifted by that many runes. A range that -// starts inside the trimmed whitespace is dropped, because it cannot name a -// word the displayed line still holds. +// plainTags returns the demoted and resting door ranges as rune offsets into +// the trimmed line a send will display. The editor's ranges are offsets into +// its raw value. The displayed line drops the leading whitespace +// ([strings.TrimSpace] in [app.enterLine]); every range is shifted by that many +// runes. A range that starts inside the trimmed whitespace is dropped, because +// it cannot name a word the displayed line still holds. func (e *editor) plainTags() []segment { - if len(e.demotedTags) == 0 { + plain := append([]segment(nil), e.demotedTags...) + // A QUOTED DOOR NAME IS PROSE WHEN SENT. The painting scan sees it, but + // the action scan does not, so it joins the plain ranges before reset. + // Live tags keep their chip to show which door acted on those words. + live := e.liveTags() + for _, s := range restingDoorWords(e.value) { + if !containsSegment(live, s) && !containsSegment(plain, s) { + plain = append(plain, s) + } + } + if len(plain) == 0 { return nil } lead := 0 for lead < len(e.value) && unicode.IsSpace(e.value[lead]) { lead++ } - out := make([]segment, 0, len(e.demotedTags)) - for _, s := range e.demotedTags { + out := make([]segment, 0, len(plain)) + for _, s := range plain { if s.from < lead { continue } @@ -370,7 +395,7 @@ func plainWithoutTag(value []rune, tag segment, plain []segment) []segment { // unpainted. func paintCommands(line string, pal palette, ink func(string) string, boundary bool) string { value := []rune(line) - spans := commandSpans(value, boundary) + spans := paintedCommandSpans(value, boundary) return paintCommandSpans(line, spans, pal, ink) } @@ -403,7 +428,7 @@ func paintCommandSpans(line string, spans []segment, pal palette, ink func(strin // rebase would mis-chip across wrapped rows, because a span's offset restarts // at zero on every row. func transcriptCommandSpans(value []rune, plain []segment, offset int) []segment { - spans := commandSpans(value, true) + spans := paintedCommandSpans(value, true) if len(plain) == 0 { return spans } @@ -419,7 +444,7 @@ func transcriptCommandSpans(value []rune, plain []segment, offset int) []segment func paintDraftCommands(line string, pal palette, ink func(string) string, offset int, boundary bool, demoted []segment) string { value := []rune(line) - spans := commandSpans(value, boundary) + spans := paintedCommandSpans(value, boundary) kept := spans[:0] for _, s := range spans { s.from += offset diff --git a/internal/tui3/slashchip_test.go b/internal/tui3/slashchip_test.go index 36d2bedf83..5a2c4360c3 100644 --- a/internal/tui3/slashchip_test.go +++ b/internal/tui3/slashchip_test.go @@ -8,6 +8,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/standing" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) // ── the chip ──────────────────────────────────────────────────────────────── @@ -69,6 +70,28 @@ func sameRuns(t *testing.T, got, want []string, what string) { } } +func TestInformationalLineStartingWithBangStillChipsAKnownCommand(t *testing.T) { + pal := newPalette(tokens.ANSI256, false) + for _, line := range []string{"! use /task for this", " ! ask about '/task'"} { + t.Run(line, func(t *testing.T) { + value := []rune(line) + spans := recognizedCommandSpans(value, true) + if len(spans) != 1 || string(value[spans[0].from:spans[0].to]) != "/task" { + t.Fatalf("informational line lost its known command: spans=%v", spans) + } + sameRuns(t, chipRuns(paintPayload(line, nil, pal, pal.ink)), []string{"/task"}, "informational paint") + if got := commandSpans(value, true); len(got) != 0 { + t.Fatalf("bash line acquired actionable commands: %v", got) + } + sameRuns(t, chipRuns(paintCommands(line, pal, pal.ink, true)), nil, "bash command paint") + sameRuns(t, chipRuns(paintDraftCommands(line, pal, pal.ink, 0, true, nil)), nil, "bash draft paint") + if got := transcriptCommandSpans(value, nil, 0); len(got) != 0 { + t.Fatalf("bash transcript acquired command chips: %v", got) + } + }) + } +} + func TestAKnownCommandIsChippedInTheBoxAndAnUnknownWordIsNot(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) @@ -304,6 +327,68 @@ func TestTaskTagUsesTheTaskCommandRoad(t *testing.T) { } } +func TestQuotedDoorNamesSendTheWholeSentenceAsProse(t *testing.T) { + for _, quotes := range [][2]string{{"'", "'"}, {"\"", "\""}, {"‘", "’"}, {"“", "”"}} { + for _, word := range []string{"task", "standing", "background", "senior-dev"} { + line := "What does " + quotes[0] + "/" + word + quotes[1] + " do?" + t.Run(line, func(t *testing.T) { + base := &fakeAgent{model: "m"} + door := &taskCommandFake{Agent: base} + a := newTestApp(door) + typeInto(t, a, line) + if tags := a.liveTags(); len(tags) != 0 { + t.Errorf("quoted command became %d actionable tags", len(tags)) + } + drive(t, a, key("enter")) + if door.singleCalls != 0 || len(base.marked) != 0 { + t.Fatalf("quoted command acted: tasks=%d marked=%q", door.singleCalls, base.marked) + } + if len(base.sent) != 1 || base.sent[0] != line { + t.Fatalf("quoted question did not send whole: %q", base.sent) + } + if len(a.entries) == 0 || a.entries[0].text != line { + t.Fatalf("ordinary message lost the whole sentence: entries=%+v", a.entries) + } + }) + } + } +} + +func TestQuotedDoorNamesStayPlainInTheOrdinarySendTranscript(t *testing.T) { + for _, quotes := range [][2]string{{"'", "'"}, {"\"", "\""}, {"‘", "’"}, {"“", "”"}} { + for _, word := range []string{"task", "standing"} { + line := "What does " + quotes[0] + "/" + word + quotes[1] + " do?" + t.Run(line, func(t *testing.T) { + base := &fakeAgent{model: "m"} + door := &taskCommandFake{Agent: base} + a := newTestApp(door) + typeInto(t, a, line) + sameRuns(t, boxRuns(a), []string{"/" + word}, "the quoted draft") + drive(t, a, key("enter")) + if door.singleCalls != 0 || len(base.marked) != 0 || len(base.sent) != 1 || base.sent[0] != line { + t.Fatalf("quoted question did not send as prose: tasks=%d marked=%q sent=%q", door.singleCalls, base.marked, base.sent) + } + e := lastUserEntry(t, a) + if e.text != line { + t.Fatalf("ordinary transcript lost the question: %q", e.text) + } + sameRuns(t, chipRuns(a.renderEntry(0, e, 30)...), nil, "the quoted ordinary transcript") + }) + } + } +} + +func TestUnquotedTaskQuestionKeepsItsLiveTag(t *testing.T) { + base := &fakeAgent{model: "m"} + door := &taskCommandFake{Agent: base} + a := newTestApp(door) + typeInto(t, a, "What does /task do?") + drive(t, a, key("enter")) + if door.singleCalls != 1 || door.brief != "What does do?" { + t.Fatalf("unquoted task question changed: tasks=%d brief=%q", door.singleCalls, door.brief) + } +} + func TestTheModelsOwnProseIsNeverChipped(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) a.entries = []entry{{kind: entryAssistant, text: "run /compact when it gets long", settled: true}} From f547156672d078d2028fe4423daad777e4bf08e6 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 11:40:30 -0400 Subject: [PATCH 19/26] credits: refresh expired keys across the engine wire without switching models The engine flattened refusals into text and lost the expiry-triggered balance read. Recognize that text beside provider status handling, and keep expired readings from moving untouched conversations between defaults. Cover structured and flattened refusals, excluded statuses and direct services, and document both launch roads. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/openrouter-credits.md | 6 ++- internal/provider/status.go | 14 ++++++ internal/provider/status_test.go | 33 +++++++++++++ internal/tui3/credits.go | 7 ++- internal/tui3/credits_test.go | 55 ++++++++++++++++++++++ 5 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 internal/provider/status_test.go diff --git a/internal/manual/chat/openrouter-credits.md b/internal/manual/chat/openrouter-credits.md index b6e7db8fef..43e759c652 100644 --- a/internal/manual/chat/openrouter-credits.md +++ b/internal/manual/chat/openrouter-credits.md @@ -82,8 +82,10 @@ warning colour. Unlike the low-credits line it shows on **every** model the defa OpenRouter service serves — free or paid — and never on a model served by Codex, a local model or another connected service. It wins over the low-credits line, and the free defaults are **not** used, because they would fail on the same key. A turn refused with -`API key expired` mid-session starts a fresh read, so the warning arrives without a -relaunch. On the first-run setup screen the same fact stands on the last row, and the list +`API key expired` mid-session starts a fresh read on both the ordinary engine launch +and `codeaf chat --no-host`, so the warning arrives without a relaunch. An expired +reading leaves an untouched conversation on its current model: it moves neither +from the free default to the paid default nor the other way, and adds no model note. On the first-run setup screen the same fact stands on the last row, and the list is not cut to free models. When a connect step came before the screen it reads `Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys` and `esc` goes back there; when the key was already in the shell or the profile and only the diff --git a/internal/provider/status.go b/internal/provider/status.go index 46aecd3a97..d7f01ac04e 100644 --- a/internal/provider/status.go +++ b/internal/provider/status.go @@ -2,10 +2,24 @@ package provider import ( "errors" + "net/http" "strconv" "strings" ) +// KeyExpiredFrom keeps the expiry fact after the engine wire has flattened a +// refusal into text, so hosted and local conversations refresh the same reading. +func KeyExpiredFrom(err error) bool { + if err == nil { + return false + } + if refusal, ok := RefusalFrom(err); ok { + return refusal.KeyExpired() + } + status, ok := statusFromText(err.Error()) + return ok && status == http.StatusUnauthorized && strings.Contains(strings.ToLower(err.Error()), "expired") +} + // StatusOf returns the HTTP status a provider failure carried, including the // status in the legacy `API error (N)` spelling. The fallback is centralized // here because an in-band or wrapped failure can lose its structured status; diff --git a/internal/provider/status_test.go b/internal/provider/status_test.go new file mode 100644 index 0000000000..cf3c353ef5 --- /dev/null +++ b/internal/provider/status_test.go @@ -0,0 +1,33 @@ +package provider + +import ( + "errors" + "fmt" + "testing" +) + +func TestExpiredKeyRecognitionKeepsStructuredAndFlattenedRefusalsEquivalent(t *testing.T) { + for _, tc := range []struct { + status int + message string + want bool + }{ + {401, "API key expired.", true}, {401, "token EXPIRED", true}, + {401, "invalid key", false}, {403, "token expired", false}, + {429, "expired quota", false}, {500, "expired upstream", false}, + } { + t.Run(fmt.Sprintf("%d/%s", tc.status, tc.message), func(t *testing.T) { + err := &APIError{Status: tc.status, Message: tc.message} + for _, got := range []error{err, errors.New(err.Error()), fmt.Errorf("turn failed: %w", err)} { + if KeyExpiredFrom(got) != tc.want { + t.Errorf("%q expired=%v, want %v", got, KeyExpiredFrom(got), tc.want) + } + } + }) + } + for _, err := range []error{nil, errors.New("connection expired"), errors.New("API error (4010): expired")} { + if KeyExpiredFrom(err) { + t.Errorf("%v became an expired key refusal", err) + } + } +} diff --git a/internal/tui3/credits.go b/internal/tui3/credits.go index f203245188..1509bd369b 100644 --- a/internal/tui3/credits.go +++ b/internal/tui3/credits.go @@ -162,7 +162,7 @@ func (a *app) refreshCreditWarnings() { // launch read says otherwise. A conversation that has sent anything keeps its // model either way, and nothing here writes a talk row. want := config.ChatDefaultAt(a.profileDir) - if a.readCredits != nil && !a.creditSwitching && a.implicitTalk && a.model != want && + if a.readCredits != nil && !a.creditsExpired && !a.creditSwitching && a.implicitTalk && a.model != want && (a.model == config.DefaultModel || a.model == config.FreeChatModel) && a.freshAndEmpty() && config.ChatModelAt(a.profileDir) == "" { a.creditSwitching = true @@ -209,7 +209,10 @@ func (a *app) creditRefusalEnded(err error) bool { if err == nil || a.readCredits == nil || a.modelIsDirect(a.model) { return false } - if refusal, ok := provider.RefusalFrom(err); ok && (refusal.AccountCannotPay() || refusal.KeyExpired()) { + if provider.KeyExpiredFrom(err) { + return true + } + if refusal, ok := provider.RefusalFrom(err); ok && refusal.AccountCannotPay() { return true } prefix := config.ConnectionOutcomeWord(modelsource.DefaultSource("").Name, modelsource.Outcome{Kind: modelsource.OutcomeAccountCannotPay}) diff --git a/internal/tui3/credits_test.go b/internal/tui3/credits_test.go index 47a1d7e760..aba0b9b0ed 100644 --- a/internal/tui3/credits_test.go +++ b/internal/tui3/credits_test.go @@ -3,6 +3,7 @@ package tui3 import ( "context" "errors" + "fmt" "strings" "testing" "time" @@ -12,9 +13,63 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/credits" "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" ) +func TestEngineRefusalReadsCreditsOnlyForExpiredKeysOrUnpayableAccounts(t *testing.T) { + a := placeApp(t) + a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{}, nil } + for _, tc := range []struct { + status int + message string + want bool + }{ + {401, "API key expired.", true}, {401, "invalid key", false}, + {403, "API key expired.", false}, {429, "expired quota", false}, + {500, "expired upstream", false}, + } { + t.Run(fmt.Sprintf("%d/%s", tc.status, tc.message), func(t *testing.T) { + err := &provider.APIError{Status: tc.status, Message: tc.message} + crossed := remote.WireEvent(session.Event{Err: err}).Unwire().Err + if got := a.creditRefusalEnded(crossed); got != tc.want { + t.Fatalf("engine refusal %q requests credit read=%v, want %v", crossed, got, tc.want) + } + }) + } + if a.creditRefusalEnded(errors.New("network connection expired")) { + t.Fatal("network failure requested a credit read") + } + prefix := config.ConnectionOutcomeWord(modelsource.DefaultSource("").Name, modelsource.Outcome{Kind: modelsource.OutcomeAccountCannotPay}) + if !a.creditRefusalEnded(errors.New(prefix)) { + t.Fatal("the account-cannot-pay reading stopped requesting credits") + } + source := modelsource.Source{ID: "custom:company", Written: "custom:company", Name: "company", Address: "http://local.invalid/v1"} + a.sources = modelsource.NewSet(modelsource.Connected{Source: source, Address: source.Address}) + a.model = "custom:company/model" + err := remote.WireEvent(session.Event{Err: &provider.APIError{Status: 401, Message: "API key expired."}}).Unwire().Err + if a.creditRefusalEnded(err) { + t.Fatal("a direct model's expired key requested OpenRouter credits") + } +} + +func TestExpiredReadKeepsBothUntouchedConversationDefaults(t *testing.T) { + for _, model := range []string{config.FreeChatModel, config.DefaultModel} { + t.Run(model, func(t *testing.T) { + a := placeApp(t) + a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{}, nil } + a.model, a.implicitTalk = model, true + seedExpiredKey(t, a) + before := len(a.entries) + a.refreshCreditWarnings() + if a.model != model || len(a.entries) != before { + t.Fatalf("expired read changed untouched model %q to %q and added %d notes", model, a.model, len(a.entries)-before) + } + }) + } +} + func TestSurfaceCancelsAndJoinsAnInFlightCreditRead(t *testing.T) { started, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) reader := func(ctx context.Context) (credits.Reading, error) { From 6d15cc32746cb9623e8239a343942f4179df095b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 11:40:31 -0400 Subject: [PATCH 20/26] tui3: keep empty model lists open and hold one setup turn timer Keep the no-match model list open with its row unanswered and its focus and model unchanged. Hold example turns behind one timer, including a tick queued before the latest key. Build the free-id set once per catalog pass and prove the 400-row order and membership; omit the wall-clock benchmark comparison. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/tui3/firstrun.go | 15 ++-- internal/tui3/onboarding.go | 56 +++++++++---- internal/tui3/onboarding_test.go | 131 +++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 21 deletions(-) diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index 21b588c07f..281f3b6a71 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -5,6 +5,7 @@ import ( "io/fs" "strconv" "strings" + "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" @@ -100,7 +101,7 @@ type setupFlow struct { control setupControl detail bool // answered marks the rows enter has acted on — the limit committed, a - // model taken, the review shown — which is what paints a row's name dim + // model taken — which is what paints a row's name dim // once it is done (onboarding.go's [app.setupLabelInk]). answered [setupControlCount]bool limitText string @@ -118,11 +119,13 @@ type setupFlow struct { demoAt int demoGen int demoTicking bool - // The turn's clock (onboarding.go's [app.setupTurnCmd]): turnGen stamps - // the ticks so one armed before a key is dropped, and turnTicking says one - // is in flight. - turnGen int - turnTicking bool + // A hold moves the deadline while one timer remains in flight. Its stamp + // is separate from the hold generation, because a key can arrive after + // the timer has queued its message and must still hold that arriving tick. + turnGen int + turnClockGen int + turnTicking bool + turnHoldUntil time.Time // refusal is the one line the screen says under the box when enter was // pressed on something it will not write. Any other key clears it. refusal string diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 197a3c7200..6cf0083adf 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -3,6 +3,7 @@ package tui3 import ( "sort" "strings" + "sync/atomic" "time" tea "charm.land/bubbletea/v2" @@ -11,6 +12,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/fuzzy" + "github.com/Agent-Field/codeaf/internal/roles" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -393,9 +395,10 @@ func (a *app) setupControlsEnter() bool { case controlChatModel: if s.modelOpen { models := a.setupModelChoices() - if s.modelAt >= 0 && s.modelAt < len(models) { - a.takeSetupModel(models[s.modelAt].ID) + if s.modelAt < 0 || s.modelAt >= len(models) { + return false } + a.takeSetupModel(models[s.modelAt].ID) s.closeChoosers() // AND THEN ENTER GOES ON, as it does on the limit. A model taken // from the list is this row answered; an earlier build left the @@ -627,11 +630,20 @@ func (a *app) setupModelChoices() []Model { // where a person who has never run the program is choosing by name, a // list of three hundred paid models they cannot use is a list they will // pick the wrong row from (five new people did, 2026-09-30). The same - // test the warning uses decides a row ([app.paidCreditModel]): a `:free` + // test the warning uses decides a row: a `:free` // id, a catalog row priced at zero, or a model another service serves. + // Build the free-id set once because this list is read on every key; + // scanning the whole catalog again for each row made that work quadratic. + freeIDs := make(map[string]bool, len(models)) + for _, model := range models { + if config.IsFreeModel(model.ID, model.PriceKnown, model.PromptPrice, model.CompletionPrice, model.RequestPrice) { + freeIDs[model.ID] = true + } + } free := make([]Model, 0, len(models)) for _, model := range models { - if !a.paidCreditModel(model.ID, models) { + bare, _ := roles.SplitEffort(strings.TrimPrefix(model.ID, "~")) + if strings.TrimSpace(model.ID) == "" || a.modelIsDirect(model.ID) || config.IsFreeModel(model.ID, false, 0, 0, 0) || freeIDs[bare] { free = append(free, model) } } @@ -1982,8 +1994,13 @@ func (a *app) setupDemoBeatAt(gen int) tea.Cmd { // next, and how long any key holds the clock. const setupTurnEvery = 3 * time.Second -// setupTurnMsg is the turn's clock arriving, stamped with the generation that -// armed it so a tick left over from before a key is dropped. +// setupTurnClockGeneration gives each physical timer a separate negative stamp. +// A timer's queued message survives a later hold, but never a closed showing; +// nonnegative generations remain the direct clock-delivery seam's hold stamps. +var setupTurnClockGeneration atomic.Int64 + +// setupTurnMsg is the turn's clock arriving, stamped so an old showing cannot +// turn a panel that has since closed. type setupTurnMsg struct{ gen int } // turnSetupExample moves the panel by delta, round the ring, and plays the @@ -2010,32 +2027,41 @@ func (a *app) setupTurnCmd() tea.Cmd { return nil } s.turnTicking = true - gen := s.turnGen - return surfaceTick(setupTurnEvery, func(time.Time) tea.Msg { return setupTurnMsg{gen: gen} }) + s.turnClockGen = -int(setupTurnClockGeneration.Add(1)) + gen := s.turnClockGen + delay := setupTurnEvery + if remaining := s.turnHoldUntil.Sub(a.now()); remaining > 0 { + delay = remaining + } + return surfaceTick(delay, func(time.Time) tea.Msg { return setupTurnMsg{gen: gen} }) } -// holdSetupTurn is any key on this screen: the clock in flight is retired and -// a fresh one armed, so the next turn is a full [setupTurnEvery] after the key. +// holdSetupTurn moves the deadline rather than arming a timer per key. The +// timer already in flight checks the last hold when it reaches the update loop. func (a *app) holdSetupTurn() tea.Cmd { s := &a.setup s.turnGen++ - s.turnTicking = false + s.turnHoldUntil = a.now().Add(setupTurnEvery) return a.setupTurnCmd() } // setupTurnAt is the clock arriving. A tick from a retired generation is // dropped whole, and so is one that finds the screen gone or stepped back to -// the key; otherwise the next example arrives, plays, and the clock is armed -// again. +// the key. A live tick waits out the latest hold before the next example +// arrives, plays, and arms the clock again. func (a *app) setupTurnAt(gen int) tea.Cmd { s := &a.setup - if gen != s.turnGen { + if gen != s.turnGen && gen != s.turnClockGen { return nil } s.turnTicking = false - if !s.open || len(s.steps) == 0 || s.step() != setupControls { + if !s.open || len(s.steps) == 0 || s.step() != setupControls || a.linear { return nil } + if a.now().Before(s.turnHoldUntil) { + return a.setupTurnCmd() + } + s.turnHoldUntil = time.Time{} a.turnSetupExample(1) return tea.Batch(a.setupDemoCmd(), a.setupTurnCmd()) } diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index e7a4bc2433..40a59ac16d 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -2,8 +2,10 @@ package tui3 import ( "context" + "fmt" "strings" "testing" + "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" @@ -14,6 +16,135 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) +func TestSetupEnterWithNoMatchingModelKeepsTheListAndFocus(t *testing.T) { + a, _ := controlsApp(t, nil) + a.setup.control = controlChatModel + pressSetup(a, key("enter")) + a.filterSetupModels("zzzz") + model := a.model + pressSetup(a, key("enter")) + if !a.setup.modelOpen || a.setup.control != controlChatModel || a.setup.answered[controlChatModel] || a.model != model { + t.Fatalf("no-match enter closed=%v moved=%v answered=%v model=%q", !a.setup.modelOpen, a.setup.control != controlChatModel, a.setup.answered[controlChatModel], a.model) + } + if !strings.Contains(setupScreen(a), "nothing matches") { + t.Fatal("no-match enter lost its nothing-matches line") + } + pressSetup(a, key("backspace"), key("backspace"), key("backspace"), key("backspace")) + if len(a.setupModelChoices()) == 0 || !a.setup.modelOpen { + t.Fatal("backspace did not widen the open list") + } + pressSetup(a, key("esc")) + if a.setup.modelOpen || a.setup.answered[controlChatModel] { + t.Fatal("esc did not close the unanswered list") + } +} + +func TestSetupRapidKeysAndPressesKeepOneTimerUntilTheLastHold(t *testing.T) { + a, _ := controlsApp(t, nil) + a.setup.turnTicking = false + now := time.Unix(100, 0) + a.clock = func() time.Time { return now } + old := surfaceTick + t.Cleanup(func() { surfaceTick = old }) + var turns []func(time.Time) tea.Msg + var delays []time.Duration + surfaceTick = func(d time.Duration, cb func(time.Time) tea.Msg) tea.Cmd { + if d > setupDemoBeat { + turns = append(turns, cb) + delays = append(delays, d) + } + return func() tea.Msg { return nil } + } + a.setupTurnCmd() + for i := 0; i < 100; i++ { + now = now.Add(10 * time.Millisecond) + if i%2 == 0 { + a.setupControlsPress("down", "") + } else { + a.Update(clickAt(0, 0)) + } + } + if len(turns) != 1 { + t.Fatalf("100 rapid keys and presses armed %d turn timers, want 1 in flight", len(turns)) + } + at := a.setup.example + now = time.Unix(103, 0) + a.Update(turns[0](now)) + if a.setup.example != at || len(turns) != 2 || delays[1] != time.Second { + t.Fatalf("early tick turned=%v timers=%d delays=%v; want a one-second remainder", a.setup.example != at, len(turns), delays) + } + now = now.Add(time.Second) + a.Update(turns[1](now)) + if a.setup.example != wrapCursor(at, 1, len(setupExamples)) || len(turns) != 3 { + t.Fatalf("last hold did not turn once and rearm: example=%d timers=%d", a.setup.example, len(turns)) + } + pressSetup(a, key("esc")) + a.Update(turns[2](now.Add(setupTurnEvery))) + if len(turns) != 3 || a.setup.turnTicking { + t.Fatal("the turn clock continued on the key step") + } +} + +func TestSetupKeyBeforeAQueuedTickKeepsTheClockAlive(t *testing.T) { + a, _ := controlsApp(t, nil) + a.setup.turnTicking = false + now := time.Unix(100, 0) + a.clock = func() time.Time { return now } + old := surfaceTick + t.Cleanup(func() { surfaceTick = old }) + var turns []func(time.Time) tea.Msg + surfaceTick = func(d time.Duration, cb func(time.Time) tea.Msg) tea.Cmd { + if d > setupDemoBeat { + turns = append(turns, cb) + } + return func() tea.Msg { return nil } + } + a.setupTurnCmd() + now = now.Add(setupTurnEvery) + queued := turns[0](now) + a.setupControlsPress("down", "") + at := a.setup.example + a.Update(queued) + if len(turns) != 2 || a.setup.example != at { + t.Fatalf("a key before the queued tick stopped the clock: timers=%d turned=%v", len(turns), a.setup.example != at) + } + now = now.Add(setupTurnEvery) + a.Update(turns[1](now)) + if a.setup.example != wrapCursor(at, 1, len(setupExamples)) { + t.Fatal("the queued tick never resumed after the full hold") + } +} + +func TestLowSetupCatalogKeepsFourHundredRowsInSourceOrder(t *testing.T) { + a, _ := controlsApp(t, nil) + a.creditsLow = true + a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{}, nil } + a.model = "paid/current" + catalog := make([]Model, 400) + want := []string{a.model} + for i := range catalog { + row := Model{ID: fmt.Sprintf("catalog/model-%03d", i), PriceKnown: i%4 != 3, PromptPrice: 1} + switch i % 4 { + case 0: + row.ID += ":free" + case 1: + row.PromptPrice = 0 + } + catalog[i] = row + if i%4 < 2 { + want = append(want, row.ID) + } + } + a.models = func() []Model { return catalog } + var got []string + for _, row := range a.setupModelChoices() { + got = append(got, row.ID) + } + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("400-row catalog changed membership or source order: got %v, want %v", got, want) + } +} + // THE CONTROLS SCREEN'S OWN TESTS (onboarding.go). // // Every one of these is about something the screen may or may not do to a From d89822a36176e51f0d44812f685e0cc59fad405e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 11:40:31 -0400 Subject: [PATCH 21/26] telemetry: stop sending immediately when the settings switch is turned off The settings row persisted off while the running process kept sending usage and exit events. Close the existing process gate after a successful off write; enabling waits for startup to resolve all opt-outs. Name replacement switches in the removed-command error and make the disclosure, hints and manual agree on timing, defaults and relay. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +- cmd/codeaf/telemetry_settings_test.go | 67 +++++++++++++++++++ cmd/codeaf/usage.go | 5 ++ cmd/codeaf/usage_test.go | 12 ++++ docs/TELEMETRY.md | 10 ++- internal/config/settings.go | 25 +++++-- internal/config/telemetry_test.go | 27 ++++++++ .../manual/chat/running-from-the-terminal.md | 59 +++++++++------- internal/telemetry/doc_test.go | 2 +- internal/tui3/settings.go | 3 +- 10 files changed, 179 insertions(+), 35 deletions(-) create mode 100644 cmd/codeaf/telemetry_settings_test.go diff --git a/README.md b/README.md index 98cb653bd6..207d1a593f 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ Harness, method and every table: [docs/benchmarks/performance](docs/benchmarks/p ## Telemetry -codeaf sends anonymous usage counts to AgentField. +codeaf sends anonymous usage counts to AgentField, on by default. - **Sent:** version, OS, mode (chat or task), session counts, errors, and total tokens used — as counts and bands, under a random per-install id. @@ -305,6 +305,8 @@ codeaf sends anonymous usage counts to AgentField. - **Turn off:** the `telemetry` switch in the chat's `/settings`, `CODEAF_TELEMETRY=off`, or `DO_NOT_TRACK=1`. Any one of them stops every stream, the Model Pool's rows included. + The `/settings` switch stops sending immediately; turning it back on takes + effect the next time codeaf starts. This page and [docs/TELEMETRY.md](docs/TELEMETRY.md) are the whole disclosure: the product itself prints no notice and has no telemetry command. The document diff --git a/cmd/codeaf/telemetry_settings_test.go b/cmd/codeaf/telemetry_settings_test.go new file mode 100644 index 0000000000..0d15ceedbc --- /dev/null +++ b/cmd/codeaf/telemetry_settings_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/telemetry" +) + +func TestSettingsTelemetryOffKeepsQueuedCountsUnsentThroughExit(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "") + t.Setenv("DO_NOT_TRACK", "") + var requests atomic.Int64 + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(relay.Close) + t.Setenv("CODEAF_TELEMETRY_ENDPOINT", relay.URL) + telemetry.Configure(false) + t.Cleanup(func() { telemetry.Configure(false) }) + telemetry.VersionForTest(t, "v0.0.0-test") + telemetry.ResetCountersForTest(t) + telemetry.EnableForTest(t, true) + if err := telemetry.SpoolSync(telemetry.UsageDelta(telemetry.ModeChat, 10, 5, "open-session", time.Now())); err != nil { + t.Fatal(err) + } + session := telemetryStart(telemetrySession{mode: telemetry.ModeChat, sessionID: "open-session"}) + t.Cleanup(session.stopPeriodicFlush) + t.Cleanup(session.finishUsage) + before := len(telemetrySpoolRows(t)) + if before != 1 { + t.Fatalf("fixture spooled %d events, want one waiting to leave", before) + } + // The test override bypasses every opt-out. Use it only to seed the open + // session, then read the real ladder so the registry's live answer is tested. + telemetry.EnableForTest(t, false) + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: t.TempDir()}).Row(config.KeyTelemetry) + if !ok { + t.Fatal("telemetry switch is missing") + } + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + if telemetry.Enabled() || telemetry.OffReason() != telemetry.OffConfig { + t.Fatalf("settings off left the live gate at %q", telemetry.OffReason()) + } + telemetry.CountTokens(100, 25) + telemetry.Spool(telemetry.UsageDelta(telemetry.ModeChat, 100, 25, session.sessionID, time.Now())) + _ = telemetry.Flush(context.Background()) + telemetryEnd(session, 0) + if after := len(telemetrySpoolRows(t)); after != before || requests.Load() != 0 { + t.Fatalf("off sent or appended events through exit: rows=%d (was %d) requests=%d", after, before, requests.Load()) + } + for _, row := range telemetrySpoolRows(t) { + if row["event_name"] == "session_ended" { + t.Fatal("off built a session-ended event") + } + } +} diff --git a/cmd/codeaf/usage.go b/cmd/codeaf/usage.go index a23a9f51f8..2a6912cfd2 100644 --- a/cmd/codeaf/usage.go +++ b/cmd/codeaf/usage.go @@ -465,6 +465,11 @@ func commandHelp(name string) error { // scrolled off the top of the terminal and the obvious next step was never // named. Now it is the miss, the nearest thing to it, and where the rest is. func unknownCommand(typed string) error { + if typed == "telemetry" { + return fmt.Errorf("there is no `codeaf %s`.\n"+ + "turn counts off with CODEAF_TELEMETRY=off, DO_NOT_TRACK=1, or the telemetry switch in /settings.\n"+ + "docs/TELEMETRY.md lists what is sent.\nrun `codeaf --help` for every command", typed) + } if nearest := nearestCommand(typed); nearest != "" { return fmt.Errorf("there is no `codeaf %s`. did you mean `codeaf %s`?\n"+ "run `codeaf --help` for every command", typed, nearest) diff --git a/cmd/codeaf/usage_test.go b/cmd/codeaf/usage_test.go index f61a6f7919..af49269236 100644 --- a/cmd/codeaf/usage_test.go +++ b/cmd/codeaf/usage_test.go @@ -11,6 +11,18 @@ import ( "github.com/Agent-Field/codeaf/internal/exec" ) +func TestRemovedTelemetryCommandRefusesAndNamesItsReplacement(t *testing.T) { + err := unknownCommand("telemetry") + if exitCodeOf(err) != 1 { + t.Fatalf("removed telemetry command exits %d, want 1", exitCodeOf(err)) + } + for _, word := range []string{"there is no", "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "/settings", "docs/TELEMETRY.md"} { + if err == nil || !strings.Contains(err.Error(), word) { + t.Errorf("removed telemetry refusal must name %q: %v", word, err) + } + } +} + // exitCodeOf reads an error the way [execute] does, so a test can assert the // number the shell actually sees rather than the shape of the error value. func exitCodeOf(err error) int { diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index bc7bf70433..aa2f20ff97 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -4,6 +4,10 @@ codeaf counts how it is used — how often, in which modes, on which platforms so the parts people rely on get the work. The counts are anonymous: nothing about you or your work ever leaves this machine. +The counts are **ON by default**. They go to AgentField's relay at +`https://agentfield.ai/api/oss/codeaf/telemetry` (`telemetry.DefaultEndpoint`). +`CODEAF_TELEMETRY_ENDPOINT` moves the relay; an empty value turns sending off. + ## The disclosure This page, and the *Telemetry* section of the repository's README, are the whole @@ -101,8 +105,10 @@ Model Pool from sending. They are checked in this order: 3. `telemetry = off` in the project's settings file, `.codeaf/config.json`. A project may only turn the counts off, never on. 4. the `telemetry` switch on the *display* tab of the chat's `/settings`, which - writes `telemetry = off` to the profile's `config.json`; the same switch is - the way back. + writes `telemetry = off` to the profile's `config.json` and stops sending + immediately, including later periodic and exit flushes. Turning it back on + takes effect the next time codeaf starts. A request already on the wire may + complete; counts already queued locally stay unsent while it is off. 5. an empty `CODEAF_TELEMETRY_ENDPOINT`. A build that cannot name its own source — dirty or unstamped — never reports, diff --git a/internal/config/settings.go b/internal/config/settings.go index 8b0150b8bd..ed18c45df9 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -20,6 +20,7 @@ import ( "github.com/Agent-Field/codeaf/internal/standing" "github.com/Agent-Field/codeaf/internal/store" "github.com/Agent-Field/codeaf/internal/taxonomy" + "github.com/Agent-Field/codeaf/internal/telemetry" ) // The settings registry is the one place a user-tunable knob is written down. @@ -2595,11 +2596,25 @@ func (s *Settings) build() []Setting { Key: KeyTelemetry, Category: CategoryInterface, Kind: SettingBool, Label: "telemetry", Env: "CODEAF_TELEMETRY", Hint: "sends the anonymous usage counts described in docs/TELEMETRY.md — session " + - "starts and ends, tool and model call counts, coarse cost — after a notice " + - "has been printed once. Off sends nothing. The session's own counters still " + - "count, because counting is free; a change lands the next time codeaf starts.", - read: func() string { return formatBool(TelemetryAt(dir)) }, - write: func(raw string) error { return writeBool(dir, KeyTelemetry, raw) }, + "starts and ends, tool and model call counts, coarse cost. Off stops sending " + + "immediately. Turning it on takes effect the next time codeaf starts. " + + "The session's own counters still count.", + read: func() string { return formatBool(TelemetryAt(dir)) }, + write: func(raw string) error { + on, err := parseBool(raw) + if err != nil { + return err + } + if err := WriteTelemetry(dir, on); err != nil { + return err + } + // OFF REACHES THE RUNNING PIPE AT ONCE. Re-enabling waits for + // startup to resolve every opt-out before anything can be sent. + if !on { + telemetry.Configure(true) + } + return nil + }, }, Setting{ Key: KeyDraftPersist, Category: CategoryInterface, Kind: SettingBool, diff --git a/internal/config/telemetry_test.go b/internal/config/telemetry_test.go index adc1406018..19856db7dd 100644 --- a/internal/config/telemetry_test.go +++ b/internal/config/telemetry_test.go @@ -4,8 +4,35 @@ import ( "os" "path/filepath" "testing" + + "github.com/Agent-Field/codeaf/internal/telemetry" ) +func TestTelemetryRegistryOffStopsTheRunningProcessAndOnWaitsForRestart(t *testing.T) { + t.Setenv("CODEAF_TELEMETRY", "") + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("CODEAF_TELEMETRY_ENDPOINT", "http://127.0.0.1:1/telemetry") + telemetry.EnableForTest(t, false) + telemetry.Configure(false) + t.Cleanup(func() { telemetry.Configure(false) }) + row, ok := registry(t, t.TempDir()).Row(KeyTelemetry) + if !ok { + t.Fatal("telemetry switch is missing") + } + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + if telemetry.OffReason() != telemetry.OffConfig || telemetry.Enabled() { + t.Fatalf("registry off left the running gate at %q, want %q", telemetry.OffReason(), telemetry.OffConfig) + } + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + if row.Value() != "on" || telemetry.OffReason() != telemetry.OffConfig { + t.Fatalf("on must persist for next start without reopening this process: row=%q gate=%q", row.Value(), telemetry.OffReason()) + } +} + // The telemetry row is registered the way the call-log and history rows are: // a key, a default, an environment pin, a place in the sheet, and a reader // the binary can call. These tests hold that shape still, because the diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index fc5c9d1f8f..95f8fb3954 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1044,31 +1044,40 @@ first — `cache clean` wants the word `now` typed out, the same word `/cache cl wants in the chat, and `rebuild` wants `y` — and `--yes` skips the question on both. The other three act at once, and all three can be undone: a retracted belief restores, a stopped service starts again, a revoked device pairs again. -## Does codeaf collect data about me — telemetry, the anonymous usage counts, and how to turn them off - -codeaf sends anonymous usage counts to AgentField: the version, OS, mode (chat or task), -session counts and errors in bands, and the tokens each provider call used. Never anything -about you or your work — no prompts, code, file names, paths, repo names, keys, email, IP or -machine name, and no model names. Each completed provider call queues a `usage_delta` with -exact input, output and total tokens, never which model or what it read; the queue is sent -every 30 seconds while the session stays open and once more when it ends, and `session_ended` -carries the outcome and bucketed session counts without repeating tokens already reported. -There is **no notice in the product and no `codeaf telemetry` command**: the whole account of -what leaves, field by field, is `docs/TELEMETRY.md` in the repository (linked from the -README's *Telemetry* section), and a test holds that page to the code. The counts are not -the only stream: with `model_pool` on, one scored row per crew seat leaves for the Model -Pool after a task lands (*Model Pool* above). - -**To turn it off**, any one of these does it, and every one of them also caps the Model -Pool at `read`, so it still fetches the index and sends nothing: the `telemetry` switch on -the *display* tab of `/settings`, which writes `telemetry = off` to your profile; -`CODEAF_TELEMETRY=off` in the shell; `DO_NOT_TRACK=1`, the ecosystem's own word for it; -`telemetry = off` in the project's `.codeaf/config.json`, which may only turn it off, never -on; or an empty `CODEAF_TELEMETRY_ENDPOINT`. A build that cannot name its own source — dirty -or unstamped — never reports, and neither does a test binary. There is nothing to show or -inspect from the terminal: the spool waits in `~/.codeaf/telemetry/spool.jsonl` and the -`codeaf telemetry status`, `info` and `show` verbs that used to print it were removed on -2026-10-01 along with the notice. +## Does codeaf collect data about me — telemetry, the anonymous usage counts + +codeaf sends anonymous usage counts to AgentField, **on by default**: version, OS, +mode (chat or task), session counts and errors in bands, and the tokens each provider +call used. Never prompts, code, file names, paths, repo names, keys, email, IP or +machine name, and no model names. Each completed provider call queues a `usage_delta` +with exact input, output and total tokens. The queue is sent every 30 seconds while +the session stays open and once more when it ends; `session_ended` carries the outcome +and bucketed counts without repeating those tokens. + +The relay is `https://agentfield.ai/api/oss/codeaf/telemetry`; +`CODEAF_TELEMETRY_ENDPOINT` moves it. The full disclosure, field by field, is +`docs/TELEMETRY.md` in the repository, linked from the README's *Telemetry* section. +The product prints no notice. With `model_pool` on, one scored row per crew seat also +leaves for the Model Pool after a task lands (*Model Pool* above). + +## How to turn telemetry off now — /settings stops sending immediately + +On the *display* tab of `/settings`, turn the `telemetry` switch off. It writes +`telemetry = off` to your profile and stops sending immediately: no new event is +spooled and later periodic and exit flushes send nothing. Turning it back on takes +effect the next time codeaf starts. A request already on the wire may complete; +counts already queued locally stay unsent while it is off. + +Other ways to turn the counts off: `CODEAF_TELEMETRY=off`, `DO_NOT_TRACK=1`, +`telemetry = off` in the project's `.codeaf/config.json` (a project may only turn it +off), or an empty `CODEAF_TELEMETRY_ENDPOINT`. Every switch also caps the Model Pool +at `read`: its index is still fetched and nothing is sent. Dirty or unstamped builds +and test binaries never report. + +`codeaf telemetry` is no longer a command. It exits with an error naming the off +switches and `docs/TELEMETRY.md`, which lists what is sent. The former `status`, `info` +and `show` verbs were removed. Unsent events can be read directly in +`~/.codeaf/telemetry/spool.jsonl`. ## Reading a plan by hand — codeaf plan new, show, revise and run diff --git a/internal/telemetry/doc_test.go b/internal/telemetry/doc_test.go index c871ee4f6d..1df0a2a354 100644 --- a/internal/telemetry/doc_test.go +++ b/internal/telemetry/doc_test.go @@ -163,7 +163,7 @@ func TestDocPropertyTableMatchesTheAllowlist(t *testing.T) { // every switch and everything that is never sent. func TestDocCarriesTheDisclosureAndTheSwitches(t *testing.T) { body := docBody(t) - for _, wanted := range []string{"CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "/settings", "codeaf sends anonymous usage counts"} { + for _, wanted := range []string{"CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "/settings", ".codeaf/config.json", "an empty `CODEAF_TELEMETRY_ENDPOINT`", DefaultEndpoint, "ON by default", "codeaf sends anonymous usage counts"} { if !strings.Contains(body, wanted) { t.Errorf("docs/TELEMETRY.md must mention %q", wanted) } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index e9fc11b616..e184d2ba38 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -594,7 +594,8 @@ var settingUI = map[string]settingMeta{ config.KeyTelemetry: { tab: tabDisplay, label: "telemetry", widget: widgetToggle, about: "sends anonymous usage counts (version, OS, mode, session and error " + - "counts); never prompts, code, paths or names. Off sends nothing. " + + "counts); never prompts, code, paths or names. Off stops sending immediately. " + + "Turning it on takes effect the next time codeaf starts. " + "docs/TELEMETRY.md in the repository lists every field.", }, config.KeyDraftPersist: { From 3a7e1fa2e93f3edce0c3289a7232046657c57670 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 11:40:31 -0400 Subject: [PATCH 22/26] manual: split setup topics and correct the onboarding change entry The controls explanation exceeded a standalone manual read, and the change entry still claimed removed review and example-key behavior. Split self-contained setup topics, add retrieval probes, and describe current credit, model-list and telemetry outcomes. Use the required first-run title and describe only origin/dev-to-PR invalidations, including the empty model list and telemetry switches. Split the empty-task-page answer too because the changed corpus ranking otherwise hid an existing probe; leave every existing probe unchanged. Keep the standalone section-size test; drop the transient change-entry test and duplicated exact-sentence assertion. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1720-onboarding-ux-cleanups.md | 17 +++-- internal/manual/chat/getting-started.md | 72 ++++++++++++++----- .../manual/chat/task-rooms-after-restart.md | 2 + internal/manual/chat_test.go | 6 ++ internal/manual/getting_started_test.go | 21 ++++++ 5 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 internal/manual/getting_started_test.go diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 0439d075b6..5bfc719e79 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -1,6 +1,6 @@ --- kind: changed -title: the first run takes clicks and a second enter, shows no telemetry notice, and lists free models only +title: first run answers clicks and a second enter, drops the telemetry notice, lists free models when low pr: 1720 surface: [chat, docs] invalidates: @@ -10,16 +10,19 @@ invalidates: - "On the setup's controls screen `Start a conversation` carried a dim `enter` at its right, and the two explanations did not name a command. The loose `enter` is gone (the keys line says `enter starts` there); the limit's line ends `/budget changes it later.` and the chat model's `/model changes it later.`, each command painted as the composer's chip." - "The setup's controls screen had a row `Other settings use defaults · review` (or `Review other settings`) between the chat model and `Start a conversation`, which enter opened into three read-only rows (memory, ask before running, task countdown) and the line `/settings changes these and every other one.`. The row is gone — the form is three rows: the limit, the chat model, the way out — and one dim line under `Start a conversation` reads `Everything else is in /settings`, with `/settings` painted as the composer's command chip. controlReview, setupFlow.reviewOpen, setupReviewRow, setupReviewRows, setupReviewKeys and setupOtherSettingsWritten are gone." - "The setup's model list drew each model's friendly name with the exact id on a second row under the cursor's model. It is a flat list of exact ids now, one row per model; the friendly name is still searched by typing and still shown on the Chat model field." - - "On the setup's controls screen a row's name was the body ink when focused and muted otherwise, and `←`/`→` stopped at the first and last example. A name is now the accent while focused, dim once enter has acted on the row (the limit set, a model taken, the review shown; setupFlow.answered), and the body ink until then, on every row including `Start a conversation`; and the examples go round in both directions." + - "On the setup's controls screen a row's name was the body ink when focused and muted otherwise, and `←`/`→` stopped at the first and last example. A name is now the accent while focused, dim once enter has acted on the row (the limit set, a model taken; setupFlow.answered), and the body ink until then, on every row including `Start a conversation`; and the examples go round in both directions." - "The setup's controls screen was headed `Models and spending` over `Keep these choices or change them.`, with the keys line at the foot of the form. Its heading is the one line `Basic settings`, and the keys line is the row directly under it. The example panel no longer carries `An illustration. Nothing here has run.` at its foot (its `Example` label says so), and the keys line no longer says `←→ examples` — the panel's own bottom edge carries the arrows." - - "The Models and spending setup screen swallowed every mouse press. A press on a row is now the key that row would take — the limit focuses, the chat model opens its list, a model in the list is taken, the review shows, `Start a conversation` leaves — and the wheel scrolls the open list." - - "Enter on the chat-model row left the focus there after a model was taken, so the next enter reopened the list; enter on an open review folded it away. Both now go on to the next row, so enter alone walks the whole form down to `Start a conversation`." + - "The Models and spending setup screen swallowed every mouse press. A press on a row is now the key that row would take — the limit focuses, the chat model opens its list, a model in the list is taken, `Start a conversation` leaves — and the wheel scrolls the open list." + - "Enter on the chat-model row left the focus there after a model was taken, so the next enter reopened the list; enter on an open review folded it away. Taking a model now goes on to the next row, so enter alone walks the three-row form down to `Start a conversation`." - "codeaf printed a six-line anonymous-usage-counts notice once per install — on the first conversation's screen under the starting points, or to stderr ahead of `chat --once` and task commands — and sent nothing until a frame or a terminal had shown it. It prints no notice anywhere now and the gate is gone; the disclosure is the README's Telemetry section and docs/TELEMETRY.md, which a test holds to the switches." - - "`codeaf telemetry` (`status`, `info`, `show`, `on`, `off`) existed and `--help` listed it. It does not exist; `codeaf telemetry` is an unknown command. The switches are unchanged: the `telemetry` toggle in /settings, CODEAF_TELEMETRY=off, DO_NOT_TRACK=1, `telemetry = off` in the project file, an empty CODEAF_TELEMETRY_ENDPOINT. tui3.Options no longer has TelemetryNotice or TelemetryNoticeShown, and internal/telemetry no longer has Notice, PrintNotice, NoticeShown or MarkNoticeShown." - - "A balance read that OpenRouter answered with `API key expired` was a failed read: nothing was recorded and nothing was said, and the turn then failed with a plain auth error. It is now a reading of its own (credits.Reading.Expired, stored beside low in credits.json, config.CreditsExpiredAt): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands under the message box in a conversation and on Home for every OpenRouter model, free included, the setup screen says the same on its last row, a turn refused as expired starts a fresh read, and the free defaults are not used for it." - - "The setup screen's example panel (`○ Example · what you can do`) was a second column to the right of the form, 36 cells wide, drawn only from 112 columns up and level with the first field; on a tall window the lower half of the screen stayed empty while the panel wrapped every sentence. It now stands ABOVE the form, under the header: the panel, two blank rows, the keys line, then the form directly under it, as wide as the window allows up to 92 cells, whole or not at all: a window with no rows to spare (24 rows) draws the keys line and the form alone, and the keys line names `←→ examples` only when the panel is drawn. The panel's top edge carries the example's title (`○ Example · Follow the work and its cost`) instead of `Example · what you can do`, and the title is no longer a row inside the body. There is a fifth example, `Hand off complex coding tasks`, whose request is `/senior-dev Add retries with backoff to the HTTP client, with tests.`; it stands beside `Start a conversation`, and a command in any example's request is painted with the composer's command chip. The form itself is 64 cells wide on every window (it was 54 beside the panel), and setupWideCols, setupFormWidth=54, setupShowGap and setupShowWidth are gone." + - "`codeaf telemetry` (`status`, `info`, `show`, `on`, `off`) existed and `--help` listed it. It does not exist: `codeaf telemetry` exits 1 with an error that names CODEAF_TELEMETRY=off, DO_NOT_TRACK=1 and the telemetry switch in /settings, and points at docs/TELEMETRY.md. The switches are unchanged: the `telemetry` toggle in /settings, CODEAF_TELEMETRY=off, DO_NOT_TRACK=1, `telemetry = off` in the project file, an empty CODEAF_TELEMETRY_ENDPOINT. tui3.Options no longer has TelemetryNotice or TelemetryNoticeShown, and internal/telemetry no longer has Notice, PrintNotice, NoticeShown or MarkNoticeShown." + - "A balance read that OpenRouter answered with `API key expired` was a failed read: nothing was recorded and nothing was said, and the turn then failed with a plain auth error. It is now a reading of its own (credits.Reading.Expired, stored beside low in credits.json, config.CreditsExpiredAt): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands under the message box in a conversation and on Home for every OpenRouter model, free included, the setup screen’s last row reads `Your OpenRouter key has expired · /connect takes a new one from openrouter.ai/settings/keys` when it is the only step, or `Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys` with a connection step behind it; a turn refused as expired starts a fresh read on the engine launch as well as `chat --no-host`, and an expired reading keeps an untouched conversation’s current model without a model note." + - "The setup screen's example panel (`○ Example · what you can do`) was a second column to the right of the form, 36 cells wide, drawn only from 112 columns up and level with the first field; on a tall window the lower half of the screen stayed empty while the panel wrapped every sentence. It now stands ABOVE the form, under the header: the panel, two blank rows, the keys line, then the form directly under it, as wide as the window allows up to 92 cells, whole or not at all: a window with no rows to spare (24 rows) draws the keys line and the form alone, and the keys line never names `←→ examples`; the panel’s own bottom edge carries the arrows. The panel's top edge carries the example's title (`○ Example · Follow the work and its cost`) instead of `Example · what you can do`, and the title is no longer a row inside the body. There is a fifth example, `Hand off complex coding tasks`, whose request is `/senior-dev Add retries with backoff to the HTTP client, with tests.`; it stands beside `Start a conversation`, and a command in any example's request is painted with the composer's command chip. The form itself is 64 cells wide on every window (it was 54 beside the panel), and setupWideCols, setupFormWidth=54, setupShowGap and setupShowWidth are gone." - "The setup screen's keys line said `enter goes on · tab moves · …` on the limit row. It says `enter sets the limit · ↑↓ moves · …`; `tab` still walks the rows but is no longer named, and the tmux suite's setupMovesWord needle is `↑↓ moves`." - "The setup screen's chat-model list was the whole catalog whatever the account's balance. While the OpenRouter balance reads low ($0.50 or less, the reading behind the low-credits warning), it offers only the `:free` ids and the catalog rows priced at zero, its count line says `free only`, and `Your OpenRouter account is low on credits · the list shows free models only` stands right-aligned on the screen's last row, where the keys row carries the warning outside the setup (the expired-key line stands there too). The model in use stays on the list." + - "Enter on the setup's open chat-model list with `nothing matches · backspace widens it` closed the list. It now keeps the list open, the row unanswered, and the focus and the model unchanged; backspace widens it and esc closes it." + - "Turning the /settings telemetry switch off wrote the profile but the running process kept sending usage and exit events; its registry hint still promised a notice and a restart for every change. Off now stops sending immediately, including later periodic and exit flushes, without building a session-ended event. Turning it back on takes effect at the next start. A request already on the wire may complete and counts queued locally stay unsent while off." + - "docs/TELEMETRY.md did not say the counts are on by default or name the relay they go to. It says both now, and its test holds the page to every one of the five off switches." --- Santosh watched five new people through the first run on 2026-09-30. Four read diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index e40ccb38fe..0e8c7a29d0 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -161,6 +161,9 @@ the turn already running always finishes (*Models and cost* has the rest). (Unti recorded spending, that running calls can carry it a little past, and that task crews have a cap of their own in `/crew`.) +## Where the daily limit is saved and why setup does not ask about every spending limit + +The setup screen’s **Daily limit** writes the same setting as `/budget`. `$500` is **the amount codeaf has always shipped** and this screen did not change it. What it writes: `daily_budget_usd` in your profile's `config.json`, through **the same @@ -174,7 +177,7 @@ here. They keep their shipped defaults — plan approval asks first above `$100` conversation ceiling is `no limit` — and `/budget` or `/settings` → Spending changes them when they start to matter. -## The model you talk to on the setup screen — and why it does not ask about the crew +## The first screen asks for a daily limit and a chat model — choosing the model you talk to **Chat model** is the model you talk to, shown by name — `DeepSeek V4 Flash` rather than `deepseek/deepseek-v4-flash`. Its line reads *The model you talk to in this @@ -189,11 +192,24 @@ rather than changes. Choosing one goes through the same settings row `/model` wr is kept for the next launch, and `enter` then goes on to the next row, as it does on the limit — so pressing `enter` alone walks the whole form down to `Start a conversation`. -**The rows take the mouse too.** A click on a row is the key that row would have taken: a +## Enter on the setup model list when nothing matches + +On **Basic settings**, the chat-model list accepts only a row it can show. +Enter with nothing matching leaves the list open with +`nothing matches · backspace widens it`. It changes no model, answers no row and +keeps the focus on **Chat model**. Backspace widens the list; `esc` closes it without +taking a model. + +## Clicking and scrolling on the setup screen + +The **Basic settings** controls take clicks and scrolling. A click on a row is the +key that row would have taken: a click on the limit focuses it to type into, a click on the chat model opens its list, a click on one model of the list takes it, and a click on `Start a conversation` leaves. The wheel scrolls the open list. A click on a sentence, a blank row or the example panel does nothing. +## Why the setup only lists free models when my account is low + **With no credit on the OpenRouter account, the list shows free models only.** When the account's balance has read low — $0.50 or less, the same reading that puts the low-credits warning under the message box — the list is cut to the `:free` ids and the catalog rows @@ -203,51 +219,67 @@ last row of the screen, right-aligned in the warning colour — where the low-cr stands under the message box once you are in a conversation. The model you are already on stays on the list whatever it costs, so accepting still confirms. The balance is read right after the key lands, so the cut usually arrives a moment after the screen does; -a top-up is read on the next launch (*OpenRouter credits and free models*). A key OpenRouter -refuses as **expired** is said the same way, on the last row, but the list is not cut, because -free models fail on an expired key too. The line names the door to a new key, and the door +a top-up is read on the next launch (*OpenRouter credits and free models*). + +## My OpenRouter key has expired on the setup screen + +On **Basic settings**, an expired OpenRouter key is shown on the last row. The list +is not cut to free models, because they fail on an expired key too. The line names the door to a new key, and the door depends on whether a connect step came before this screen: with one, it reads `Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys` and `esc` goes back there; when the key was already in the shell or the profile and only this screen was asked, `esc` skips the setup instead — the keys line says so — and the line reads `Your OpenRouter key has expired · /connect takes a new one from openrouter.ai/settings/keys`. +## Why setup does not ask about the task crew + **There is no crew question**, because the crew is three seats — the worker, the planner and the checker — and codeaf picks all three for each task from what kind of work it is, so there is nothing to choose before the first task. `/crew` shows the crew, and pins a seat when -you want one model there every time. The model that answers you is the one above, and -neither touches the other. - -**`esc` out of the list leaves the row exactly as it was.** A cursor inside a list is -provisional until you accept it. +you want one model there every time. **Chat model** chooses the model that answers +you; changing it does not change the task crew. If a **task model** is pinned (`task.model`), one dim line under the chat model says so — `Tasks are pinned to … · /settings changes that` — because that pin decides the worker seat, and a screen that did not mention it would be hiding where tasks run. -**Above the form**, under the `codeaf · setup · 2 of 2` header, a bordered panel stands on +## What the example panel above the setup form shows + +**Above the form**, under the setup header, a bordered panel stands on any window with the rows to hold the whole of it — the only bordered surface codeaf draws, so it cannot be read as more form. Its top edge is labelled `○ Example · ` followed by the example's own title (`Understand an unfamiliar project`, `Hand off something longer`, `Follow the work and its cost`, `Compare the options`, `Hand off complex coding tasks`); its bottom edge carries `← 3 / 5 →`, the arrows that browse it. It is as wide as the screen allows, up to 92 columns, so the request in it stands on one row. It holds one request you could -type and what it leads to. It opens on the first (`Understand an unfamiliar project`) and +type and what it leads to. + +## Why the setup examples change every three seconds and how to pause them + +The **Basic settings** example panel opens on the first (`Understand an unfamiliar project`) and **turns to the next by itself every 3 seconds**, round and round through the five (`Hand off something longer` with `/task Fix the failing tests and explain the changes.`, `Follow the work and its cost`, `Compare the options`, `Hand off complex coding tasks` with `/senior-dev Add retries with backoff to the HTTP client, with tests.`); a command in a request is painted as the same chip the message box paints a recognised command with. -`←`/`→` browse by hand and go round the same ring. **Any key holds the clock** for 3 +`←`/`→` browse by hand and go round the same ring. **Any key or click holds the clock** for 3 seconds from that key — typing an amount, walking the rows, browsing — so the panel never turns under your hands. Walking the rows does not move it (until 2026-10-01 it followed the -row you were on). Each example **types itself out once** on arriving, then settles; typing -settles it at once. Two blank rows separate the panel from the form's heading. On a window +row you were on). The screen-reader tier never turns by itself. The clock stops when setup closes or returns +to the connection step. Each example **types itself out once** on arriving, then settles; typing +settles it at once. + +## Why the setup example disappears on a short window + +The **Basic settings** form keeps its values and keys when the example panel cannot fit. +Two blank rows separate the panel from the form's heading. On a window too short to hold the form and the whole panel — 24 rows, say — the panel is not drawn and the form is unchanged. (Until 2026-10-01 the panel was a second column to the right of the form, drawn only from 112 columns up, and carried `An illustration. Nothing here has run.` at its foot; the label on its edge now says that once.) +## What the setup row colours and keys mean + **A row's name says where you are.** Each name — `Daily limit`, `Chat model`, `Start a conversation` — is in the body colour until you have answered it, blue while you are on it, and grey once `enter` has acted on it: the limit set, a model taken from the @@ -293,7 +325,7 @@ the header is the count of those: a key already in the shell leaves the controls alone on the frame, with no `1 of 1` counting to one at anybody. While it is up it is the whole screen: every keystroke belongs to it except `ctrl+c`, -which is still the door (twice, as always), and the mouse does nothing. The returning +which is still the door (twice, as always). The controls take clicks and scrolling. The returning provider step may open after you type, but the draft is held untouched underneath it. ## Change what I picked during setup — where each answer lives afterwards @@ -312,6 +344,8 @@ A credential changed in the settings row reaches the running conversation at onc exactly as the setup's does. A crew pin and the budget are read live too: the next task starts on the new pin, and the rail is checked against the new ceiling. +## Which settings the setup does not ask about + **The setup asks about two things and no more.** Memory stays on, tool approvals keep prompting, and a proposed task keeps its 15-second countdown — none of them becomes a question there, because none can be answered usefully before you have seen codeaf do @@ -362,6 +396,8 @@ Font, in Terminal.app under Profiles → Text → Font → Change…, and in kit ghostty with `font_family`, `[font.normal] family` and `font-family` in their config files. +## My Option key types letters on macOS instead of moving to a place + **Option as meta, on macOS.** Every chord codeaf binds is the option key, and on a Mac it is drawn the way the keycap names it, `opt+enter` to send what you typed off as a task, `opt+1`…`opt+8` to jump to a place, `opt+.` for the map. (On Linux and Windows the same chords are drawn @@ -377,7 +413,9 @@ composer still names what `enter` does — and the first place you land on says line: `your terminal sends opt as a letter — turn on "use option as meta" in …`, naming the terminal you are actually in. -**The first-run setup says it too.** When the questions are done, a Mac gets one more +## Why setup mentions Option on a Mac and which terminals also accept Control + +**The first-run setup explains Option on a Mac.** When the questions are done, a Mac gets one more line: `the places answer opt+1…opt+8 · if opt types a character instead, turn on "use option as meta" in …`. It is a condition rather than a report — nothing has been pressed yet — and it is said once. diff --git a/internal/manual/chat/task-rooms-after-restart.md b/internal/manual/chat/task-rooms-after-restart.md index f1e8fca233..5f1584a835 100644 --- a/internal/manual/chat/task-rooms-after-restart.md +++ b/internal/manual/chat/task-rooms-after-restart.md @@ -54,6 +54,8 @@ nothing on this page yet — it fills in as the task works Neither of the landed lines would be true there: nothing was lost, and nothing is over. This one comes off by itself the moment the task's first block arrives. +## What an empty task page shows before any tool has run or after the transcript is lost + **A room never draws a blank body under its header.** Above that line the page draws what it already knows, which is different for work that is still going and work that is over. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 578e1b5168..a6e7d00cd4 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -3063,6 +3063,12 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"what does the task proposal card look like", "tasks"}, {"why did team_start not ask me first", "team-manager"}, {"why did setup show only one screen", "getting-started"}, + {"enter on the setup model list when nothing matches", "getting-started"}, + {"clicking and scrolling on the setup screen", "getting-started"}, + {"why the setup only lists free models", "getting-started"}, + {"why the setup examples change every three seconds", "getting-started"}, + {"asking what a quoted /task command does", "commands"}, + {"how to turn telemetry off now", "running-from-the-terminal"}, {"where are my cleared drafts", "commands"}, {"what does /workspace path do", "commands"}, } diff --git a/internal/manual/getting_started_test.go b/internal/manual/getting_started_test.go new file mode 100644 index 0000000000..f3a74b8f30 --- /dev/null +++ b/internal/manual/getting_started_test.go @@ -0,0 +1,21 @@ +package manual + +import ( + "os" + "strings" + "testing" + "unicode/utf8" +) + +func TestGettingStartedTopicsFitStandaloneReads(t *testing.T) { + body, err := os.ReadFile("chat/getting-started.md") + if err != nil { + t.Fatal(err) + } + for _, section := range strings.Split(string(body), "\n## ")[1:] { + heading, _, _ := strings.Cut(section, "\n") + if size := utf8.RuneCountInString(section); size >= 2000 { + t.Errorf("setup topic %q has %d characters, want under 2000 for a standalone read", heading, size) + } + } +} From 3138850d0573c367a8aa70bf7c24908394a1687e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 12:14:15 -0400 Subject: [PATCH 23/26] credits: an engine-road key refusal asks for the balance read The engine sends the session's unauthorized ending without its typed provider refusal, so the ordinary launch missed the fresh balance read. Export one unauthorized sentence for both session spellings and match its trimmed prefix after the engine wire, under the existing refusal debounce. Keep typed expiry recognition and the direct-service exclusion; prove the real 401 ending, arbitrary key-source tails, excluded statuses and unchanged conversation defaults. Document that the read decides expiry and an invalid key leaves the previous reading alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/openrouter-credits.md | 8 +++- internal/session/auth_ending_test.go | 41 +++++++++++++++++++++ internal/session/taxonomy_boundary.go | 9 ++++- internal/tui3/credits.go | 7 +++- internal/tui3/credits_test.go | 43 ++++++++++++++++++++++ 5 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 internal/session/auth_ending_test.go diff --git a/internal/manual/chat/openrouter-credits.md b/internal/manual/chat/openrouter-credits.md index 43e759c652..52a2363812 100644 --- a/internal/manual/chat/openrouter-credits.md +++ b/internal/manual/chat/openrouter-credits.md @@ -63,6 +63,11 @@ error from OpenRouter — changes nothing and never warns. A key with no spendin cap whose account balance OpenRouter will not report is simply unknown: no free defaults, no warning. +On the ordinary engine launch, a turn ending `your key was not accepted for this model` +for the default service asks for a fresh read, whatever key source follows the sentence. +The read decides whether the key expired; a merely invalid key is a failed read and +changes nothing. + A conversation that has sent nothing and whose model nobody chose follows the default both ways: onto the free model when the account reads low, and back to the usual default when a later read finds more than $0.50. A conversation that has @@ -83,7 +88,8 @@ OpenRouter service serves — free or paid — and never on a model served by Co model or another connected service. It wins over the low-credits line, and the free defaults are **not** used, because they would fail on the same key. A turn refused with `API key expired` mid-session starts a fresh read on both the ordinary engine launch -and `codeaf chat --no-host`, so the warning arrives without a relaunch. An expired +and `codeaf chat --no-host`, unless one finished in the last 30 seconds, so the warning +arrives without a relaunch. An expired reading leaves an untouched conversation on its current model: it moves neither from the free default to the paid default nor the other way, and adds no model note. On the first-run setup screen the same fact stands on the last row, and the list is not cut to free models. When a connect step came before the screen it reads diff --git a/internal/session/auth_ending_test.go b/internal/session/auth_ending_test.go new file mode 100644 index 0000000000..870a73673f --- /dev/null +++ b/internal/session/auth_ending_test.go @@ -0,0 +1,41 @@ +package session + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/taxonomy" +) + +// The engine sends the ending's words without its wrapped refusal, so the +// surface's shared prefix must be the one the real turn actually ends on. +func TestExpiredKeyTurnEndsOnTheSharedUnauthorizedSentence(t *testing.T) { + profile := t.TempDir() + t.Setenv("OPENROUTER_API_KEY", "test-router-key") + refusal := &provider.APIError{Status: 401, Message: "API key expired."} + completer := &scriptedCompleter{steps: repeatedStep(2, func(context.Context, []ai.Message) (*ai.Response, error) { + return nil, refusal + })} + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.Model, c.ProfileDir = config.DefaultModel, profile + c.Sources = config.ResolveSources(profile, config.APIKeyAt(profile), config.DefaultBaseURL) + }) + failure := turnFailure(t, agent, "hello") + if failure.Err == nil || !strings.HasPrefix(failure.Err.Error(), UnauthorizedKeySentence) { + t.Fatalf("expired-key turn ending = %v; want prefix %q", failure.Err, UnauthorizedKeySentence) + } + if !strings.HasSuffix(failure.Err.Error(), " — the shell's OPENROUTER_API_KEY") { + t.Fatalf("expired-key turn lost its key source: %v", failure.Err) + } + if !provider.KeyExpiredFrom(failure.Err) { + t.Fatal("the in-process ending lost the typed expired-key refusal") + } + verdict := taxonomy.Verdict{Reason: taxonomy.ReasonUnauthorized} + if got := transportKeptWords(verdict); got != UnauthorizedKeySentence { + t.Fatalf("repeated unauthorized refusal = %q; want %q", got, UnauthorizedKeySentence) + } +} diff --git a/internal/session/taxonomy_boundary.go b/internal/session/taxonomy_boundary.go index f15fcb4fbb..91d33372e1 100644 --- a/internal/session/taxonomy_boundary.go +++ b/internal/session/taxonomy_boundary.go @@ -314,6 +314,11 @@ func (a *Agent) readWireEvidence(evidence taxonomy.Evidence, model, role string) return verdict } +// UnauthorizedKeySentence is the ending shared with surfaces that receive only +// words over the engine wire. Keeping it here lets those surfaces ask for a +// fresh account read without inventing a second spelling of the refusal. +const UnauthorizedKeySentence = "your key was not accepted for this model" + // transportWords is the person's spelling of a wire failure — the same shape the // journal names, said the way somebody watching a reply would say it. // @@ -347,7 +352,7 @@ func transportWords(verdict taxonomy.Verdict) string { case taxonomy.ReasonWithdrawn: return "that model is not being served any more" case taxonomy.ReasonUnauthorized: - return "your key was not accepted for this model" + return UnauthorizedKeySentence case taxonomy.ReasonOverflow: return "this conversation got too long for the model" case taxonomy.ReasonTooBig: @@ -391,7 +396,7 @@ func transportKeptWords(verdict taxonomy.Verdict) string { case taxonomy.ReasonWithdrawn: return "that model is not being served any more" case taxonomy.ReasonUnauthorized: - return "your key was not accepted for this model" + return UnauthorizedKeySentence case taxonomy.ReasonOverflow, taxonomy.ReasonTooBig: return "this conversation kept being too long for the model" case taxonomy.ReasonOurBytes: diff --git a/internal/tui3/credits.go b/internal/tui3/credits.go index 1509bd369b..c8388b0e53 100644 --- a/internal/tui3/credits.go +++ b/internal/tui3/credits.go @@ -13,6 +13,7 @@ import ( "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/roles" + "github.com/Agent-Field/codeaf/internal/session" ) const lowCreditsWarning = "Your OpenRouter account is low on credits — some models may not be available" @@ -215,8 +216,12 @@ func (a *app) creditRefusalEnded(err error) bool { if refusal, ok := provider.RefusalFrom(err); ok && refusal.AccountCannotPay() { return true } + // THE READ DECIDES WHETHER THE KEY EXPIRED. The engine sends the session's + // unauthorized sentence without its typed refusal, so that shared sentence + // asks for a read too; a merely invalid key leaves the last reading alone. + message := strings.TrimSpace(err.Error()) prefix := config.ConnectionOutcomeWord(modelsource.DefaultSource("").Name, modelsource.Outcome{Kind: modelsource.OutcomeAccountCannotPay}) - return strings.HasPrefix(err.Error(), prefix) + return strings.HasPrefix(message, session.UnauthorizedKeySentence) || strings.HasPrefix(message, prefix) } func (a *app) paidCreditModel(id string, models []Model) bool { diff --git a/internal/tui3/credits_test.go b/internal/tui3/credits_test.go index aba0b9b0ed..7b5442500c 100644 --- a/internal/tui3/credits_test.go +++ b/internal/tui3/credits_test.go @@ -18,6 +18,49 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) +// A terminal session sentence is what crosses the engine wire, so a raw +// provider error alone cannot prove the ordinary launch asks for a read. +func TestEngineUnauthorizedEndingRequestsCreditReadOnlyOnDefaultService(t *testing.T) { + a := placeApp(t) + a.model = config.DefaultModel + a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{}, nil } + for _, tail := range []string{"", " — the shell's OPENROUTER_API_KEY", " — the profile's saved key"} { + t.Run("default"+tail, func(t *testing.T) { + err := errors.New(session.UnauthorizedKeySentence + tail) + crossed := remote.WireEvent(session.Event{Kind: session.EventError, Err: err}).Unwire().Err + if !a.creditRefusalEnded(crossed) { + t.Fatalf("engine ending %q did not request a credit read", crossed) + } + if !a.creditRefusalEnded(errors.New(" \n" + crossed.Error() + "\t")) { + t.Fatal("space around an engine ending hid its shared prefix") + } + }) + } + for _, tc := range []struct { + status int + message string + }{ + {401, "invalid key"}, {403, "API key expired."}, + {429, "expired quota"}, {500, "expired upstream"}, + } { + t.Run(fmt.Sprintf("%d/%s", tc.status, tc.message), func(t *testing.T) { + err := &provider.APIError{Status: tc.status, Message: tc.message} + crossed := remote.WireEvent(session.Event{Kind: session.EventError, Err: err}).Unwire().Err + if a.creditRefusalEnded(crossed) { + t.Fatalf("raw refusal %q unexpectedly requested a credit read", crossed) + } + }) + } + source := modelsource.Source{ID: "custom:company", Written: "custom:company", Name: "company", Address: "http://local.invalid/v1"} + a.sources = modelsource.NewSet(modelsource.Connected{Source: source, Address: source.Address}) + a.model = "custom:company/model" + err := errors.New(session.UnauthorizedKeySentence + " — the shell's OPENROUTER_API_KEY") + crossed := remote.WireEvent(session.Event{Kind: session.EventError, Err: err}).Unwire().Err + if a.creditRefusalEnded(crossed) { + t.Fatal("a direct model's unauthorized ending requested OpenRouter credits") + } +} + func TestEngineRefusalReadsCreditsOnlyForExpiredKeysOrUnpayableAccounts(t *testing.T) { a := placeApp(t) a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{}, nil } From f070cb52007c24043691ab73fe1ff6b019eeb964 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 12:14:16 -0400 Subject: [PATCH 24/26] pool: read live off switches before sending judged rows A landing retained its sendable pool configuration through judging, so turning the settings switch off still allowed a later POST. Resolve the live pool ladder before adding outgoing scores and at each HTTP request, including later batches and retries; keep local scores and leave older unsent rows pending. Preserve the pool's existing behavior on source and test builds through its own configuration ladder. Prove a switch changed inside the judge sends no new request, a switch changed during the first batch stops the next one, and ordinary pushes and quiet modes still work. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/pool_send_gate_test.go | 146 ++++++++++++++++++++++++++++++ cmd/codeaf/poolinstall.go | 21 ++++- cmd/codeaf/poolrecord.go | 4 +- 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 cmd/codeaf/pool_send_gate_test.go diff --git a/cmd/codeaf/pool_send_gate_test.go b/cmd/codeaf/pool_send_gate_test.go new file mode 100644 index 0000000000..289603cb17 --- /dev/null +++ b/cmd/codeaf/pool_send_gate_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/pool/judge" + "github.com/Agent-Field/codeaf/internal/pool/record" + "github.com/Agent-Field/codeaf/internal/telemetry" +) + +// Turning the switch off while the judge holds a sendable snapshot must keep +// both its new scores and the outbox's older rows from reaching the relay. +func TestSettingsOffStopsPoolRowsAlreadyBeingJudged(t *testing.T) { + t.Setenv("CODEAF_TELEMETRY", "") + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("CODEAF_MODEL_POOL", "on") + t.Setenv("CODEAF_HOME", t.TempDir()) + telemetry.Configure(false) + t.Cleanup(func() { telemetry.Configure(false) }) + var requests atomic.Int64 + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(relay.Close) + t.Setenv("CODEAF_MODEL_POOL_SUBMIT_URL", relay.URL) + t.Setenv("CODEAF_TELEMETRY_ENDPOINT", relay.URL) + dir := seedOutbox(t) + before := config.ModelPoolAt(dir) + if !before.CanSend() { + t.Fatal("fixture cannot send before the setting changes") + } + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTelemetry) + if !ok { + t.Fatal("telemetry row is absent") + } + var asked int + ask := func(string) judge.Ask { + return func(context.Context, string, string) (string, error) { + // The hook has resolved the pool before it asks its first judge, + // and no pool request has started when the switch changes here. + asked++ + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + return `{"score": 88, "reason": "the delivered work answers the brief"}`, nil + } + } + poolJudgeLandingContext(context.Background(), config.Config{}, dir, poolTestCatalog, ask, + func() time.Time { return time.Unix(100, 0) }, "task", poolTestLanding()) + if asked != 2 { + t.Fatalf("the local judge answered %d seats, want both", asked) + } + if telemetry.OffReason() != telemetry.OffConfig || config.ModelPoolAt(dir).CanSend() { + t.Fatal("off did not close the usage gate and a freshly resolved pool") + } + if got := requests.Load(); got != 0 { + t.Fatalf("settings off still allowed %d new pool POSTs from a retained configuration", got) + } + sheet, err := record.LoadSheet(record.OwnSheetPath(configuredPoolDir(dir))) + if err != nil { + t.Fatal(err) + } + if got := len(record.Cells(sheet)); got != 2 { + t.Fatalf("the local sheet holds %d cells, want both scored seats", got) + } + box, err := outboxOpenForTest(dir) + if err != nil { + t.Fatal(err) + } + defer box.Close() + if got := len(box.Pending()); got != 2 { + t.Fatalf("the quieted outbox holds %d pending rows, want the two older rows", got) + } +} + +// A push can need more than one POST. The first request may finish after the +// switch goes off, but the rows still waiting must not start another request. +func TestPoolPushStopsLaterBatchesWhenSettingsTurnOff(t *testing.T) { + t.Setenv("CODEAF_TELEMETRY", "") + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("CODEAF_MODEL_POOL", "on") + t.Setenv("CODEAF_HOME", t.TempDir()) + telemetry.Configure(false) + t.Cleanup(func() { telemetry.Configure(false) }) + dir := seedOutbox(t) + box, err := outboxOpenForTest(dir) + if err != nil { + t.Fatal(err) + } + // One row past a full batch makes the second POST observable without a + // timer or a race between the setting write and the next request. + for i := len(box.Pending()); i < 201; i++ { + if err := box.Append([]byte(`{"model":"crew/worker"}`)); err != nil { + t.Fatal(err) + } + } + if err := box.Close(); err != nil { + t.Fatal(err) + } + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTelemetry) + if !ok { + t.Fatal("telemetry row is absent") + } + var requests atomic.Int64 + applied := make(chan error, 1) + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) == 1 { + applied <- row.Apply("off") + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(relay.Close) + t.Setenv("CODEAF_MODEL_POOL_SUBMIT_URL", relay.URL) + t.Setenv("CODEAF_TELEMETRY_ENDPOINT", relay.URL) + before := config.ModelPoolAt(dir) + if !before.CanSend() { + t.Fatal("fixture cannot send before the setting changes") + } + poolPush(context.Background(), dir, before, poolPushBudget) + select { + case err := <-applied: + if err != nil { + t.Fatal(err) + } + default: + t.Fatal("the first batch did not reach the relay") + } + if got := requests.Load(); got != 1 { + t.Fatalf("settings off allowed %d pool POSTs, want only the request already on the wire", got) + } + box, err = outboxOpenForTest(dir) + if err != nil { + t.Fatal(err) + } + defer box.Close() + if got := len(box.Pending()); got != 1 { + t.Fatalf("the quieted outbox holds %d pending rows, want the unsent second batch", got) + } +} diff --git a/cmd/codeaf/poolinstall.go b/cmd/codeaf/poolinstall.go index 6c1a1db32c..b38e945ef7 100644 --- a/cmd/codeaf/poolinstall.go +++ b/cmd/codeaf/poolinstall.go @@ -13,7 +13,9 @@ import ( "context" cryptorand "crypto/rand" "encoding/hex" + "errors" "log" + "net/http" "os" "path/filepath" "regexp" @@ -82,7 +84,7 @@ func installNonce(poolDir string) (string, error) { // switch and never at a person. The rows a batch did not reach stay pending, // where the next judged run and the next start-up try them again. func poolPush(ctx context.Context, profileDir string, cfg poolcfg.Config, budget time.Duration) { - if !cfg.CanSend() { + if !cfg.CanSend() || !config.ModelPoolAt(profileDir).CanSend() { return } poolDir := config.ProfilePath(profileDir, "pool") @@ -103,7 +105,24 @@ func poolPush(ctx context.Context, profileDir string, cfg poolcfg.Config, budget } box.Install = nonce box.Budget = budget + box.Client = &http.Client{Transport: poolSendTransport{profileDir: profileDir}} if _, err := box.Send(ctx, cfg.SubmitURL); err != nil && trace.Enabled() { log.Printf("model pool: send: %v", err) } } + +// poolSendTransport asks the live switches immediately before EVERY REQUEST, +// because one outbox send can hold later batches and retries of refused rows. +// A request refused here leaves its rows pending, as an unanswered relay does. +// The pool's own ladder decides this; the usage gate also closes on source +// builds, which have always been able to send pool scores. +type poolSendTransport struct { + profileDir string +} + +func (transport poolSendTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if !config.ModelPoolAt(transport.profileDir).CanSend() { + return nil, errors.New("model pool sending is off") + } + return http.DefaultTransport.RoundTrip(request) +} diff --git a/cmd/codeaf/poolrecord.go b/cmd/codeaf/poolrecord.go index 3a6920d688..7117e95518 100644 --- a/cmd/codeaf/poolrecord.go +++ b/cmd/codeaf/poolrecord.go @@ -260,7 +260,9 @@ func poolJudgeLandingContext(ctx context.Context, settings config.Config, profil return } recorder := &record.Recorder{Sheet: sheet} - if pool.CanSend() { + // The judge may have outlived a settings change. A quieted install still + // keeps its own scores, but those scores never enter the outgoing rows. + if config.ModelPoolAt(profileDir).CanSend() { ob, err := outbox.Open(outboxPath(poolDir)) if err != nil { if trace.Enabled() { From 8c9c9594ef53b99eef9b09b0832664128085261e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 12:14:16 -0400 Subject: [PATCH 25/26] manual: name the empty-task foot and correct change-entry claims Name the finished-task foot in the standalone empty-page answer and shorten it to 1,945 characters without losing facts; keep every existing retrieval probe. Remove the inaccurate claim that the previous telemetry page omitted the relay URL. Update the existing engine-read and settings-off claims to describe the shared unauthorized prefix, the 30-second debounce and pool rows whose judging was already running. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1720-onboarding-ux-cleanups.md | 6 +++--- .../manual/chat/task-rooms-after-restart.md | 20 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md index 5bfc719e79..f59ebbd895 100644 --- a/docs/changes/unreleased/1720-onboarding-ux-cleanups.md +++ b/docs/changes/unreleased/1720-onboarding-ux-cleanups.md @@ -16,13 +16,13 @@ invalidates: - "Enter on the chat-model row left the focus there after a model was taken, so the next enter reopened the list; enter on an open review folded it away. Taking a model now goes on to the next row, so enter alone walks the three-row form down to `Start a conversation`." - "codeaf printed a six-line anonymous-usage-counts notice once per install — on the first conversation's screen under the starting points, or to stderr ahead of `chat --once` and task commands — and sent nothing until a frame or a terminal had shown it. It prints no notice anywhere now and the gate is gone; the disclosure is the README's Telemetry section and docs/TELEMETRY.md, which a test holds to the switches." - "`codeaf telemetry` (`status`, `info`, `show`, `on`, `off`) existed and `--help` listed it. It does not exist: `codeaf telemetry` exits 1 with an error that names CODEAF_TELEMETRY=off, DO_NOT_TRACK=1 and the telemetry switch in /settings, and points at docs/TELEMETRY.md. The switches are unchanged: the `telemetry` toggle in /settings, CODEAF_TELEMETRY=off, DO_NOT_TRACK=1, `telemetry = off` in the project file, an empty CODEAF_TELEMETRY_ENDPOINT. tui3.Options no longer has TelemetryNotice or TelemetryNoticeShown, and internal/telemetry no longer has Notice, PrintNotice, NoticeShown or MarkNoticeShown." - - "A balance read that OpenRouter answered with `API key expired` was a failed read: nothing was recorded and nothing was said, and the turn then failed with a plain auth error. It is now a reading of its own (credits.Reading.Expired, stored beside low in credits.json, config.CreditsExpiredAt): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands under the message box in a conversation and on Home for every OpenRouter model, free included, the setup screen’s last row reads `Your OpenRouter key has expired · /connect takes a new one from openrouter.ai/settings/keys` when it is the only step, or `Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys` with a connection step behind it; a turn refused as expired starts a fresh read on the engine launch as well as `chat --no-host`, and an expired reading keeps an untouched conversation’s current model without a model note." + - "A balance read that OpenRouter answered with `API key expired` was a failed read: nothing was recorded and nothing was said, and the turn then failed with a plain auth error. It is now a reading of its own (credits.Reading.Expired, stored beside low in credits.json, config.CreditsExpiredAt): `Your OpenRouter key has expired — make a new one at openrouter.ai/settings/keys` stands under the message box in a conversation and on Home for every OpenRouter model, free included, the setup screen’s last row reads `Your OpenRouter key has expired · /connect takes a new one from openrouter.ai/settings/keys` when it is the only step, or `Your OpenRouter key has expired · esc to paste a new one from openrouter.ai/settings/keys` with a connection step behind it; a turn refused as expired starts a fresh read on the engine launch as well as `chat --no-host`, under the same 30-second debounce. The engine launch recognizes the session's shared `your key was not accepted for this model` prefix for the default service, whatever key-source tail follows; the read decides whether the key expired, and a merely invalid key changes nothing. An expired reading keeps an untouched conversation’s current model without a model note." - "The setup screen's example panel (`○ Example · what you can do`) was a second column to the right of the form, 36 cells wide, drawn only from 112 columns up and level with the first field; on a tall window the lower half of the screen stayed empty while the panel wrapped every sentence. It now stands ABOVE the form, under the header: the panel, two blank rows, the keys line, then the form directly under it, as wide as the window allows up to 92 cells, whole or not at all: a window with no rows to spare (24 rows) draws the keys line and the form alone, and the keys line never names `←→ examples`; the panel’s own bottom edge carries the arrows. The panel's top edge carries the example's title (`○ Example · Follow the work and its cost`) instead of `Example · what you can do`, and the title is no longer a row inside the body. There is a fifth example, `Hand off complex coding tasks`, whose request is `/senior-dev Add retries with backoff to the HTTP client, with tests.`; it stands beside `Start a conversation`, and a command in any example's request is painted with the composer's command chip. The form itself is 64 cells wide on every window (it was 54 beside the panel), and setupWideCols, setupFormWidth=54, setupShowGap and setupShowWidth are gone." - "The setup screen's keys line said `enter goes on · tab moves · …` on the limit row. It says `enter sets the limit · ↑↓ moves · …`; `tab` still walks the rows but is no longer named, and the tmux suite's setupMovesWord needle is `↑↓ moves`." - "The setup screen's chat-model list was the whole catalog whatever the account's balance. While the OpenRouter balance reads low ($0.50 or less, the reading behind the low-credits warning), it offers only the `:free` ids and the catalog rows priced at zero, its count line says `free only`, and `Your OpenRouter account is low on credits · the list shows free models only` stands right-aligned on the screen's last row, where the keys row carries the warning outside the setup (the expired-key line stands there too). The model in use stays on the list." - "Enter on the setup's open chat-model list with `nothing matches · backspace widens it` closed the list. It now keeps the list open, the row unanswered, and the focus and the model unchanged; backspace widens it and esc closes it." - - "Turning the /settings telemetry switch off wrote the profile but the running process kept sending usage and exit events; its registry hint still promised a notice and a restart for every change. Off now stops sending immediately, including later periodic and exit flushes, without building a session-ended event. Turning it back on takes effect at the next start. A request already on the wire may complete and counts queued locally stay unsent while off." - - "docs/TELEMETRY.md did not say the counts are on by default or name the relay they go to. It says both now, and its test holds the page to every one of the five off switches." + - "Turning the /settings telemetry switch off wrote the profile but the running process kept sending usage and exit events; its registry hint still promised a notice and a restart for every change. Off now stops sending immediately, including later periodic and exit flushes, without building a session-ended event. A pool row already being judged is not sent either: the pool reads the live off switches before recording outgoing rows and immediately before a POST. Turning it back on takes effect at the next start. A request already on the wire may complete and counts queued locally stay unsent while off." + - "docs/TELEMETRY.md did not say the counts are on by default. It says so now, and its test holds the page to every one of the five off switches." --- Santosh watched five new people through the first run on 2026-09-30. Four read diff --git a/internal/manual/chat/task-rooms-after-restart.md b/internal/manual/chat/task-rooms-after-restart.md index 5f1584a835..547661f206 100644 --- a/internal/manual/chat/task-rooms-after-restart.md +++ b/internal/manual/chat/task-rooms-after-restart.md @@ -56,8 +56,9 @@ This one comes off by itself the moment the task's first block arrives. ## What an empty task page shows before any tool has run or after the transcript is lost -**A room never draws a blank body under its header.** Above that line the page draws what -it already knows, which is different for work that is still going and work that is over. +**A room never draws a blank body under its header.** A landed task draws what it knows +above the foot line `this task has finished — say it to main`. Before landing it draws +that above `nothing on this page yet — it fills in as the task works`, with no foot. For a task that has **not landed**, the page draws **the instruction you gave it** — the brief, held since the task was admitted — and then, where the engine has said one, the @@ -75,14 +76,12 @@ Every one of those rows is drawn only while the page has no blocks, so the whole replaced — not added to — the moment the transcript arrives. Opening the page starts nothing and restarts nothing: it reads work that is already running. -The header above it is saying the state in one word (`working`, `waiting`, `queued`) with -the clock beside it, so the body never repeats that word — what it adds is the reason -underneath it, which is the thing the header has no room for. +The header gives the state (`working`, `waiting`, `queued`) and clock; the body adds +the reason instead of repeating the state. -For a task that **has landed**, the report takes that place. If the roster still holds it — -and it does, for anything that landed in a conversation you have open — the report is drawn -above the line, so the page tells you what the work came to even when the transcript behind -it is gone: +For a task that **has landed**, its report takes that place when the roster holds it — +as it does for landings in an open conversation. Even with the transcript gone, it says +what the work came to: ``` Added the guard in parseRow and covered it with a test. @@ -90,8 +89,7 @@ this task's transcript is not here any more this task has finished — say it to main ``` -The header is drawn from the same record, which is why it stays correct — the name, the -state, the elapsed — in every one of these cases. +The header reads the same record, keeping the name, state and elapsed correct. The room is one of two doors onto old work. The other is the sessions place (`ctrl+.`, `/history`), whose `enter` on a completed task's row opens a card with the task's report read off From d9dab5c577dbc4788702d04f979818d919a2e627 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 2 Oct 2026 13:04:56 -0400 Subject: [PATCH 26/26] pool: the live off check reads only the switches that change at run time Keep the caller's resolved pool configuration instead of re-reading the process environment. Recheck the project and profile telemetry rows before recording or sending to honor live opt-outs. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/poolinstall.go | 12 +++++++----- cmd/codeaf/poolrecord.go | 2 +- internal/config/settings.go | 9 +++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/cmd/codeaf/poolinstall.go b/cmd/codeaf/poolinstall.go index b38e945ef7..33b5b35e10 100644 --- a/cmd/codeaf/poolinstall.go +++ b/cmd/codeaf/poolinstall.go @@ -84,7 +84,7 @@ func installNonce(poolDir string) (string, error) { // switch and never at a person. The rows a batch did not reach stay pending, // where the next judged run and the next start-up try them again. func poolPush(ctx context.Context, profileDir string, cfg poolcfg.Config, budget time.Duration) { - if !cfg.CanSend() || !config.ModelPoolAt(profileDir).CanSend() { + if !cfg.CanSend() || config.PoolTelemetryRowsOffAt(profileDir) { return } poolDir := config.ProfilePath(profileDir, "pool") @@ -105,23 +105,25 @@ func poolPush(ctx context.Context, profileDir string, cfg poolcfg.Config, budget } box.Install = nonce box.Budget = budget - box.Client = &http.Client{Transport: poolSendTransport{profileDir: profileDir}} + box.Client = &http.Client{Transport: poolSendTransport{profileDir: profileDir, cfg: cfg}} if _, err := box.Send(ctx, cfg.SubmitURL); err != nil && trace.Enabled() { log.Printf("model pool: send: %v", err) } } -// poolSendTransport asks the live switches immediately before EVERY REQUEST, -// because one outbox send can hold later batches and retries of refused rows. +// poolSendTransport asks the live disk switches before EVERY REQUEST, because +// one outbox send can hold later batches and retries of refused rows. It keeps +// the caller's resolved configuration so its environment remains the caller's. // A request refused here leaves its rows pending, as an unanswered relay does. // The pool's own ladder decides this; the usage gate also closes on source // builds, which have always been able to send pool scores. type poolSendTransport struct { profileDir string + cfg poolcfg.Config } func (transport poolSendTransport) RoundTrip(request *http.Request) (*http.Response, error) { - if !config.ModelPoolAt(transport.profileDir).CanSend() { + if !transport.cfg.CanSend() || config.PoolTelemetryRowsOffAt(transport.profileDir) { return nil, errors.New("model pool sending is off") } return http.DefaultTransport.RoundTrip(request) diff --git a/cmd/codeaf/poolrecord.go b/cmd/codeaf/poolrecord.go index 7117e95518..feaf921b31 100644 --- a/cmd/codeaf/poolrecord.go +++ b/cmd/codeaf/poolrecord.go @@ -262,7 +262,7 @@ func poolJudgeLandingContext(ctx context.Context, settings config.Config, profil recorder := &record.Recorder{Sheet: sheet} // The judge may have outlived a settings change. A quieted install still // keeps its own scores, but those scores never enter the outgoing rows. - if config.ModelPoolAt(profileDir).CanSend() { + if pool.CanSend() && !config.PoolTelemetryRowsOffAt(profileDir) { ob, err := outbox.Open(outboxPath(poolDir)) if err != nil { if trace.Enabled() { diff --git a/internal/config/settings.go b/internal/config/settings.go index ed18c45df9..233435a1a4 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -3433,6 +3433,15 @@ func ModelPoolResolved(profileDir string, lookup func(string) (string, bool)) po return cfg } +// PoolTelemetryRowsOffAt rechecks the telemetry project file and profile row +// before an in-flight pool send, because either can change after the caller +// resolved its configuration. The caller keeps its own resolved environment; +// this check does not resolve the pool configuration again. +func PoolTelemetryRowsOffAt(profileDir string) bool { + cwd, _ := os.Getwd() + return telemetryRowsOff(cwd, profileDir) +} + // telemetryRowsOff is the disk half of [TelemetryOffReason]: the project file // and the profile row. It does not read CODEAF_TELEMETRY itself — the caller // has read that through its own lookup — but it is not blind to the process