From 0da7b196dbe6cb358d06d4812abbccfe9820d7de Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:17:11 -0400 Subject: [PATCH 01/31] Make settings discoverable and preserve existing preferences --- cmd/codeaf/chatv3.go | 2 + cmd/codeaf/settings_document_test.go | 21 ++ internal/config/settings_presentation.go | 224 ++++++++++++ internal/config/settings_presentation_test.go | 119 +++++++ internal/manual/chat/commands.md | 233 +++---------- .../manual/chat/conversations-and-teams.md | 12 +- internal/manual/chat/delegates.md | 4 +- internal/manual/chat/getting-started.md | 4 +- internal/manual/chat/hints-and-tips.md | 16 +- internal/manual/chat/home.md | 34 +- internal/manual/chat/keeping-an-eye.md | 2 +- internal/manual/chat/keys.md | 29 +- internal/manual/chat/lanes.md | 6 +- .../chat/making-pictures-audio-and-video.md | 8 +- internal/manual/chat/models-and-cost.md | 58 ++-- internal/manual/chat/permissions.md | 12 +- internal/manual/chat/places.md | 54 +-- internal/manual/chat/questions.md | 8 +- .../manual/chat/running-from-the-terminal.md | 8 +- .../manual/chat/running-on-another-machine.md | 18 +- internal/manual/chat/screen.md | 10 +- internal/manual/chat/senior-dev.md | 4 +- internal/manual/chat/services.md | 10 +- internal/manual/chat/sessions-and-rewind.md | 4 +- internal/manual/chat/settings.md | 79 +++++ .../manual/chat/staying-on-that-machine.md | 6 +- internal/manual/chat/task-controls.md | 4 +- .../manual/chat/task-rooms-after-restart.md | 2 +- internal/manual/chat/tasks.md | 40 +-- internal/manual/chat/team-manager.md | 12 +- internal/manual/chat/teams-page.md | 56 ++- internal/manual/chat/what-i-can-do.md | 4 +- internal/manual/chat/what-i-remember.md | 4 +- internal/manual/chat/worker-harness.md | 10 +- internal/tui3/app.go | 2 +- internal/tui3/chrome_test.go | 87 ++--- internal/tui3/connectcaps.go | 17 +- internal/tui3/conversationdelete_test.go | 2 +- internal/tui3/head_test.go | 12 +- internal/tui3/homepanel_sessions_test.go | 2 +- internal/tui3/narrow_test.go | 6 +- internal/tui3/navigation_clarity_test.go | 30 ++ internal/tui3/pages.go | 56 +-- internal/tui3/pages_test.go | 22 +- internal/tui3/place_home_test.go | 2 +- internal/tui3/place_sessions.go | 2 +- internal/tui3/place_settings.go | 100 +++++- internal/tui3/place_teams.go | 8 +- internal/tui3/settings.go | 326 ++++++++++-------- internal/tui3/settings_organization_test.go | 151 ++++++++ internal/tui3/settingssearch_test.go | 6 +- internal/tui3/switcher_test.go | 2 +- internal/tui3/tasksplace.go | 2 +- internal/tui3/teamcreate.go | 4 +- internal/tui3/teammanager.go | 2 +- internal/tui3/teammembership.go | 4 +- internal/tui3/teammenu_test.go | 2 +- internal/tui3/teamnest_test.go | 4 +- internal/tui3/teamoverlay_test.go | 2 +- internal/tui3/teamsall_test.go | 2 +- internal/tui3/teamsglobal.go | 2 +- internal/tui3/teamsglobal_test.go | 8 +- internal/tui3/teamsoverview.go | 2 +- internal/tui3/teamspage.go | 6 +- internal/tui3/teamspage_test.go | 4 +- internal/tui3/teamspagedraw.go | 2 +- internal/tui3/teamspreview_test.go | 2 +- internal/tui3/topnav_test.go | 4 +- internal/tui3/wallview_test.go | 4 +- 69 files changed, 1255 insertions(+), 751 deletions(-) create mode 100644 cmd/codeaf/settings_document_test.go create mode 100644 internal/config/settings_presentation.go create mode 100644 internal/config/settings_presentation_test.go create mode 100644 internal/manual/chat/settings.md create mode 100644 internal/tui3/navigation_clarity_test.go create mode 100644 internal/tui3/settings_organization_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index a0443dcfc7..472c918c57 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -962,6 +962,8 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) { BaseURL: settings.BaseURL, Sources: settings.Sources, CompactEnabled: !opts.NoCompact, + // Carry the saved reader choice into the live chat and its tasks. + DocumentEngine: settings.DocumentEngine, SessionFile: transcript, TaskLanded: taskLanded, // The folder this conversation keeps everything in (Decision 26). It is diff --git a/cmd/codeaf/settings_document_test.go b/cmd/codeaf/settings_document_test.go new file mode 100644 index 0000000000..2cd4e180db --- /dev/null +++ b/cmd/codeaf/settings_document_test.go @@ -0,0 +1,21 @@ +package main + +import "testing" + +// The launch boundary must carry the resolved preference to read_document; +// merely saving and displaying it made the old settings row ineffective. +func TestSettingsDocumentReaderReachesLiveChat(t *testing.T) { + for _, engine := range []string{"auto", "local", "free", "ocr"} { + t.Run(engine, func(t *testing.T) { + proc := v3TestProcess(t) + proc.Settings.DocumentEngine = engine + launch, err := openV3Launch(proc, v3Options{Workspace: t.TempDir(), Model: "test/model", OneModel: true}) + if err != nil { + t.Fatal(err) + } + if launch.Config.DocumentEngine != engine { + t.Fatalf("document reader = %q, want %q", launch.Config.DocumentEngine, engine) + } + }) + } +} diff --git a/internal/config/settings_presentation.go b/internal/config/settings_presentation.go new file mode 100644 index 0000000000..361298e261 --- /dev/null +++ b/internal/config/settings_presentation.go @@ -0,0 +1,224 @@ +package config + +// SettingPresentation describes the live chat settings surface. It deliberately +// does not change the shared registry or persisted keys: resident users and old +// profiles keep the same readers, validators and writers. +type SettingPresentation struct { + Category string + Label string + Description string + Aliases []string + Advanced bool + Hidden bool + Scope string + Activation string +} + +// ChatPresentation is a pure view of a setting, never a configuration migration. +// Empty Label or Description lets a surface retain its existing wording. Timing +// is stated only where the consumer establishes it; no blanket "applies now". +func (s Setting) ChatPresentation() SettingPresentation { + p := SettingPresentation{Scope: "This profile", Aliases: []string{s.Label, s.Category, s.Key}} + switch s.Category { + case CategoryModels: + p.Category = "Models" + case CategorySpending: + p.Category = "Spending" + case CategorySafety: + p.Category = "Permissions" + case CategoryTasks: + p.Category = "Tasks" + case CategoryTeams: + p.Category = "AI teams" + p.Scope = "Default for AI teams; teams can override it" + case CategoryPractice, CategoryInterface: + p.Category = "General" + } + if ProjectKeyAllowed(s.Key) { + p.Scope = "Profile default; a project can override it" + } + if s.Kind == SettingModel && s.Slot == "talk" { + p.Scope = "This chat and the profile default" + } + switch s.Key { + case KeyPracticeIdle, KeyPracticeBudget, KeyBriefAfter, KeyTenureAfter, KeySplitPct: + p.Hidden = true // Resident-only controls have no live chat consumer. + case KeyMemoryEnabled: + p.Category, p.Label = "Memory", "remember across chats" + p.Description = "Learns useful preferences and corrections from conversations and recalls relevant memories. /memories lets you inspect what is saved. Turning this off does not delete saved memories." + p.Activation = "Restart the CLI to apply this to already-open chats" + case KeyStandingBackground: + p.Label = "background reminders" + p.Description = "Check reminders, watches and routines while the CLI is closed. Uses a timer under your login; the machine must be awake and you must be logged in." + case KeyUpdateAuto: + p.Label = "automatic updates" + case KeyMouse: + p.Label = "mouse interaction" + case KeyWork: + p.Label = "completed tool details" + case KeyIcons: + p.Label = "tool icons" + case KeyTaskColumn: + p.Label = "task sidebar" + case KeyQuickSwitch: + p.Label = "switch chats immediately" + case KeyHints: + p.Label = "show hints" + p.Description = "Show contextual tips while you work." + case KeyAttributionModel: + p.Label = "include model in commit attribution" + case KeyHistoryEnabled: + p.Category, p.Label = "Privacy", "save input history" + case KeyDraftPersist: + p.Category, p.Label = "Privacy", "save unfinished drafts" + case KeyTelemetry: + p.Category, p.Label = "Privacy", "share anonymous usage reports" + case KeyModelPool: + p.Category, p.Label = "Privacy", "model pool participation" + case KeyModelPoolPublicKey: + p.Category, p.Label, p.Advanced = "Privacy", "model pool signing key", true + case KeyToolApprovalMode: + p.Label = "tool approvals" + case KeyToolApprovals: + p.Label = "tool exceptions" + case KeyGuardian: + p.Label = "AI approval screening" + case KeyConsentTimeout: + p.Label, p.Advanced = "pause approval timer after", true + case KeyBashBackgroundAfter: + p.Category, p.Label = "Tasks", "background shell commands after" + case KeyTaskAutoApprove: + p.Category, p.Label = "Permissions", "start proposed tasks after" + case KeyTaskSettle: + p.Category, p.Label = "Tasks", "when task results need a decision" + case KeyTaskStart: + p.Label = "task planning" + case KeyTaskAudit: + p.Label = "review task results" + case KeyTaskRepairRounds: + p.Label = "repair attempts" + case KeyTaskParallel: + p.Label = "concurrent tasks" + case KeyTaskMaxLoad: + p.Label, p.Advanced = "maximum load per CPU core", true + case KeyTaskMinFreeMB: + p.Label, p.Advanced = "minimum available RAM", true + case KeyTeamsQuestionsUp: + p.Label = "ask the AI manager first" + case KeyTeamsWake: + p.Label = "run when team messages arrive" + case KeyTeamsCapUSDDay: + p.Label = "daily spending limit" + case KeyTeamsDepthLimit: + p.Label, p.Advanced = "maximum team nesting", true + case KeyTeamsSubSharePct: + p.Label, p.Advanced = "new subteam budget share", true + p.Activation = "Applies to newly created subteams only" + case KeyDailyBudget: + p.Label = "daily spending limit" + case KeySpendRail: + p.Label = "per-chat spending limit" + case KeyPlanConsent: + p.Label = "ask before a plan costs more than" + case KeyVisionModel: + p.Label = "image understanding model" + case KeyDocumentEngine: + p.Label = "document reader" + case KeyEffort: + p.Label = "reasoning effort" + case KeySearchProvider: + p.Category, p.Label = "Connections", "web search provider" + case KeyExaKey, KeyFirecrawlKey, KeyJinaKey: + p.Category = "Connections" + case KeyAPIKey: + p.Category = "Connections" + case KeyGoogleOAuthClient, KeyGoogleOAuthSecret, KeySlackOAuthClient: + p.Category, p.Advanced = "Connections", true + case KeySSHControlPersist: + p.Category, p.Label, p.Advanced = "Connections", "SSH connection reuse", true + case KeySSHServerAlive: + p.Category, p.Label, p.Advanced = "Connections", "SSH keepalive interval", true + case KeySSHServerMisses: + p.Category, p.Label, p.Advanced = "Connections", "SSH missed keepalives", true + case KeySSHIPQoS: + p.Category, p.Label, p.Advanced = "Connections", "SSH traffic priority", true + case KeyContextFill: + p.Label, p.Advanced = "compact context at", true + case KeyCompletionReserve: + p.Label, p.Advanced = "tokens reserved for replies", true + case KeyWorkingSet: + p.Label, p.Advanced = "working context limit", true + case KeyContextReuse: + p.Label, p.Advanced = "context reuse limit", true + case KeyRouting, KeyPromptProfile, KeyLaneGuard, KeyModelRoles, KeyTierReflexModel, KeyTierLowModel, KeyTierWorkerModel, KeyTierHighModel, KeyTierMastermindModel, KeyReplyGuard: + p.Advanced = true + } + // Preserve the former navigation names as search aliases for people who + // learned them before the categories were reorganized. + switch s.Category { + case CategoryModels: + p.Aliases = append(p.Aliases, "Providers", "Context") + case CategorySafety: + p.Aliases = append(p.Aliases, "Safety") + case CategoryInterface: + p.Aliases = append(p.Aliases, "Display", "Workspace") + case CategoryPractice: + p.Aliases = append(p.Aliases, "Session", "Workspace") + } + if s.Key == LaneSettingKey(LaneSlotTalk) { + p.Label = "model provider" + p.Advanced = true + } + if s.Kind == SettingModel { + switch s.Slot { + case "talk": + p.Label = "chat model" + case "image": + p.Label = "image generation" + case "speech": + p.Label = "speech generation" + case "music": + p.Label = "music generation" + case "video": + p.Label = "video generation" + case "voice": + p.Label = "audio transcription" + } + + if slot, ok := ModelSlotFor(s.Slot); ok && slot.Role != "" && s.Slot != "talk" { + p.Hidden = true // Legacy role picker cannot apply to a v3 engine. + } + } + switch s.Key { + case KeyEffort, KeyModelFallbacks, KeyTaskAutoApprove, KeyBashBackgroundAfter, + KeyTaskModel, KeyTaskAudit, KeyAttributionModel, KeyReplyGuard, + KeyPromptProfile, KeyTaskSettle, KeyTaskRepairRounds, KeyTaskParallel, + KeyHistoryEnabled, KeyDraftPersist, KeyDocumentEngine, KeyTaskColumn, + KeyGoogleOAuthClient, KeyGoogleOAuthSecret, KeySlackOAuthClient, + KeySSHControlPersist, KeySSHServerAlive, KeySSHServerMisses, KeySSHIPQoS: + p.Activation = "Restart the CLI to apply this to already-open chats" + case KeyTelemetry: + p.Activation = "Off stops sending now; turning on applies next CLI launch" + case KeySearchProvider, KeyExaKey, KeyFirecrawlKey, KeyJinaKey: + p.Activation = "Applies on the next web search" + } + switch s.Key { + case KeyMemoryEnabled: + p.Aliases = append(p.Aliases, "remember", "learn", "forget", "recall") + case KeyTelemetry: + p.Aliases = append(p.Aliases, "analytics", "tracking", "privacy", "data sharing") + case KeyTaskMinFreeMB: + p.Aliases = append(p.Aliases, "RAM", "memory", "resources") + case KeyTaskMaxLoad: + p.Aliases = append(p.Aliases, "CPU", "resources") + case KeyUpdateAuto: + p.Aliases = append(p.Aliases, "updates", "upgrade", "version") + case KeyAPIKey, KeyExaKey, KeyFirecrawlKey, KeyJinaKey: + p.Aliases = append(p.Aliases, "API key", "credentials") + case KeyDailyBudget, KeySpendRail, KeyTeamsCapUSDDay, KeyPlanConsent: + p.Aliases = append(p.Aliases, "budget", "cost", "limit") + case KeyHistoryEnabled, KeyDraftPersist: + p.Aliases = append(p.Aliases, "privacy", "saved messages") + } + return p +} diff --git a/internal/config/settings_presentation_test.go b/internal/config/settings_presentation_test.go new file mode 100644 index 0000000000..2683d24106 --- /dev/null +++ b/internal/config/settings_presentation_test.go @@ -0,0 +1,119 @@ +package config + +import ( + "bytes" + "encoding/json" + "os" + "slices" + "testing" +) + +func TestChatPresentationLeavesExistingProfileUntouched(t *testing.T) { + dir := t.TempDir() + existing := []byte(`{"ui.hints":false,"memory.enabled":"off","task.parallel":4,"teams.wake":false,"practice_idle":"31m","future.setting":{"nested":[1,"keep"]}}`) + if err := os.WriteFile(BudgetConfigPath(dir), existing, 0600); err != nil { + t.Fatal(err) + } + rows := registry(t, dir).Rows() + for _, row := range rows { + _ = row.ChatPresentation() + } + got, err := os.ReadFile(BudgetConfigPath(dir)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, existing) { + t.Fatalf("reading the new categories rewrote the existing profile: %s", got) + } + if TaskParallelAt(dir) != 4 || TeamDefaultsAt(dir).Wake || HintsAt(dir) || MemoryEnabledAt(dir) { + t.Fatal("existing preferences were reset") + } + row, _ := registry(t, dir).Row(KeyTaskParallel) + if err := row.Apply("6"); err != nil { + t.Fatal(err) + } + got, err = os.ReadFile(BudgetConfigPath(dir)) + if err != nil { + t.Fatal(err) + } + var before, after map[string]json.RawMessage + if err := json.Unmarshal(existing, &before); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(got, &after); err != nil { + t.Fatal(err) + } + for key, value := range before { + if key == KeyTaskParallel { + continue + } + var a, b bytes.Buffer + if err := json.Compact(&a, value); err != nil { + t.Fatal(err) + } + if err := json.Compact(&b, after[key]); err != nil { + t.Fatal(err) + } + if !bytes.Equal(a.Bytes(), b.Bytes()) { + t.Errorf("editing concurrency changed %s: %s -> %s", key, value, after[key]) + } + } + if TaskParallelAt(dir) != 6 { + t.Fatal("newly saved concurrency not used by resolver") + } +} + +func TestChatPresentationPreservesHiddenResidentSettings(t *testing.T) { + r := registry(t, t.TempDir()) + for _, key := range []string{KeyPracticeIdle, KeyPracticeBudget, KeyBriefAfter, KeyTenureAfter} { + row, ok := r.Row(key) + if !ok || !row.ChatPresentation().Hidden { + t.Fatalf("%s must remain registered but hidden from chat", key) + } + } + for _, slot := range ModelSlots() { + row, ok := r.Row(ModelSettingKey(slot.Slot)) + if !ok { + t.Fatal(slot.Slot) + } + wantHidden := slot.Role != "" && slot.Slot != "talk" + if row.ChatPresentation().Hidden != wantHidden { + t.Errorf("slot %s visibility incompatible with live picker", slot.Slot) + } + } +} + +func TestChatPresentationKeepsAliasesAndEveryVisibleRowCategorized(t *testing.T) { + categories := []string{"General", "Models", "Memory", "Tasks", "AI teams", "Permissions", "Spending", "Connections", "Privacy"} + for _, row := range registry(t, t.TempDir()).Rows() { + p := row.ChatPresentation() + if !p.Hidden && !slices.Contains(categories, p.Category) { + t.Errorf("%s has unknown category %q", row.Key, p.Category) + } + if !slices.Contains(p.Aliases, row.Label) || !slices.Contains(p.Aliases, row.Key) { + t.Errorf("%s lost legacy search terms", row.Key) + } + } +} + +func TestChatPresentationDoesNotInvertPersistedHints(t *testing.T) { + dir := t.TempDir() + row, _ := registry(t, dir).Row(KeyHints) + if row.ChatPresentation().Label != "show hints" { + t.Fatal("missing positive display label") + } + // The shared registry still answers the resident's negative question. The + // chat surface alone reverses its display; storage remains positive. + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + if HintsAt(dir) { + t.Fatal("disable-hints on must preserve ui.hints=false") + } + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + if !HintsAt(dir) { + t.Fatal("disable-hints off must preserve ui.hints=true") + } +} diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 6905c25f94..aee1cf9a74 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -301,14 +301,14 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/crew` | — | `models ` | which models a seat may be picked from — `all`, `open`, `≤in/out`, ids | | `/crew` | — | `cap ` | the most tasks' crews may spend in a day | | `/crew` | — | `cap task ` | the most one task may spend — $5 unless set; `-yes-spend` does not lift it | -| `/task` | — | — | opens the full-screen sessions place — the same place as `/history` and ctrl+. | +| `/task` | — | — | opens the full-screen activity page — the same place as `/history` and ctrl+. | | `/task` | — | `` | starts one worker at once; its brief is written and its width read beside it, and wide work splits | | `/task` | — | `solo ` | starts one worker at once, with no reading of its width | | `/task` | — | `--best ` | starts the task on the strongest crew the allowed models make, this task only | | `/task` | — | `--cheap ` | starts the task on the cheapest crew that does the work, this task only | | `/stop` | — | — | asks before stopping the open task or selected work; with no target says `open a running task to stop it` | | `/redo` | — | `stronger` | runs the last task again on a stronger crew, and teaches the crew that kind of work needs more | -| `/history` | — | — | opens the full-screen sessions place — every task this machine has run, filterable (also ctrl+.) | +| `/history` | — | — | opens the full-screen activity page — every task this machine has run, filterable (also ctrl+.) | ## Status, search, teams and spending commands @@ -370,7 +370,7 @@ Under the table `/help` prints the keys that have no slash command, including rows, directly under the `tab` row: ``` -alt+1…8 go to a place · in the tab bar's own order: home teams chats sessions spend settings standing memory +alt+1…8 go to a place · established shortcut order: home · AI teams · chats · activity · spend · settings · standing · memory alt+. on a place: what else is here · every key that place has, drawn on a place, tab is the next place · esc back ``` @@ -1548,7 +1548,7 @@ The row is called by the first words you typed for a second or two, and then by name a small model gives it. When a `/task` runs on the run engine (*The worker harness* page), the row it writes to the -sessions place carries the step its worker is on **right now** — the running glyph `◐`, the shell +activity page carries the step its worker is on **right now** — the running glyph `◐`, the shell lead `$` and the command — with the task's `N steps · $0.11` under it. The line is there only while a step is in flight, and goes the moment the command ends. @@ -1556,10 +1556,10 @@ while a step is in flight, and goes the moment the command ends. strips the tag and takes the remaining words through this same road. Backspace immediately after the tag makes it plain prose. -**A bare `/task` opens the full-screen sessions place** — the same place `/history` and `ctrl+.` +**A bare `/task` opens the full-screen activity page** — the same place `/history` and `ctrl+.` open, holding this machine's conversation and task record. It does *not* print a usage line, and it starts nothing. On a project that has never run one it opens the page anyway, headed -`sessions` over one line: `work you send off with /task lands here, and its record stays`. The `+ /task` +`activity` over one line: `work you send off with /task lands here, and its record stays`. The `+ /task` row at the foot of the task column types `/task ` into your box, which is why the word on its own has an answer worth giving. @@ -1582,7 +1582,7 @@ happened: `/task adaptive` on its own, with no brief after it, starts nothing and prints the usage line instead: `usage: /task · /task solo `. -The `starting a task` row in `/settings` → Session decides what the plain form does: +The `task planning` row in `/settings` → Tasks decides what the plain form does: `sized` is the default and is the behaviour above, and `single` always starts one worker and does not read its width at all. `solo` typed on the command line overrides the row either way. Two of the row's old answers are gone — `ask` with the two-choice list it @@ -1637,12 +1637,12 @@ and nothing to walk between — the arrows keep their ordinary meaning — and w forming there is no head that counts, only the block described under *Why is there a line next to my task*. A typed `/task` never joins the block: it has no wait in front of it. -## /history — task history in the sessions place, including past tasks +## /history — task history in the activity page, including past tasks -`/history` opens the full-screen `[sessions]` place, the same destination as `ctrl+.`, +`/history` opens the full-screen `[activity]` place, the same destination as `ctrl+.`, `alt+4` and a bare `/task`. It lists this machine's conversations and their nested tasks across projects and earlier sessions. The column beside a conversation holds -that conversation's tasks; the sessions place holds the broader record. There is no +that conversation's tasks; the activity page holds the broader record. There is no `/tasks` command. `/task ` and `/task solo ` start work; `/history` only opens the record. @@ -1659,7 +1659,7 @@ otherwise it opens that task's record card. `→` opens the row's available actions. Another window's running task can be opened or located when its row offers that action. -On a machine with no task record, the place still opens under `sessions` with +On a machine with no task record, the place still opens under `activity` with `work you send off with /task lands here, and its record stays`. ## /crew — the crew panel, and the three seats a task runs on @@ -1769,7 +1769,7 @@ this split when it opens; configure the remote profile on that machine. `/settings` (or `/set`, `/config`, or ctrl+,) opens a fullscreen page: a tab bar over the codeaf settings, plus a tab of connected accounts. It was the first of the three fullscreen -pages here — the others are `/history` (the sessions place, ctrl+.) and `/home` — and **only one +pages here — the others are `/history` (the activity page, ctrl+.) and `/home` — and **only one of the three is ever up at a time**: opening any one closes the other two. Moving in it: @@ -1777,7 +1777,7 @@ Moving in it: - ↑ / ctrl+p and ↓ / ctrl+n move a row at a time. Headings are stepped over, never landed on. pgup/pgdown move 16. home/end jump to the ends. - ← and → switch sections, clamping at the ends rather than wrapping. **`tab` no longer - does**: it is the way to the next place — home, teams, sessions, spend, + does**: it is the way to the next place — home, AI teams, activity, memory, spend, settings — here as everywhere else, and `shift+tab` walks that circle back. The panel's own bar is the second one, under the places' bar. - **Any printable key types into a search box** that filters across all tabs at once, @@ -1879,191 +1879,48 @@ config.json keys are not read: models, tiers; anything set under them is ignored It names every unread key, sorted. It is said **once per profile for that set of keys**: the next launch with the same keys says nothing, and a set that changes — a key added -or taken away — is said again. It is not a tip, so turning tips off in the Display -tab's `hints` row does not hide it, and a conversation over `--host` says it about the +or taken away — is said again. It is not a tip, so turning tips off in the General +category's `show hints` row does not hide it, and a conversation over `--host` says it about the profile on the machine running the work. Nothing is rewritten: to act on it, move the value to the key a settings row names (`settings` lists every one), or delete the key. -## The ten settings tabs +## Settings categories — where did Session, Context, Workspace, Display and Providers go -The tabs, in order: +Settings now groups controls by what you want to change: -``` -Session · Context · Workspace · Display · Spending · Safety · Tasks · Teams · Providers · Connections -``` - -## The settings tab strip on a narrow terminal - -On a terminal too narrow for all ten tabs, the strip **scrolls** rather -than being cut: the tab you are standing on is always drawn and always inked, its -neighbours are drawn while they fit, and each end that had to give a tab up wears a `…` -saying there is more that way. `←` and `→` still walk the tabs one at a time, and the -strip follows. - -It anchors left while you are near `Session` and right while you are near `Connections`, -so both ends of the walk look exactly like a strip that fits. A tab the strip could not -draw cannot be clicked, because nothing on this surface acts on something you cannot see. - -**Session** — the rows this conversation carries that belong nowhere else: "memory" and -"fallback models". Two rows, and that is the honest size of it. - -The four ssh rows a `--host` conversation rides on ("ssh reuse", "ssh heartbeat", "ssh -missed heartbeats", "ssh traffic") are **not** here any more — they are on **Workspace**. -Every one of them lands on the next launch rather than on the conversation in front of -you, so they are a fact about this machine and not about this session. Nothing you saved -moved: the keys they are stored under are unchanged. - -The models codeaf uses on your behalf are **not** here — they are on Providers, with -the row that says which model you are talking to. They used to be on this tab, one tab away -from it, which made "which model does the planning" and "which model am I talking to" two -errands on two screens. Neither is the conversation's own money limit here any more: it is -`per conversation` on **Spending**. - -**Context** — what a model carries. Rows: "compact at", "answer room", "working set", -"context reuse", "searching", "exa key", "firecrawl key", "jina key". - -The selected **searching** row is a live explanation rather than a static -description. On untouched `auto` it reads `now firecrawl, keyless — set -search.exaKey or search.firecrawlKey to raise it`; a pinned Exa row with no key -quotes the exact `Search failed (exa): no API key` answer; a configured pin -reads `exa, with your key`. Provider and key changes land on the next search in -the conversation already open. - -**Workspace** — this machine and this project: what codeaf does with its own time here, and -what it may reach on your behalf. Rows: "quiet before practice", "arrival brief after", -"tenure after", "background checks", "model in commits", "google sign-in id", "google sign-in -secret", "slack sign-in id", and the four ssh rows — "ssh reuse", "ssh heartbeat", "ssh -missed heartbeats", "ssh traffic". **It holds no money row at all** — every one of those -moved to Spending. +`General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Privacy` -The ssh rows are here because "what may codeaf reach on your behalf" is this tab's own -question, and a link to another machine is that question asked about a machine rather -than about an account. They are not on the tab named **Connections**: that one is the -catalog of third-party accounts you sign in to, and it is built from the account list -rather than from the settings registry. +General holds appearance, input and updates. Models holds model selection and +advanced routing/context controls. Memory holds remembering across chats and a +link to saved memories. Tasks controls delegated work; AI teams sets defaults +for groups of AI chats. Permissions controls approvals. Spending holds limits. +Connections holds accounts, keys, web search and remote connection settings. +Privacy holds history, drafts and data-sharing choices. -**"background checks"** is on by default: one small timer under your own login checks -your reminders, watches and routines every 5 minutes with no window open. Off removes it -and nothing standing is lost — see *Keeping an eye on things* for the whole of it. +Old saved keys and values are unchanged. Search accepts old labels and keys, +including settings inside Advanced. Resident-only practice, arrival-brief and +tenure controls no longer appear in chat settings. See **Settings** for scope, +activation timing and how an existing profile carries forward. -**Spending** — money, and nothing that is not money. Seven lines: the reading `today`, then -"per day", "per conversation", "per plan", the readings "per task" and "per standing run", -and "practice". `/budget` and `/limits` open it. *Models, context, and what it costs* has -every row and every door onto them. +## Settings categories on a narrow terminal -**Safety** — what codeaf may do without asking you first. Rows: "ask before running", -"tool exceptions", "shell command rules", "guardian", "approval countdown", "background -after", "task countdown", "who settles work that needs a look". +Wide terminals show a category list beside the controls. Narrow terminals show +a scrolling category bar. The selected category remains visible; `…` indicates +more categories. Left and right change category and the bar follows. Only +visible controls can be clicked. Hover shows row help without changing its value. -**Tasks** — how work you can walk away from is run. Rows: "starting a task", "check task -work", "task repair rounds", "tasks at once", "busy machine", "memory floor", "task -model". +Type to search across settings and connection services. Escape clears the +search before leaving settings. Advanced controls remain searchable while +collapsed. Escape from an editor cancels its unfinished edit. ## What the number on a settings row is counted in -Every row that shows a number shows what the number counts, so a whole tab can be read -without moving the cursor onto each row. The unit is part of the value: - -| Row | Reads | Counted in | -| --- | --- | --- | -| ssh reuse | `300s` | seconds a connection stays reusable after its channel closes | -| ssh heartbeat | `3s` | seconds of silence before ssh asks whether the far machine is there | -| ssh missed heartbeats | `3` | unanswered heartbeats — the label says what is counted | -| approval countdown | `10s` | seconds an approval question counts down before it pauses and keeps waiting | -| background after | `30s` | seconds a foreground command runs before it becomes a job | -| task countdown | `15s` | seconds a proposed task waits for you before it starts | -| compact at | `60%` | how much of the model's window is filled before compaction | -| answer room | `65536 tok` | tokens every call keeps free for its answer | -| working set | `160000 tok` | tokens of material kept quoted in front of a worker | -| context reuse | `250%` | shares of one whole context a job may re-send — 100% is once | -| memory floor | `1536 MB` | megabytes that must be free before another task starts | -| busy machine | `1.5 per core` | the load average per core at which new tasks wait | -| tenure after | `3 clean firings` | clean firings a standing charter needs to earn tenure | -| chat width | `50%` | the chat pane's share of the frame while the task rail is open | - -A row whose label already names what is counted — "task repair rounds", "tasks at once", -"ssh missed heartbeats" — shows the figure alone rather than saying the word twice. - -**A row takes its unit back.** The box a value is typed into opens on the bare figure, and -typing the unit back in is accepted: `120s` on "ssh reuse" and `120` both save 120 -seconds. Anything else is refused in the row's own words. - -**A duration row is different and always was.** "quiet before practice" and "arrival brief -after" are written the way you would say them — `20m`, `4h`, `1h30m` — and `0` turns them -off. - -**Display** — how the surface draws itself and what it remembers of your typing. Rows: -"input history", "keep drafts", "task column", "chat width", -"mouse", "timestamps", "turn work". There is no "nerd font" or "linear mode" row: icons -need no patched font anywhere on this surface, and the accessible single-column rendering -is the `--linear` flag at launch rather than a persisted setting. - -**Providers** — which model answers what. It leads with the **Models section**, in this -order: - -1. **your model** — the model you are talking to. It is the conversation slot, and picking - here is the same road `/model` takes — the same picker, providers and all. Its value carries - the provider serving it: `deepseek/deepseek-v4-flash · auto (cloudflare now)`. -2. **provider** — which provider answers your model. enter opens them with - what has been measured of each, and enter on one pins it. -3. **speed guard** — whether an answer slow to start is asked of the next-best provider as - well. -4. **routing** — what every request prefers among the providers, and it cycles - `simple`, `latency`, `price`, `off`. **`simple` is what it ships as**: codeaf sends no - preference of its own, a provider you pinned goes out as the whole request, and with no pin - the router's own default routing answers. `latency` asks for the fastest provider and - `price` for the cheapest, on every call. With `off` nothing is measured, so the two rows - above it have no provider to name. -5. **prompt profile** — how much codeaf tells the model before you type: `auto`, `lean`, - `full`. Another cycle row. `auto` reads the model's context window and goes lean under - 32,000 tokens (see *Models, context, and what it costs*). -6. **reflex** — `near-free · reads every turn — memory, titles, safety` -7. **small work** — `cheap · the small calls — names, digests, the safety gate` -8. **seats** — `the worker, planner and checker, the models they may be picked from, the providers they may route through, and the per-task and daily caps · enter opens /crew` -9. **pinned roles**, and hanging off it the **roles** list — one row per auxiliary call - codeaf makes for itself, grouped under its row. Those rows come from the running binary - rather than the settings registry. - -**worker**, **checker** and **planner** are the crew's three seats, and on the tab they are -one row, **seats** (`auto · 1 pinned · models open · 3 of 4 providers · per task $5 · crew daily cap $5.00`). -`enter` on it opens the `/crew` panel, where the seats, the allowed models, the providers -and the per-task and daily caps are changed; `esc` there comes back to the row. - -**A pin for a role this build no longer has is ignored, and the row stops showing it.** Roles -come and go with the calls that use them — `compaction` was one, and a compaction has not asked -a model since long before it was deleted. A pin left behind for a word like that is dropped -when the row is read, never written back, and the foot line says `compaction is no longer a -role — that pin is ignored` the next time you change any pin. **Every other pin on the row -keeps working**, which is the whole point: the row is one string holding all of them, and -refusing the lot over one dead word would leave you unable to change any of them without -editing `config.json` by hand. - -Typing a word that is not a role is still refused outright, with the real names listed — that -refusal is for the pin you are adding now, which is the one you can do something about. - -The four machine rows lead because the endpoint serving your model is part of the same -decision as the model, and they used to sit at the foot of the tab, forty rows below it. - -Then the rest of the tab: "looking", "reading", "reply guard" — whether a reply -that has come apart is cut and asked again, on by default (see *Models, context, and what -it costs*) — and one row per capability slot added automatically from the settings -registry: drawing, speaking, composing, filming, voice. - -The reflex and small work rows are **select** rows and open the model picker. The crew's -worker, checker and planner have no rows of their own here: the one **seats** row opens the -`/crew` panel, and a seat is pinned there or with `/crew pin` — a pin may carry a thinking -level, `/crew pin planner moonshotai/kimi-k3:high`, and the seat is then asked at that level. - -The connected model providers have their own section on the tab, each with its billing -door, the safe spelling of its key, its region and its order. The section ends with a -`+ add a provider` row, and once a custom provider is connected an `active -provider` row follows it: it reads -`answering on localhost · enter moves it to homelab`, and enter moves this conversation -onto the next provider, wrapping past the last back to the first. The -[services page](services.md) has the whole of it. - -**Connections** — the accounts this profile has connected and what each may do. Its rows -come from the engine rather than the settings registry. +Settings show units with their values: dollars, seconds, percentages, tokens, +RAM in MB and task counts. The editor explains accepted values and validates +before saving. Zero is not interchangeable between controls: it can mean no +limit, waiting indefinitely or disabling a behavior. Read the selected control's +help before changing it. Invalid input keeps the editor open with an explanation; +it does not silently save another value. ## Changing a row in settings @@ -2092,7 +1949,7 @@ hear, and everything else follows the general chat rule. Its legend is Because the picker is the same component, everything true of `/model`'s ranking, its rows and its ctrl+t effort knob is true here too — **including the providers** on the row that has -them. On **your model**, `→` or `tab` unfolds the providers serving the model under the +them. On **chat model**, `→` or `tab` unfolds the providers serving the model under the cursor, walks the cursor into them, and `enter` on one pins it, exactly as under `/model`. The legend says `↑↓ move · → or tab providers · enter choose · esc cancel · type to filter` on a model and `↑↓ move · ← or tab back · enter choose · esc cancel · type to filter` @@ -2105,7 +1962,7 @@ Connections tab — the foot drops `tab next place`, because the layer has taken ## The roles rows in settings — pinning a role, and del to unpin -The **roles** list sits on the **Providers** tab, directly under "pinned roles". Each row is +The **roles** list is in **Models → Advanced**, alongside "pinned roles". Each row is one call codeaf makes outside a turn — `title`, `guardian`, `checker`, `planner`, `designer`, `worker`, `router`, `vision`, `reflex`, and `spellout`, which is the one of them you ask for yourself with `ctrl+r` (see the keys page) — drawn as @@ -2136,7 +1993,7 @@ in the models page. ## Which model draws my pictures, speaks, films, or looks at an image -Each of those is one row on the **Providers** tab, and the row is the front door: the model +Each of those is one row on the **Models** category, and the row is the front door: the model you pick there is the model that runs. A blank row reads `automatic`, which is not "off" — it means codeaf picks one for you. diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index fd76730bfb..857843b33d 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -138,10 +138,10 @@ to several teams. The Chats overlay shows all the selected team's members, manag the `▦ All` grid always shows all saved conversations regardless of team. Create a team with **+ New team** below the Teams sidebar list. **+ Add subteam** beside -Add member creates one under the selected team. A dedicated dialog over Teams offers Name, +Add chat creates one under the selected team. A dedicated dialog over Teams offers Name, Colour, and an optional searchable Name/Project member list. Click several rows to select them; filters keep earlier selections. Leave the list unselected to create an empty team and use -Add member afterward. `tab` changes fields, arrows move through members or colours, `space` +Add chat afterward. `tab` changes fields, arrows move through members or colours, `space` selects the highlighted member, `enter` creates, and `esc` cancels. Manager assignment stays separate. Duplicate team names are refused; Rename is in Settings. @@ -157,10 +157,10 @@ its members. `esc` cancels naming, then selection, then the grid. Cancelling doe memberships or the conversation's draft. Both creation routes use the same saved catalog and creation rules. -Use **+ Add member** in the team's overview to add an existing conversation or create one. +Use **+ Add chat** in the team's overview to add an existing conversation or create one. Use the member card's **x** to remove that membership with confirmation. Current work finishes, and the conversation survives with its other memberships. Assign another manager before -removing the current one. **Choose manager** selects an existing member; to use a new +removing the current one. **Choose AI manager** selects an existing member; to use a new conversation, add it first, then choose it. A manager may manage its team's descendants but cannot also manage an unrelated team or be both the global and an ordinary team's manager. @@ -238,7 +238,7 @@ it does not select an overlay. ## A team's manager A **manager** is a conversation that runs the team: you talk to it, it sends work to members -and reports progress. Teams offers **Choose manager** for existing members and **+ Manager** +and reports progress. Teams offers **Choose AI manager** for existing members and **+ Manager** when a team has none. Add a new member first when you need a new conversation to replace an existing manager. All teams has a dedicated Global manager card. `+ Global manager` creates its optional conversation. Its reports are the managers of open top-level teams; subteam managers @@ -246,7 +246,7 @@ report to their parent. Its card's `x` deletes the global-manager conversation w normal confirmation, preserving every team and its manager. The creation control returns. In a selected overlay the manager's tab is pinned at the left. It reads `◆ Manager`, or -`+ Manager` while there is none. The grid does not pin managers or relabel their tiles. +`+ AI manager` while there is none. The grid does not pin managers or relabel their tiles. `alt+m` goes to the selected team's manager. A manager can manage descendants of its team, but cannot manage two unrelated teams. Ordinary memberships in multiple teams are allowed. diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index d2a723512e..6539dd1602 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -32,8 +32,8 @@ did. ## Where a program's badge appears — and where it does not **Every program's tasks wear its name as a badge**: `[]` after the task's title on -the side list, the proposal card, the task's page, the `@` list, the sessions place and home, and its -initials (`[sd]` for senior-dev, `[s]` for sec, `[r]` for review) where a list is narrow. A task codeaf's own worker does +the side list, the proposal card, the task’s page, the `@` list, the activity page and home, and its +initials (`[sd]` for senior-dev, `[s]` for sec, `[r]` for review) where a list is narrow. A task codeaf’s own worker does wears none, and a program added to codeaf later gets its own badge from its name. The landed card's head shows the title and outcome without a program badge. diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index e81f85419b..ef19da62fd 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -109,7 +109,7 @@ leaves this line under the box and stays on the step: What it writes for the default provider: the `api_key` field of your profile's `config.json` (under `~/.codeaf`), owner-readable only. That is the same field the **openrouter key** row on the settings -panel's Providers tab writes, and the one every later launch reads. The running +panel's Connections category writes, and the one every later launch reads. The running conversation takes it at once — the next message rides it, no restart. ## Skip the default OpenRouter provider, retry later, and keep the message I typed @@ -337,7 +337,7 @@ Every answer went through a settings row, so every answer has a door: | What you answered | Where to change it later | | --- | --- | -| the default provider's openrouter key | clear or remove it and the next local interactive launch offers **connect openrouter** again; `/settings`, Providers tab, the **openrouter key** row still accepts a pasted replacement | +| the default provider's openrouter key | clear or remove it and the next local interactive launch offers **connect openrouter** again; `/settings`, Connections category, the **openrouter key** row still accepts a pasted replacement | | 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 | diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 9bb658491d..efdea84f40 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -233,7 +233,7 @@ of the commands they named still works** — only the tips about them are gone. - `/attach lets you browse anywhere for files` was a second row about one command, which is one row too many. - `ctrl+. sees every task this project has run` came off on the owner's word, the last of - the three reads. The chord still opens the sessions place, `/history` still opens it too, and + the three reads. The chord still opens the activity page, `/history` still opens it too, and the *tasks* manual page still says so. Unless a line above says otherwise, a tip is true from the first minute on home and after @@ -247,17 +247,9 @@ for a chord that works only in a conversation and only over a making-shaped sent ## Turn off hints — stop showing tips, disable the hints, the disable hints row -Open the settings panel with `/settings` (or `ctrl+,`), go to the **Workspace** tab, and flip -the **disable hints** row on. The line under the row reads -`disable💡 tips everywhere (requires restart)`. Enter or space toggles it; it is off by -default, which means the tips show. (Until 2026-09-22 it was a **hints** row on the Display -tab, on by default.) Inside a running codeaf the change lands at the end of the next turn, -in every conversation and on home alike; restarting codeaf is the only way to have it at once. On silences the tips — over a conversation's -box and over home's alike — and the what's-new lines together; it does not touch the keys -row's own words for a live state — `esc interrupt` and the rest are not hints and cannot -be turned off. +Open `/settings` (or `ctrl+,`), choose **General**, and turn **show hints** off. This hides contextual tips and what's-new notices; the keys row still explains available actions. Turn it on to show tips again. Tips already retired stay retired. -Turning the row back off shows whatever is due. Tips you had already retired stay retired. +Existing profiles retain their preference. If you previously turned **disable hints** on, **show hints** now appears off. ## What "news" lines are — what's new after an update @@ -269,4 +261,4 @@ of the same build says nothing. There is nothing to announce yet, so no news line has ever been printed by this build. A first launch on a fresh profile says nothing either — nothing is new to somebody who never saw -the older build. The **disable hints** row on the Workspace tab silences news lines along with the tips. +the older build. Turning **show hints** off under General silences news lines along with tips. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index c91f6eba7e..c2f2f4714f 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -59,7 +59,7 @@ There is no argument form. The screen is how you name what you want; a command t project name would be asking you to type out the very thing home exists to show you. Home is the **first place on the top line**, right after the `codeaf` wordmark: -`home teams chats sessions spend settings`. +`home chats AI teams activity memory spend settings`. The **teams page**, right after it, is where your teams and their managers live. It still does nothing on its own: no notifications and no alerts. You open it, you see where things stand, and you either act on something or leave. @@ -188,7 +188,7 @@ watch that needs somebody, never age off home. as an ordinary line once it has been answered or has aged out of the group. **On a short frame the whole group folds to one line before any question gives way** — -`8 unread`. `enter` expands that group; the `sessions` heading opens the sessions place. +`8 unread`. `enter` expands that group; the `sessions` heading opens the activity page. ## Answer from home — a digit answers the question that is drawing its answers @@ -245,7 +245,7 @@ A conversation another terminal is holding is not refused: `enter` brings it her Work that landed as `your call` **waits until you decide about it**, however many days that is: nothing more happens to that work until somebody answers it. Only its row on home ages — two days after it landed it leaves `needs you` for the count on the panel's last line, -`3 more`, and the sessions place still lists it; `enter` on that line opens the panel and shows +`3 more`, and the activity page still lists it; `enter` on that line opens the panel and shows it again. Three ways to settle it, and they are the same door: @@ -271,18 +271,18 @@ not where it is settled.** ## Sessions — the fifteen most recent conversations on Home and what is running on this machine right now -The **sessions** section replaces the old list of individual tasks. It shows up to +The **activity** section replaces the old list of individual tasks. It shows up to fifteen conversations across projects, newest conversation activity first, regardless of whether they have tasks. Closed conversations remain in history with dim titles. Each row uses the full conversation title, truncated to fit, and its age. Selecting a row opens that conversation; `→` offers the usual conversation actions. -Click **sessions** to open the **sessions** tab (formerly tasks), which holds the full +Click **activity** to open the **activity** tab (formerly tasks), which holds the full conversation trees under running and completed. The running section shows work in flight across projects. Short frames show fewer rows with a fold for the remainder of these fifteen. The heading leads to the full history. -There is exactly one conversation list on Home, under **sessions**. Open tabs, +There is exactly one conversation list on Home, under **activity**. Open tabs, recently closed conversations and saved history are combined by conversation identity before choosing the fifteen most recent, so each conversation appears once. The `opt+k` chats menu still lists open tabs; dismissing or reopening a tab @@ -291,7 +291,7 @@ updates the tab and the row in Sessions together. ## Why does home show a session id for an untitled chat, untitled conversation, new conversation **A conversation nothing has named yet reads `new conversation`** on home's sessions -list, the same word the sessions place uses. It never reads as its session id, including +list, the same word the activity page uses. It never reads as its session id, including the id with its first letter raised (`D53cceead3f99593`). A conversation that has a title keeps that title. @@ -325,7 +325,7 @@ spinner. In screen-reader (linear) mode nothing turns at all. ## What did it do while I was away — the since you left panel, what happened while the terminal was shut -**What happened on its own while you were not looking**, under `sessions` — in the field +**What happened on its own while you were not looking**, under `activity` — in the field when it holds anything and in the rail when it does not — headed with how long you were away: @@ -423,7 +423,7 @@ no right side at all on the resting screen. Where to go for the whole figure instead: - **`→` then `its chats`** on the row lists every conversation in the project as a search; -- **the sessions place** for the work a project has run, which is the record rather than a +- **the activity page** for the work a project has run, which is the record rather than a count; - **the spend place** for what it has cost. @@ -533,7 +533,7 @@ put would move you onto something else between two glances. **It is not empty; the panels are waiting for their first rows.** On a machine that has done nothing yet, every panel but one keeps its heading and one dim line naming what arrives there — `questions from any chat or task land here · a digit answers them` under -`needs you`, `work you send off with /task runs here on its own` under `sessions`, and so on +`needs you`, `work you send off with /task runs here on its own` under `activity`, and so on (*See everything at once*). `projects` always has the folder this window opened in, and the conversation list has this conversation from its first minute. @@ -590,7 +590,7 @@ panels, and `enter` again folds it. The box at the foot still searches every con the machine as you type — a project's name, a folder's name or a word from what a task came to all find them — whether or not a panel is drawing the row. -Every panel's fold works the same way: `N more` under `sessions`, `since you left`, `standing` +Every panel's fold works the same way: `N more` under `activity`, `since you left`, `standing` and `projects` opens that panel. The places themselves — tasks, standing, spend — are on the tab bar and their slash commands, not behind the folds. @@ -608,7 +608,7 @@ there is no strip. The rule and a blank come next, three rows, and the page star row higher. **Inside a chat** and on every place but home the top line reads: ``` - >● codeaf home teams chats sessions spend settings 2 want you · 4 moving · $0.55 / $500 · tue 1:11pm + >● codeaf home chats AI teams activity memory spend settings 2 want you · 4 moving · $0.55 / $500 · tue 1:11pm ``` **On home it drops the two counts** and keeps the budget and the clock, because the @@ -644,7 +644,7 @@ cent — one reading of one file, wherever you are standing. ## Where did standing and memory go: the six words on the tab bar -The top line reads `home teams chats sessions spend settings`. `alt+1` through +The top line reads `home chats AI teams activity memory spend settings`. `alt+1` through `alt+6` go to each; `chats` (`alt+3`) returns to your conversation. - `/standing` (or `/orders`) and `alt+7` open Standing. @@ -1639,7 +1639,7 @@ reports that it can send one, and there `ctrl+1` … `ctrl+8` are a second spell map's own line names while it is live. Beyond that alias there is no `ctrl+` chord for home: the plain ones are all taken (`ctrl+.` is the -sessions place, `/history`). +activity page, `/history`). ## What landed while I was away — since you left, and the look stamp @@ -1740,7 +1740,7 @@ rows on disk that say `running` forever. Home never repeats that claim. **It asks the session itself.** A live session says out loud, every few seconds, which task nodes it currently has out; the task details show it as running only while the session still names that node. Every other live-looking row is **work that was under way when the window went**, and it is -on no panel: the sessions place and the card beside a search say `incomplete` against it. +on no panel: the activity page and the card beside a search say `incomplete` against it. A session too old to keep that file, but whose journal a window is holding, falls back to the older answer: the lock is asked, and its rows are believed. That is the same rule with @@ -2140,7 +2140,7 @@ Completed work can also appear under `since you left`. ## How do I see more tasks on the right — ▸ …5 more tasks -**On the card beside a search, `▸ N more tasks` names the sessions place** out at the right +**On the card beside a search, `▸ N more tasks` names the activity page** out at the right margin rather than unfolding, because a card is not the place that holds them; `ctrl+.`, or `tab` onto `sessions`, is the way there. A band of files or folders with more behind it says `▸ …3 more files`, and a click on that line — or `→` with the strip closed — opens it, and @@ -2449,7 +2449,7 @@ On the card beside a search and on the phone sheet, a conversation's tasks form their actual parents. Clicking a task opens that task's record. A child needing a person brings its family forward; work still moving comes before settled work. The compact preview keeps three task names in parent-first order, so it never shows a child without its visible -ancestry, and `N more tasks` names the sessions place, which holds the full conversation tree. +ancestry, and `N more tasks` names the activity page, which holds the full conversation tree. ## What home cannot do yet — stop another window's task, and a something is wrong panel diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index 4767fd5ce4..af62807679 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -657,7 +657,7 @@ nothing was lost while there was no key. ## Turn background checks off -`/settings` → **Workspace** → **background checks**, or just say "turn off the +`/settings` → **General** → **background reminders**, or just say "turn off the background checks" and the chat will do it. It is a two-word row — `on` or `off` — and `on` is the default. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index a95d59b842..9ec99a63f1 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -670,7 +670,7 @@ cannot send it delivers plain enter on many keyboards, or a newline on some. | `ctrl+,` | Open the settings panel | | `alt+e` | Walk this conversation's thinking rung one step: auto → low → medium → high → xhigh → max, and back to auto. Works with a sentence half typed. On home and every other place it walks the rung of the **next** conversation instead — the effort word after the model’s colon on home’s seam | | `alt+a` | Walk what this conversation runs without asking one stop: asks → guardian → YOLO → asks. Never lands on `refuses`. Works with a sentence half typed; over `--host` it says the far machine's rules decide. On home and every other place it walks the gate of the **next** conversation — the `◇` cell on the rule above that box — and that pin is spent by the conversation that uses it | -| `ctrl+.` | Open the sessions place (`/history`) — every task this machine has run, across every project and every session; type to filter it. It opens on a machine that has run nothing too, and the page says what tasks are | +| `ctrl+.` | Open the activity page (`/history`) — every task this machine has run, across every project and every session; type to filter it. It opens on a machine that has run nothing too, and the page says what tasks are | | `space` `space` | On an **empty** box: open home (`/home`) — every project and conversation on the machine the session runs on, and an empty home on a fresh one. Does nothing when the box has words in it | | `ctrl+l` | Jump back to the live edge of the conversation | | `alt+v` (`opt+v`) | Open the **conversations view** (`/wall`) on Chats: all saved, non-deleted conversations, with live or saved previews. Select cards to create a team. Press again or `esc` to close it. Its own keys are on the *Conversations and teams* page | @@ -1369,8 +1369,8 @@ What it changes and what it does not: it. It is sticky, kept in this session's own `meta.json`, so it is still there after you close codeaf and `/resume`. `codeaf resume --yolo` outranks the saved word for that launch. -- It does **not** change other conversations. Their answer is the **"ask before running"** - row and the **guardian** row on `/settings`' Safety tab, unless they have a saved +- It does **not** change other conversations. Their answer is the **tool approvals** + row and the **AI approval screening** row on `/settings`' Permissions category, unless they have a saved posture of their own. - **Neither floor moves.** Dangerous shell commands and anything sent in your name are asked about at every stop, `YOLO` included, exactly as under `--yolo`. @@ -1725,7 +1725,7 @@ follows what you type. Only these keys are taken from you: **Model picker** — opened by `/model` with no argument, by clicking the model name in the status row, or by `enter` on the **your model** row of the settings panel's -Providers tab (the same list and the same keys, drawn in the panel's place): +Models category (the same list and the same keys, drawn in the panel's place): `esc` close · `enter` switch to the highlighted model · `ctrl+t` cycle the reasoning effort · `ctrl+r` fetch the newest model list (`/model` only — not the settings panel's @@ -1757,7 +1757,7 @@ Every model picker column is two rungs — its own direction, then reversed — `model ↑`, `via ↓`, `via ↑`, and so on back round to the name, skipping any column this list published nothing in; `alt+shift+s` retraces it. The list is always sorted and the sorted column always wears `↓` or `↑` in the heading. Inside an open provider fold the same key -sorts the PROVIDERS, and the two tables keep their own orders. It is a chord and not a bare `s` for the reason the sessions place gives: +sorts the PROVIDERS, and the two tables keep their own orders. It is a chord and not a bare `s` for the reason the activity page gives: `s` is one of the commonest letters a filter starts with, and the list a person was narrowing would re-sort instead. @@ -2331,8 +2331,8 @@ to filter, `↑↓` to walk, `enter` to use it, `esc` to go back to the layer. **Press the space bar twice with an empty message box.** That is the way back to home from inside a conversation, and `/home` opens it too. -**There is also a number: `alt+1` (`opt+1` on a Mac).** Home is the first of the six places on -the top line, `home teams chats sessions spend settings`, and each answers to its position there, +**There is also a number: `alt+1` (`opt+1` on a Mac).** Home is the first of the seven main destinations on +the top line, `home chats AI teams activity memory spend settings`, and each answers to its position there, `alt+1` through `alt+6`. **`alt+7` and `alt+8` are kept**, on the two places that are off the bar — standing and memory — so those keys still open a room rather than doing nothing; `alt+.` draws them all with their numbers. `alt+2` is the teams page, and @@ -2365,7 +2365,7 @@ tasks, on standing orders and on memory alike: a foot that offered `enter` or `t filter` over a body with no rows would be naming a key with nothing to act on. There is no `ctrl+` chord for home: every one this surface could use is already -taken, and `ctrl+.` is the sessions place (`/history`) from a conversation — while a place is +taken, and `ctrl+.` is the activity page (`/history`) from a conversation — while a place is standing that same `ctrl+.` draws the map, on the terminals that can send it, because a place takes the whole frame and never reaches the conversation's keys. `esc` was not available either: on an idle conversation it already arms rewind and already clears messages waiting from the turn, and a third @@ -2623,7 +2623,7 @@ column's two words, and elsewhere they do nothing. **The walk stops at this conversation's last job, after its last task.** The roster holds this conversation's work, then the jobs section under it, so `↓` walks both and clamps at the bottom rather than carrying on into the project's record. Old tasks from earlier -sessions are on the sessions place, reached from the column's own `ctrl+. earlier` line, from +sessions are on the activity page, reached from the column's own `ctrl+. earlier` line, from `ctrl+.` or from `/history`; `enter` on an `earlier` row there goes inside that task's card. In a directory whose earlier sessions ran tasks but where **this** conversation has run none and started no jobs, `alt+t` falls through: there is nothing on the column to @@ -2751,7 +2751,7 @@ A program's task page — senior-dev's — opens on the actions it took, each un of its process. **`ctrl+y` turns it to the raw calls** it made to its model: what it sent, what the model answered, which model it was, and the call in flight. `ctrl+y` again turns it back. It works in the program's room, whichever door opened it — its row, its card, or -the sessions place — and the key row names it: `ctrl+y calls` over the actions, `ctrl+y actions` +the activity page — and the key row names it: `ctrl+y calls` over the actions, `ctrl+y actions` over the calls. Every page opens on the actions. It is a chord, so it never costs a character: the room's box keeps what you typed. It is @@ -2820,7 +2820,7 @@ or a part of one it handed out itself: whole of it, and no key on this surface ever does something that is not drawn on the screen in front of you. Where a landing draws each answer on a line of its own — the one road that does, because its `[a]` moves files of yours — `d you decide` is on it. The standing -choice is `task.settle` in `/settings` under Session, and it is the right place for it: a +choice is `task.settle` in `/settings` under Tasks, and it is the right place for it: a letter that hands one landing over changes nothing about the next one. While `task.settle` is `auto` the reason row also reads `codeaf is deciding`; the answers @@ -3104,8 +3104,7 @@ press a key or press `ctrl+s` again. While the pointer is out, the row under the box reads exactly `drag to select · any key ends it`. That line is how you tell this apart from everything else here. -**The `ui.mouse` setting is off.** It is **on** by default — `/settings`, the Display -section, the row labelled `mouse`. With it off, codeaf never asks your terminal to report +**The `ui.mouse` setting is off.** It is **on** by default — `/settings`, the General category, the row labelled `mouse interaction`. With it off, codeaf never asks your terminal to report the pointer at all: no hover, no click, no wheel, and your terminal keeps drag-select permanently. `/select` then says `your terminal already has the pointer — drag to select.` @@ -3397,11 +3396,11 @@ This holds however the row was changed — in the panel, or by asking codeaf to `change_setting`. The row is written straight away and the transcript says so; the screen picks it up when the turn ends. -The **background after** row is different. Its key is +The **background shell commands after** row is different. Its key is `bash.background_after_seconds`, and the session engine and the visible countdown arm from it together at launch. A change lands on the **next session**, not at the end of the current turn. Because this row controls how hard the machine may be worked, -`change_setting` refuses it; open `/settings`, choose the Safety tab, and change the row +`change_setting` refuses it; open `/settings`, choose the Tasks category, and change the row yourself. With `ui.mouse` off, every drag belongs to your terminal permanently, and `ctrl+s` has diff --git a/internal/manual/chat/lanes.md b/internal/manual/chat/lanes.md index 21a060cbb1..0014c32b50 100644 --- a/internal/manual/chat/lanes.md +++ b/internal/manual/chat/lanes.md @@ -334,7 +334,7 @@ What that means, exactly: - **the line stays in the conversation.** It is not one of the dim retry notes the work chip collapses when an answer lands, so it is still on the screen after the turn finishes; -- **your settings row is not touched.** The `host` row on the Providers tab still reads +- **your settings row is not touched.** The `host` row on the Models category still reads `pinned: coreweave`, exactly as you wrote it. What changes is everything that names the host **requests are going to**: the `@coreweave` comes off the model's name, the tail on the `your model` row reads `auto (coreweave cannot serve this model)`, and the fold's @@ -511,7 +511,7 @@ The table above is the whole answer, and **`response.attempts` is the one thing you can turn about it**. It is a multiplier on those times, not a number of requests: `3` means three times as long — four and a half minutes on a turn you are watching instead of ninety seconds — and the default is `1`, which is exactly -the table. Set it on the **Providers** tab of `/settings`, or with +the table. Set it on the **Models** tab of `/settings`, or with `CODEAF_RESPONSE_ATTEMPTS`. ``` @@ -926,7 +926,7 @@ model id is already the whole route. ## How do I stop codeaf choosing the host itself — the simple routing mode, OpenRouter's default routing, and what my pinned host still sends -The `routing` row (`/settings` → **Providers**) has a fourth answer, **`simple`**, +The `routing` row (`/settings` → **Models**) has a fourth answer, **`simple`**, for exactly this. Under it codeaf keeps no opinion of its own about the hosts behind your model, and sends none: diff --git a/internal/manual/chat/making-pictures-audio-and-video.md b/internal/manual/chat/making-pictures-audio-and-video.md index 8356093fbe..5c2ace515e 100644 --- a/internal/manual/chat/making-pictures-audio-and-video.md +++ b/internal/manual/chat/making-pictures-audio-and-video.md @@ -133,7 +133,7 @@ as ever, and the next call without the argument rides the default again — a one-call choice never changes any setting. The result line always names the model that actually generated the file, so you can tell which one made what. -This is the same freedom you have yourself in `/settings` → Providers, handed +This is the same freedom you have yourself in `/settings` → Models, handed to codeaf per call: ask it to "draw this one with gemini" or "try the best image model" and it can, just in time. @@ -185,7 +185,7 @@ expansion shows, e.g. `Image generation failed (bytedance-seed/seedream-5-0-pro) To change which model draws, just say so: "draw this one with gemini", "use the best image model". That is the `model` argument on the tool, described in the section above, and it changes nothing permanently. For a new default, use -`/settings` → Providers. +`/settings` → Models. ## Why does a picture or video look generic, blurry, or like AI slop? @@ -268,7 +268,7 @@ with the path, the file size and the model, e.g. you asked for a particular voice, because a voice the model does not have is a failed generation rather than a near miss. -With no speech model set in `/settings` → Providers, the default is +With no speech model set in `/settings` → Models, the default is `fish-audio/s2.1-pro`, falling back to `fish-audio/s1`, then `hexgrad/kokoro-82m` and then `openai/gpt-4o-mini-tts` on a catalog that does not advertise it. A model you set yourself wins over all of them. @@ -366,7 +366,7 @@ note simply omits both rather than guessing. A render that fails says so the same way: `job 3 failed: video generation timed out (); no video was saved`. Nothing waits for it and nothing polls it. -With no video model set in `/settings` → Providers, the default is +With no video model set in `/settings` → Models, the default is `bytedance/seedance-2.5`, falling back to `bytedance/seedance-2.0-mini` on a catalog that does not advertise it. A model you set yourself wins over both. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 02e608cb8c..28b4676133 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -215,7 +215,7 @@ Now you pause and press it once. your query. `↑`/`↓` do not count as editing. `tab` always opens and closes. **This is one list with two doors.** `/model` opens it, and so does the **your model** -row at the top of the Providers tab in `/settings` — the same rows, the same name +row at the top of the Models category in `/settings` — the same rows, the same name search, the same providers under `→`, and `enter` on a provider pins it either way. The media slots on that tab (**drawing**, **speaking**, **looking** and the rest) open the same component over their own models, but they have no provider row behind them, so nothing @@ -537,7 +537,7 @@ whenever one serves the model. The crew is three seats because those are the calls a task spends most of its money on. codeaf also makes smaller calls on your behalf — naming a session, the safety gate, memory -— and those ride two rows of their own in `/settings` → Providers, **reflex** and +— and those ride two rows of their own in `/settings` → Models, **reflex** and **small work**, which ship pointed at near-free models and are not part of the crew. **`/model` is untouched.** It is the model you talk to, and nothing about the crew moves @@ -679,7 +679,7 @@ that seat through OpenRouter even when a direct connection also serves the model the rule would have to break is not a pin. And a pinned model none of your connections can reach is not quietly swapped: the task does not start, and says why. -In `/settings` → Providers the three seats are one row, **seats**, which says how many are +In `/settings` → Models the three seats are one row, **seats**, which says how many are pinned, the allowed rule, how many providers are on, the per-task limit and the daily cap; `enter` on it opens the crew panel, and `esc` there brings you back to the row. @@ -903,7 +903,7 @@ is not touched. A family of ### The roles under each row -Directly under the **pinned roles** row, `/settings` → Providers lists **every registered +Directly under the **pinned roles** row, `/settings` → Models lists **every registered role**, grouped under the row answering it, saying which model comes out: | role | row | what it is | @@ -1101,7 +1101,7 @@ asks for high thinking separately. - Where a level is set, the role rows print it after the id, `kimi-k3:low`, which is the same notation the model picker and `/status` use. -The **planner** row in `/settings` → Providers is a text box because a picker hands back a +The **planner** row in `/settings` → Models is a text box because a picker hands back a bare id, while this row may hold an id with a thinking instruction on it. ## Why is my crew thinking at low — the pin is being ignored, effort=low in the log @@ -1143,7 +1143,7 @@ without any one message reading as three times the price of its neighbours. ## Which model the vision role uses -For `vision`, the **looking** row further down the Providers tab is the front door +For `vision`, the **looking** row further down the Models category is the front door for which model sees, and it wins over this role's class. The `vision` pin is the second rung of that ladder — set the looking row for the ordinary case, and pin the role only when you want a pin that also binds the older surfaces. @@ -1172,11 +1172,11 @@ on the first row of the page instead, above the chips. It is moved, not dropped header sheds first is the goal, which you can still read in the conversation. The nodes under the planner run on the `worker` role, which sits on the crew's **worker** — -a different model, and not on this line. `/settings` → Providers lists both. +a different model, and not on this line. `/settings` → Models lists both. ## Pinning one role to its own model, and unpinning it -In `/settings` → Providers, move onto any row of the roles list and press **enter**. That +In `/settings` → Models, move onto any row of the roles list and press **enter**. That opens the model picker — the same one `/model` opens, same filter box, same ranking — and what you choose is **pinned** to that role alone. The row then reads ` pinned`, and the legend at the foot offers **del unpin**. Press @@ -1193,9 +1193,9 @@ and pinning from the list rewrites the row without disturbing the other pins in **A third door: just ask.** "Use `deepseek/deepseek-v4-pro` for planning and for designing harnesses" is a sentence codeaf acts on — it looks the row up with `settings` and writes it with `change_setting`, into the same `models.roles` row, after asking you. The reflex and -small work rows of the Providers tab, and the crew's worker, checker and planner pins behind +small work rows of the Models category, and the crew's worker, checker and planner pins behind its one **seats** row, are writable that way too — a pin outside your allowed models is -refused there as it is on `/crew`; only the role **slots** further down the Providers tab are not, +refused there as it is on `/crew`; only the role **slots** further down the Models category are not, because those are bindings the running session holds rather than values in your profile. So the ladder for any role, most specific first: **its pin**, then **its row's model**, @@ -1605,7 +1605,7 @@ number of tries (see *How long codeaf keeps trying*). **Sometimes it moves after two attempts instead of three.** Three attempts are worth making only when they can reach *different* endpoints. If the stream died before naming -which endpoint served it, or you have set `routing` to `off` or `simple` on the **Providers** tab, then +which endpoint served it, or you have set `routing` to `off` or `simple` on the **Models** category, then nothing is being routed around and the next attempt lands in exactly the same place — so codeaf stops asking and moves to the next model a try earlier. Setting `routing` to `off` or `simple` switches off **endpoint** steering; it does not switch off moving to another model. @@ -2174,7 +2174,7 @@ came back with nothing in it at all. Its row in the provider fold then reads `ba an hour, and every usable answer it serves afterwards walks it back up. Recovery is by serving properly, which is the only evidence there could be. -**Turning it off.** The row is `reply guard` on the **Providers** tab of `/settings`, `on` +**Turning it off.** The row is `reply guard` on the **Models** category of `/settings`, `on` or `off`, and the default is **on**. Off means you see whatever arrives, and keep whatever you stop. You can also just ask codeaf to turn it off; it is not one of the rows it refuses. The two clocks in the @@ -2205,7 +2205,7 @@ reply arrives (*The model was printing garbage* above), so only the text that ha stopped being language is dropped — and the line above is the only time you are told, which is how you can tell the two apart. -**It does not happen at all with the guard off.** `reply guard` on the **Providers** tab of +**It does not happen at all with the guard off.** `reply guard` on the **Models** category of `/settings` is `on` by default; set it to `off` and you see, and keep, whatever arrives — including whatever you stop. @@ -2244,7 +2244,7 @@ what a tool call looks like gets you an honest answer full of exactly this synta that merely names a tool is safe: the markup has to be the substance of the reply, not a word in a sentence. And a conversation with no tools available cannot trigger it at all. -**The switch is the same one.** `reply guard` on the **Providers** tab of `/settings` +**The switch is the same one.** `reply guard` on the **Models** category of `/settings` turns this off together with the repetition guard above; off means you see whatever arrives. @@ -2600,7 +2600,7 @@ name on the row. A thing neither of them knows keeps its id. **`enter` on any row under "by topic" or "by standing order" opens the thing itself**, and the foot says so on every row that is a door — `enter opens what spent it`. The headings said it too for a while; the heading over the cut is the control that swaps cuts now, and -a control with an unrelated instruction after it is two objects on one line. A task opens **its own record card** in the sessions place, with the list +a control with an unrelated instruction after it is two objects on one line. A task opens **its own record card** in the activity page, with the list behind it parked on that row; a standing promise opens the standing place **on that order**; and a conversation **opens** — brought forward if this terminal already has it, otherwise opened beside the one you are in, with all of that door's refusals (a folder that @@ -2647,7 +2647,7 @@ Time is two questions, so it gets two arrow axes and no letters: The cut of the ledger is its own control and has nothing to do with time: walk onto the heading and `←`/`→` swap `by topic` for `by model`. The window opens on **the last 14 days, by the day**. The label between the arrows is the -reading and the control at once, and the same head row is drawn on the sessions place and the +reading and the control at once, and the same head row is drawn on the activity page and the standing place. A terminal too narrow to draw the control has no window there at all — the keys do nothing rather than moving something nothing on screen reports — and the zoom keys are bound only where `shift+↑ coarser` fits beside the arrows. A week buckets from Monday; there is no year rung, because a @@ -2970,9 +2970,8 @@ They live on **one tab**: `/settings` → **Spending**, which `/budget` opens di | **per plan** | `asks first above $100` | a planned job estimated above it quotes its step count and its price and waits for your go-ahead — it asks, it does not stop | | **per task** | `$5 a task` | an ordinary task's next priced call is not made; set in `/crew`. senior-dev has its own run ceiling | | **per standing run** | `$5 a firing` | that one firing stops there; each order may name its own | -| **practice** | `$50 of the day` | codeaf's practice on itself stops until tomorrow, and your own work is untouched | -Above those six the tab leads with **`today`**, which is a reading and not a setting: +Above those limits the category leads with **`today`**, which is a reading and not a setting: `$3.42 of $500 · resets at midnight`, or `$3.42 · no limit` on a machine with no daily limit. Before the first model call of the day it is **not on the tab at all** — a machine that has not spent anything has not spent zero. @@ -2989,24 +2988,21 @@ the conversation itself had spent, so the tab and the row a person pressed to ge disagreed while a task was running. It also counts a call whose receipt arrived after its turn ended, the same moment `today` does. -**Four of the six are rows you can edit** — `per day`, `per conversation`, `per plan`, -`practice`. `per task` and `per standing run` are **readings**: they are real rails, and +**Three are rows you can edit** — daily spending, per-chat spending and the plan approval threshold. `per task` and `per standing run` are **readings**: they are real rails, and neither is a number a settings row could hold. The sections below say why. -The rows used to be spread across two other tabs — the money on **Workspace**, the -conversation's own ceiling on **Session**. They are all on **Spending** now, and -**Workspace holds no money row at all**. +Open **Spending** for monetary limits. Resident practice settings are absent from chat settings because they do not control this chat surface. ## Where are the spending limits — the Spending tab, and every door onto it The settings panel's tab bar reads, in order: ``` -Session · Context · Workspace · Display · Spending · Safety · Tasks · Teams · Providers · Connections +General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Privacy ``` Money is on **Spending** and nowhere else. The rows that used to share it are on the two -tabs beside it: **Safety** is what codeaf may do without asking you first (ask before +tabs beside it: **Permissions** is what codeaf may do without asking you first (ask before running, tool exceptions, shell command rules, guardian, approval countdown, task countdown, who settles work that needs a look), and **Tasks** is how work you can walk away from is run (starting a task, check task work, task repair rounds, tasks at once, @@ -3196,7 +3192,7 @@ nothing and says, for a $5 limit, One model id is served by many providers, and they differ in two ways at once: how fast they answer, and what they charge. The published list price beside a model is the model's own figure — no provider is obliged to match it, and the fastest one often does not. -**Left alone, codeaf asks for nothing.** The **routing** row on the Providers tab ships as `simple`, and `simple` means the request carries no preference of codeaf's own: with no provider pinned there is no `provider` object on it at all, and OpenRouter's own default routing picks the provider. Pin a provider and that pin is the whole request — that provider, `only`, no fallbacks, and nothing else added to it. Nothing is ranked, nothing is capped, nothing is retired behind your back, and what the picker shows, what is chosen and what the record says are the same thing. +**Left alone, codeaf asks for nothing.** The **routing** row on the Models category ships as `simple`, and `simple` means the request carries no preference of codeaf's own: with no provider pinned there is no `provider` object on it at all, and OpenRouter's own default routing picks the provider. Pin a provider and that pin is the whole request — that provider, `only`, no fallbacks, and nothing else added to it. Nothing is ranked, nothing is capped, nothing is retired behind your back, and what the picker shows, what is chosen and what the record says are the same thing. It has not always been this way: until this build the shipped row was `latency`, and codeaf asked for the fastest provider on your own turns and the cheapest on work you were not waiting on. That choosing was invisible — the one decision in a turn you could not see being made — so it is now something you turn on rather than something you turn off. @@ -3258,7 +3254,7 @@ model you picked. codeaf calls one of those a **provider** — older builds called it a *lane*, and the setting on disk still does — and you can see them and choose one. -**Three rows on the Providers tab of `/settings` sit directly under **your model**, in +**Three rows on the Models category of `/settings` sit directly under **your model**, in that order** — **provider**, **speed guard**, **routing** — because the provider that serves your model is part of the same decision as the model: @@ -3592,7 +3588,7 @@ is kept, so a rescue is also a free measurement. ## Speed guard — what it costs and when to turn it off -**speed guard** is a row on the Providers tab of the settings panel, directly under +**speed guard** is a row on the Models category of the settings panel, directly under **provider** and two rows under your model, and it is **on**. It hedges **at most one extra call** per answer and stays under **a tenth** of what the @@ -3665,14 +3661,14 @@ probes. That file is the record of what was sent. **How to make it zero.** It is already zero on a home where nobody has touched **routing**: the shipped row is `simple` and it buys none of these. If you have set -`latency` or `price` and want it back to zero: Settings → Providers → **speed guard**, +`latency` or `price` and want it back to zero: Settings → Models → **speed guard**, off. The same row governs asking a second provider when an answer is slow to start, so turning it off stops both. `routing simple`, `routing off`, and a provider row set to `openrouter`, also stop it. ## The provider row in settings — auto, pinned, pinned but borrowable, openrouter -Settings → Providers has two rows under **routing**: +Settings → Models has two rows under **routing**: ``` your model deepseek-v4-flash @@ -3710,7 +3706,7 @@ row drops its `auto (cloudflare now)` tail rather than name a provider nobody ch Every request in a conversation re-sends the whole conversation. What keeps that from costing a fortune is the **prompt cache**: the provider that answered you a moment ago still has those tokens, and re-reading them costs a fraction of sending them fresh. The catch is that the cache sits on **one provider**. An provider that has never seen your conversation charges full price for all of it — measured on a real run, the same 94,000-token context cost **4.7 times more** on a cold provider than on the warm one, and that alone is where a quarter of the requests in that run ate half its money. -**This is something codeaf does under `routing: latency` and `routing: price`, and not under the shipped `simple` row.** Under `simple` the request carries no preference of codeaf's own at all, and asking for last time's provider is a preference — so keeping the cache warm is the router's business there, as the rest of the choosing is. The row is one word away if you want it: `/settings` → Providers → **routing**. +**This is something codeaf does under `routing: latency` and `routing: price`, and not under the shipped `simple` row.** Under `simple` the request carries no preference of codeaf's own at all, and asking for last time's provider is a preference — so keeping the cache warm is the router's business there, as the rest of the choosing is. The row is one word away if you want it: `/settings` → Models → **routing**. Under those two rows, codeaf remembers which provider answered your last request and **asks for that same provider first on the next one**. It is a preference, not a demand: if that provider is busy or gone, the request still goes through somewhere else rather than failing. Nothing extra is sent and nothing is probed to work this out — it is the name that came back on the last answer. diff --git a/internal/manual/chat/permissions.md b/internal/manual/chat/permissions.md index 727bc07c52..4330afd80a 100644 --- a/internal/manual/chat/permissions.md +++ b/internal/manual/chat/permissions.md @@ -298,7 +298,7 @@ chip for as long as anything is open: ``` `alt+y` raises the newest open question from **any page** — home, a room, the -sessions place — and takes you back to the conversation it belongs to. The count +activity page — and takes you back to the conversation it belongs to. The count includes the ones you folded: `esc` is later and not cancelled, so a question you put off is still a question the work is waiting on. @@ -553,7 +553,7 @@ wildcard and all — the dialect has no escape for `*`. ## The approval modes, and the default — does changing "ask before running" affect the conversation I am in `tools.approvalMode` decides what happens when the model asks to run a tool and -nothing more specific applies. It is in `/settings`, under **Safety**, on the row +nothing more specific applies. It is in `/settings`, under **Permissions**, on the row labelled **"ask before running"**. | value | what it means | @@ -700,7 +700,7 @@ legend above the message box names it after the thinking rung — `◇ asks`, The change is **live** — the next tool call is decided under it — and **sticky**: it is written into this session's `meta.json` and is still in force after `/resume`. It changes **this conversation only**; every other conversation -follows the "ask before running" and guardian rows on `/settings`' Safety tab. +follows the "ask before running" and guardian rows on `/settings`' Permissions category. What each posture is, in the gate's terms: @@ -898,7 +898,7 @@ The whole list, by settings key: work costs. - **How hard this machine may be worked** — `task.parallel`, `task.max_load`, `task.min_free_mb`, `bash.background_after_seconds`. The last is labelled - **background after** on `/settings`' Safety tab and arms both the engine's handoff + **background after** on `/settings`' Permissions category and arms both the engine's handoff clock and the countdown for the next session. - **Whether codeaf's own work is checked** — `task.audit`. A session that can switch off the check can call anything done. @@ -925,7 +925,7 @@ conversation's own model (`model.talk`) is changed with `/model`, and the other role slots — `model.plan`, `model.work`, `model.verify`, `model.scribe` — are bindings the running session holds rather than values in your profile, so neither `change_setting` nor the panel can write them. To send codeaf's own -auxiliary calls somewhere, set the reflex or small work row on the Providers tab, pin a +auxiliary calls somewhere, set the reflex or small work row on the Models category, pin a crew seat — worker, checker or planner — with `/crew pin` or on the `/crew` panel that the tab's one **seats** row opens, or pin one role in `models.roles`. @@ -1055,7 +1055,7 @@ Delete the key from your profile's `config.json`, or unset the variable, and the note stops. **What you can still turn off is the model's name.** The **model in commits** -row (`attribution.model`, on the Workspace tab of `/settings`), or +row (`attribution.model`, on the General category of `/settings`), or `CODEAF_ATTRIBUTION_MODEL=0`, leaves `Assisted-by: CodeAF` with no brackets. A landing on the worker harness, the default belt, carries that bare line either way. codeaf cannot change the row for you — ask it to and it says so and points you diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index f202986ace..272009cc27 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -2,25 +2,17 @@ ## What a place is, and the seven of them -A **place** is a full-screen room in codeaf that is not this conversation. There are seven, -and they are always in the same order: the words on the top line, then the two reached by -their command, with `chats`, the way back to your conversations, third on the line: +A **place** is a full-screen view in codeaf. The main bar groups your daily work first, then utilities: -`home` · `teams` · `chats` · `sessions` · `spend` · `settings` · `standing` · `memory` +`home` · `chats` · `AI teams` · `activity` · `memory` · `spend` · `settings` -**The top line draws six places:** `home teams chats sessions spend settings`. **Teams** is -right after home: your teams, what waits on you from them, and the selected team's manager -conversation (the **Teams page** of this manual has all of it). `chats` is not a room: it is -the way back to your conversations (*The `chats` word on the bar* below). Standing and memory are -places all the same — `/standing`, `/memory`, their digit, the map and the typed -box all reach them — and while you are standing in one its word is on the bar after the -six, so the bar always says where you are. +AI teams groups AI chats around ongoing work, with an optional AI manager. Activity shows work across chats and delegated tasks. Memory is always available, including before anything has been saved. Standing orders remain accessible through `/standing`, their shortcut, and the map; their label appears when that view is open. They are drawn on the **top line of every page**, right after the `codeaf` wordmark, on a place and in a conversation alike. This manual still calls that row of words **the bar**: ``` - >● codeaf home teams chats sessions spend settings 3 moving · $1.20 thu 10:31pm + >● codeaf home chats AI teams activity memory spend settings 3 moving · $1.20 thu 10:31pm ────────────────────────────────────────────────────────────────────────────────────────── ``` @@ -45,7 +37,7 @@ stacked: opening one closes whichever was up, so `esc` is always one press from ## The `chats` word on the bar: how do I get back to my conversation from a place -**`chats` is the third word on the bar, and it is the way back.** Click it, press `alt+3` +**`chats` is the second word on the bar, and it is the way back.** Click it, press `alt+3` (`opt+3` on a Mac), or walk the bar's cursor onto it and press `enter`: the place closes and the conversation that was in front before you opened it is in front again, with its draft and its scroll where you left them. With no conversation open at all, it opens the new-chat @@ -63,11 +55,9 @@ no count. Its hint says `every conversation, one at a time`, which is how it dif Four ways, and they all reach the same seven rooms: - **`tab`** — the next place **on the bar**, round again from the last. **`shift+tab`** — - the one before. From standing or memory, `tab` goes on round the bar to home. + the one before. The order follows the visible bar; Chats returns to the conversation. - **`alt+1`** … **`alt+8`** (**`opt+1`** … **`opt+8`** on a Mac) jump straight to one, **from a - place or from a conversation**. `alt+1` … `alt+6` are the tab bar's own order (home, - teams, chats, sessions, spend, settings), and `alt+7` and `alt+8` are the two places off the bar: - standing and memory. Hold `alt` and press the digit. macOS draws the modifier as `opt` + place or from a conversation**. Shortcuts preserve their established destinations even though the visual order changed: home (1), AI teams (2), chats (3), activity (4), spend (5), settings (6), standing (7), memory (8). Hold `alt` and press the digit. macOS draws the modifier as `opt` because that is the key's name on a Mac keycap; Linux and Windows draw it `alt+`, and it is the same chord either way. `tab` and the shift-arrows are not like them: in a conversation those already belong to path completion and to the caret, so the digits are the one class of @@ -106,7 +96,7 @@ empty room actually wants — see **Every place opens, always** further down thi That is true of every door onto a place and not only of the walk: a number, a click on the word, and the command that names it all open the same room on the same machine. `/history` -and a bare `/task` on a machine that has run nothing open the sessions place, headed `sessions` +and a bare `/task` on a machine that has run nothing open the activity page, headed `activity` over one line: `work you send off with /task lands here, and its record stays`. ## How do I move between the tabs with the arrow keys — the tab bar is a row the cursor can stand on @@ -188,7 +178,7 @@ does nothing; a press on the word you are already standing on does nothing. ## Where did the places go: the top line holds the places, and why chat tabs are not showing on the home page **The places sit on the top line, after the `codeaf` wordmark. The tab strip of your chats -is drawn only while a conversation is in front.** It is not on home, teams, sessions, spend, +is drawn only while a conversation is in front.** It is not on home, AI teams, activity, memory, spend, settings, or any other place. Chat tabs on the home page are not a thing this screen does. `chats` on the top line, `alt+k`, and home's sessions list are how you get to one. @@ -196,7 +186,7 @@ The places used to be a row of their own under the top line on a place, and the took that same row inside a conversation. For a while after that the strip stayed on every page, which repeated the teams rail and home's sessions. Now: -- **The top line** is the wordmark, the six places, and the machine's signs on the far end. +- **The top line** is the wordmark, the seven main destinations, and the machine's signs on the far end. It is identical on every page; only which word is lit changes. It never moves. - **The second line of a chat** is the tab strip: the team chip (` ● harbor ▾ `, or a quiet ` Teams ▾ `), `◆ Manager`, your tabs, `+` and `▦ All`. It has no `home` piece, since home @@ -255,7 +245,7 @@ six of the seven places that box was a lie in a slot: `enter` opened rows, typin went to the filter at the top of the list, and on spend the words went nowhere at all. It is gone (2026-09-17). -**Typing still filters where a list is worth filtering.** On **sessions** every printable key +**Typing still filters where a list is worth filtering.** On **activity** every printable key narrows the conversation list by conversation name, project name or path, or nested task name. Matching conversations keep their complete task trees. The letters draw on the control row at the top beside the `⌕` mark. On **memory** the head row echoes the filter in place of @@ -422,7 +412,7 @@ The last two classes are bound where there is something to bind. `alt+` `alt+s` on the memory place, which changes which shelf it is showing; home's panels have no second shape, so `alt+g` and `alt+q` do nothing there. `shift+` is a place's time window — `shift+←→` moves it by its own length, `shift+↑↓` changes how coarse it is — and -three places have one: **sessions** (when it ran), **standing** (when it fired) and **spend** +three places have one: **activity** (when it ran), **standing** (when it fired) and **spend** (which days). All three draw the same control on their own head row, at the right of the line: `shift+← aug 12 – aug 25 →`, with `shift+↑ coarser` beside it where the line has room. The label between the arrows is the control and the reading at once, so the span is on the @@ -518,8 +508,7 @@ Neither of them ever ends in `…`, and neither ever cuts inside a word. Press `alt+.` and the whole key map appears **in the cells you were already reading**: -- the tab bar's words grow their numbers: `1 home`, `2 teams`, `3 chats`, `4 sessions`, - `5 spend`, `6 settings`. The two places off the bar follow: `7 standing`, `8 memory`. +- the bar shows the established shortcut numbers beside each destination: `1 home`, `3 chats`, `2 AI teams`, `4 activity`, `8 memory`, `5 spend`, `6 settings`, followed by `7 standing`. Visual order does not change shortcut destinations. - the hint line becomes the chord list `?` over an empty box draws the same map, which is what that key means on a place — show me @@ -558,7 +547,7 @@ bullets on the conversation or task, with no separate `needs you` heading. ## teams: your teams, what waits on you, and each team's manager -The second place on the bar, right after home. On the left is a **rail**: your teams as a +The AI teams destination follows Chats on the bar. On the left is a **rail**: your teams as a tree (a sub-team indented under its parent), each with its colour, and a mark only when something is happening in it: a dim `⠿` while one of its members is working, and an amber `? 2` while two things wait on you. The sidebar keeps `+ New team` after the teams list and a `Show closed · N` toggle at its bottom. @@ -568,7 +557,7 @@ cards. Decisions and permissions remain actionable, and exchanges expand inline. team selected. Selecting the team itself leaves the current chat and draft untouched. `/teams`, `alt+2` and a click on `teams` open it. The **Teams page** has the details. -## sessions — the sessions page for tasks and history, and how to get to it without a command +## sessions — the activity page for tasks and history, and how to get to it without a command The full-screen conversation tree groups every chat and its tasks into **running** and **completed**. A conversation stays under running while it is answering or has running, @@ -756,12 +745,7 @@ the line): 6. and then the day's figure itself. **The count of things waiting on you never goes.** `2 ?` outlasts the money and the places, -at every width. At 80 columns all six places fit beside `2 ? · $1.20 / $20`. At 60, with -nothing waiting on you, they read - -``` - >● codeaf home teams chats sessions more ▾ $1.20 -``` +at every width. With activity and spending to show, an 80-column bar keeps daily work and Memory visible, while Spend and Settings remain under `more`. The menu preserves their names and shortcuts. Wider windows show all main destinations; narrower windows progressively fold them while keeping the current destination visible. **`more ▾` is a door.** Click it and a small menu hangs under it listing exactly the places it folded, each with its key (`spend alt+5`, `settings alt+6`). The pointer lights a row and a @@ -789,7 +773,7 @@ do. It never says the place is empty. or any of the eight assigned digits, is a key that does nothing. On a machine codeaf was installed on an hour ago, `alt+4`, `alt+7` and `alt+8` all open: -- **sessions**, headed `sessions`: +- **activity**, headed `activity`: `work you send off with /task lands here, and its record stays` - **standing**, headed `standing orders`: `reminders, watches and routines · "remind me at 6" or "every morning at 9"` @@ -863,7 +847,7 @@ own remote stores when the engine supports them: |---|---| | **home** | the far machine's projects and conversations | | **teams** | the far machine's teams, their packets and their spend; the team card's settings rows are that machine's, and `Wrap up first` is offered only when its engine answers the wrap-up doors | -| **sessions** | the far machine's work, out of the same reading | +| **activity** | the far machine's work, out of the same reading | | **standing** | the far machine's orders — both what stands on this conversation and what stands anywhere else on that machine | | **settings** | this computer's rows; the sheet says the far conversation reads its profile on the other machine | | **spend** | the far machine's priced model calls | @@ -906,7 +890,7 @@ something true to say. **Yes — the far machine's work, and none of this one's.** -The sessions place reads its rows out of the same reading home lists, so the door that carried +The activity page reads its rows out of the same reading home lists, so the door that carried home carried this too. `/history`, `ctrl+.` and the tabs all open the same page. This was the worst of the seven before it crossed. The page walked *this* computer's diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md index ba11f42c38..7fcf5dd143 100644 --- a/internal/manual/chat/questions.md +++ b/internal/manual/chat/questions.md @@ -297,7 +297,7 @@ you, or `/autonomy decide`. **How do I turn that off:** `/autonomy puts it back to asking you every time, and `/autonomy` on its own shows every row as it stands. -**Open settings** (`ctrl+,` or `/settings`) and look under **Safety** for +**Open settings** (`ctrl+,` or `/settings`) and look under **Permissions** for `questions while you are away`. There is a row per kind of question, and `enter` walks the answer round three words: @@ -719,7 +719,7 @@ With more than one waiting it counts them instead — `? 3 questions · alt+y`. ratify line is never counted: nothing is waiting on it. The chip never takes the keys off the page you are standing on. A task that -raised a question while you were on the sessions place says so at the right of the +raised a question while you were on the activity page says so at the right of the foot, and the row's own `enter open its room` stays where it was. **`alt+y` brings the newest one back** and takes you to the conversation it @@ -835,7 +835,7 @@ minute has passed — that line is news, and coming back later is later. The decision itself is in the transcript and in `decisions.jsonl` for good. **A question you have NOT answered is still there when you come back**, wherever -you went — another tab, home, the sessions place. That is the point of leaving it: +you went — another tab, home, the activity page. That is the point of leaving it: nothing about the work moves while it waits, and the chip in the status line counts it from every page. @@ -1117,7 +1117,7 @@ have one is to close this and talk. ## Can I just let it decide — stop asking me this kind of thing **There is a row for it where settings are browsed.** Open settings (`ctrl+,`, -or `/settings`), go to **Safety**, and the section *questions while you are +or `/settings`), go to **Permissions**, and the section *questions while you are away* has one row per kind of question — permission, choice, judgement, landing, assumptions — each saying `ask me`, `recommend then go · 30s` or `decide yourself`, and `enter` walks it round the three. Searching settings for "away" diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index f1faa2be10..0d61e2c98e 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -3,14 +3,14 @@ ## Is there a newer version — update a dev build — keep a dev build up to date — latest dev — /update — why does it say this every time I start At launch, a stable, dev or staging build behind the newest release of its own -channel is told about it, and with `auto update` on (the default) the install +channel is told about it, and with `automatic updates` on (the default) the install begins on its own after a brief pause: the sentence `codeaf is out · installs in 10s · skip · /update` waits in the keys line, counted down from about ten seconds. It never blocks a keystroke. `/update` while it is up installs immediately — that is the release the offer is about, not a rollback; the skip chord, or `/update skip`, answers that one release, it is never offered again, and a later release is a new question; `/update never` turns -`auto update` off, and so does the `auto update` row on `/settings`' **Workspace** tab. Turning it off does not +`automatic updates` off, and so does the `automatic updates` row on `/settings`' **General** tab. Turning it off does not cancel an install already downloading, and the launch check still makes its one request and names what is out. A release whose automatic install failed three times stops being tried on its own. @@ -20,10 +20,10 @@ or ahead build gets nothing. Source and unstamped builds make no launch request, and their `/update` answers `this codeaf was built from source · rebuild with make build, or install a release: curl -fsSL https://agentfield.ai/get/codeaf | bash`. A codeaf owned by Homebrew, Nix or the system package manager, or one in a -folder this account cannot write, is never replaced in place: with `auto update` +folder this account cannot write, is never replaced in place: with `automatic updates` on or off the launch names the manager and the curl line, not `/update`. -With `auto update` off, the launch check still runs and still says `codeaf +With `automatic updates` off, the launch check still runs and still says `codeaf is out · you have · /update installs it for the next launch`, but nothing is downloaded until you ask. diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index 59ddf253c8..fb4b7a410a 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -460,13 +460,13 @@ exact sentence each one says. travels on the wire like every other answer; nothing about it needs a browser or a port. This is the one entry on the list that is a capability, not a limit. -5. **`/settings` opens anyway, and says whose rows these are.** Every tab but Teams writes - this machine. The Teams tab is saved on the other machine when that machine can take the +5. **`/settings` opens anyway, and says whose rows these are.** Every category but AI teams writes + this machine. The AI teams category is saved on the other machine when that machine can take the change. Opening on any other tab says exactly: - `these rows belong to this machine; the Teams tab is saved on the other one.` - On the Teams tab it says only what is true there: + `these rows belong to this machine; the AI teams category is saved on the other one.` + On the AI teams category it says only what is true there: `these rows are saved on .` - An older engine, where the Teams tab cannot be saved over the connection, says exactly: + An older engine, where the AI teams category cannot be saved over the connection, says exactly: `these rows belong to this machine; this conversation reads its profile on the other one.` ## More of what does not work over --host @@ -687,10 +687,10 @@ browser and no port, so that road stays open over `--host`. `/settings` opens over a connection. On any tab but Teams it says exactly: ``` -these rows belong to this machine; the Teams tab is saved on the other one. +these rows belong to this machine; the AI teams category is saved on the other one. ``` -On the Teams tab it says only what is true there: +On the AI teams category it says only what is true there: ``` these rows are saved on . @@ -703,13 +703,13 @@ these rows belong to this machine; this conversation reads its profile on the ot ``` The rows on every tab but Teams are this machine's, and those changes apply to what you -are looking at. The Teams tab is the other machine's when the connection can save it. +are looking at. The AI teams category is the other machine's when the connection can save it. **Asking codeaf to change a setting goes the other way.** `settings` and `change_setting` run inside the session, which is on the far machine, so they read and write that machine's profile, which is the profile the conversation actually obeys. So over a connection the two doors land in two different files: the panel edits this laptop, and -asking edits the machine the work is on. The Teams tab is the exception on the panel. +asking edits the machine the work is on. The AI teams category is the exception on the panel. It saves on the other machine, the same place asking would write a team default. ## Can I change the Teams settings on another machine, and where do team defaults go over a connection diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 83231e3e49..311a05d040 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -105,7 +105,7 @@ navigation from reading. The strip is that chat's row. Home, teams, sessions, sp settings and every other place do not draw it: ``` - >● codeaf home teams chats sessions spend settings $1.20 / $20 · thu 10:31pm + >● codeaf home chats AI teams activity memory spend settings $1.20 / $20 · thu 10:31pm openrouter price scrape Refactor the rail scope... Shipping the parser × + ▦ All ────────────────────────────────────────────────────────────────────────────────────────────────── ``` @@ -1187,7 +1187,7 @@ expire on time. ## Why the bottom rows of a long list look dimmer — faded, greyed out or washed out rows The last three rows of a list that runs on past the bottom of its window are drawn a step -fainter each, fading toward the background. It happens on the sessions place (`/history`, +fainter each, fading toward the background. It happens on the activity page (`/history`, `ctrl+.`) and on the task column. Home's panels never fade: each one folds inside itself with `N more` instead. @@ -1436,7 +1436,7 @@ The top line is the program's name and the places on the left, and the machine's signs on the right: ``` - >● codeaf home teams chats sessions spend settings 2 want you · 4 moving · $0.55 / $20 · thu 1:11pm + >● codeaf home chats AI teams activity memory spend settings 2 want you · 4 moving · $0.55 / $20 · thu 1:11pm ``` When there is not room for all of it, things give way **one at a time, in a fixed order** @@ -2389,7 +2389,7 @@ than the sentence it belongs to. ## Proper icons, missing icons, empty boxes, Nerd Font and the step icons setting The normal view uses the Font Awesome icons included in Nerd Fonts. Under -`/settings` → Display → **step icons** (`ui.icons`), `auto` chooses those icons +`/settings` → General → **tool icons** (`ui.icons`), `auto` chooses those icons unless terminal detection calls for plain symbols. Known console and locale limitations fall back; colour depth alone does not remove icons. @@ -3240,7 +3240,7 @@ Finished work sits under `Done`, which starts folded to its heading: press `Done `enter` on it, to open it, and it stays open for the session. A background job is not a finished task row: it lives in the `jobs` section, and its log -path is on the job's page. The full record of any task is on the sessions place (`ctrl+.`, +path is on the job's page. The full record of any task is on the activity page (`ctrl+.`, `/history`). ## Scrolling the task column: the mouse wheel over the sidebar, and the keys that walk it diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 258a386a00..faa085e6e5 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -178,7 +178,7 @@ A task handed to senior-dev wears its name as a badge wherever a task is named: same number in this conversation wears. - **The task strip**, the row of chips that stands in for the side list under 100 columns, wears `[sd]`. -- **The `@` list, the sessions place and home** — its list of work, a landing under +- **The `@` list, the activity page and home** — its list of work, a landing under `needs you` and a line under `since you left` — wear `[senior-dev]`, or `[sd]` where the row is short of room. The title is cut before the badge, and a `since you left` line cuts what the work came to first: `rewrite the auth middleware [senior-dev] · it…`. @@ -1116,7 +1116,7 @@ tool, the conversation list's task counts and every other codeaf window on the p see it, and a window that has the run's conversation open says it is being worked on. The conversation that started the run lists it once, by the number its rail shows. -**Another window can watch it, read-only.** On that window's sessions place the run's row +**Another window can watch it, read-only.** On that window's activity page the run's row stands under `running` with `another window` beside it, and `enter read it as it runs` opens the page the conversation that started it shows: senior-dev's actions under their steps, the line over them with the step, the spend, the calls and the time, and `ctrl+y` diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md index e30d4c9ac6..959c559eb1 100644 --- a/internal/manual/chat/services.md +++ b/internal/manual/chat/services.md @@ -16,7 +16,7 @@ Pick a row and answer its fields. A successful listed provider says `deepseek-direct is connected · 6 models`; one without a list says only `deepseek-direct is connected`. A provider with more than one billing door names the one it bound: `z-ai-direct is connected · coding plan · 4 models` or -`z-ai-direct is connected · pay-as-you-go · 10 models`. The Providers tab in `/settings` +`z-ai-direct is connected · pay-as-you-go · 10 models`. The Connections category in `/settings` then shows the provider, door, safe spelling of its key, region and order. The default provider remains first. With two or more providers, `/model` groups models by @@ -39,7 +39,7 @@ default provider does have a key, those calls keep using their configured models ## What model do I get after connecting a provider — why did my model change -A successful connection from `/connect`, or a reconnect from the Providers tab in +A successful connection from `/connect`, or a reconnect from the Connections category in `/settings`, moves this conversation onto that provider in the same moment. A plan door's first documented model wins. Otherwise codeaf uses the vendor's preferred model when the provider listed it or published no list, then the first model the provider listed. With no @@ -204,7 +204,7 @@ The default provider's model ids remain unchanged and unqualified. A model from provider is written `/`, such as `deepseek-direct/deepseek-v4-pro`. That first segment is how the conversation remembers where the model can be reached. With two or more connected providers, `/model` shows a dim -heading for each provider, default first, in the order shown in the Providers tab. A +heading for each provider, default first, in the order shown in the Connections category. A custom provider's heading is the name you gave it. The status line uses the same spelling: an unqualified default-provider id, and @@ -259,7 +259,7 @@ available spelling (`localhost-direct`, then numbered ones) and the connect line what it used. That name is the provider everywhere. It is the row's name in `/connect` and on the -Providers tab, the heading its models sit under in `/model`, and the first segment of +Connections category, the heading its models sit under in `/model`, and the first segment of every model id it serves, so a model on a provider named `homelab` reads `homelab/glm-5.3` and `/model homelab/glm-5.3` moves onto it. A refusal or a success names the provider by the name it was given; neither switches back to `custom`. @@ -270,7 +270,7 @@ becomes that first provider's edit door once one is connected and a `+ add a provider` row connects a new one; with none connected yet, **Custom OpenAI-compatible API** is the door onto the first. -On the Providers tab in `/settings` each custom provider is a row of its own. `enter` +On the Connections category in `/settings` each custom provider is a row of its own. `enter` opens its available actions. Custom providers offer refresh, rename, change key, and disconnect; rename reopens the address and name with their saved values. An empty key box keeps the saved key. A changed name is a rename: every model id already picked under the old diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 0b9147a11f..3dffdda771 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -899,8 +899,8 @@ are `enter choose · esc cancel`. Cancel changes nothing. A conversation that manages any active ordinary team cannot be deleted. After `delete`, the confirmation names every active team it manages: `This conversation manages . Assign another manager before deleting it.` Go to Teams, select each -named team, and use `Choose manager` to select an existing member. To use a new -conversation, first use `+ Add member`, then `Choose manager`. Return to Home, Chats or +named team, and use `Choose AI manager` to select an existing member. To use a new +conversation, first use `+ Add chat`, then `Choose AI manager`. Return to Home, Chats or Sessions to delete the former manager. Deletion never appoints a manager, creates a conversation or disbands a team. Tasks inside manager conversations can still be deleted independently. The optional global-manager conversation can be deleted without a replacement; diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md new file mode 100644 index 0000000000..a75e860cc8 --- /dev/null +++ b/internal/manual/chat/settings.md @@ -0,0 +1,79 @@ +# Settings + +## Find a setting — categories, search and advanced controls + +Open `/settings` or press `ctrl+,`. Settings is organized into General, Models, +Memory, Tasks, AI teams, Permissions, Spending, Connections and Privacy. Wide +terminals keep the categories beside the controls; narrow terminals show a +category bar. Left and right change category, up and down choose a row, and +Enter changes it. The pointer can select the same controls. + +Type to search across categories, including advanced controls and connection +services. Old setting names and configuration keys remain searchable. Clear the +query to return to browsing. Each category keeps uncommon controls behind +`Advanced`; search can find them without opening that section first. + +Selected-row help explains the setting. Scope and activation details appear +where known. A value controlled by an environment variable remains read-only; +change the variable at launch rather than trying to override it in Settings. +Escape backs out of an editor without applying its unfinished value. + +## Did the update reset my settings — old profiles and existing preferences + +No configuration migration is needed for the new categories. Existing profile +keys, saved values, project overrides and environment precedence keep their +meaning. The same reader, validator and writer still apply each registry setting. +For example, `show hints` presents the positive choice while preserving the +existing stored preference. Renaming or moving a control does not reset it. + +Some controls previously visible in chat settings belonged to the separate +resident: practice idle time, practice spending, arrival briefs and tenure. +They are omitted here instead of suggesting that changing them teaches this chat. +Their stored values remain intact. Internal model slots that this surface cannot +change are omitted; supported model controls remain in Models. + +## AI teams settings versus an individual AI team + +**AI teams** contains defaults for groups of AI chats, including questions to an +AI manager, turns started by team messages, spending and subteam behavior. It +does not configure human members or shared user accounts. A team may have no +manager, and a chat can belong to several teams. + +An individual team's own Settings controls its supported overrides. Global +defaults do not erase explicit team overrides. New-subteam budget share applies +when a subteam is created; it does not redistribute existing budgets. + +## Memory settings — saved versus active, inspect and forget + +**Memory → remember across chats** controls the saved memory preference. It +supports learning useful preferences and corrections from conversations without +requiring every memory to be dictated. Use `/memory` or `/memories` to inspect +saved memories; the memory page offers its supported correction and forgetting +controls. A setting being on is not proof that any particular fact was saved. + +Already-open chats keep the memory store they opened with. Restart the CLI to +apply a changed preference to those chats. Turning memory off does not delete +saved memories. Input history and unfinished drafts are separate Privacy choices. + +## Permissions, task timers and automatic work + +**Permissions** controls tool approvals and exceptions. Dangerous-command +exceptions still apply in permissive modes. An approval timer pauses when it +expires and keeps waiting; it does not silently approve a tool. + +The proposed-task start timer has a different consequence: it starts the proposed +work when it expires. Read each control's consequence before changing its duration. +**Tasks** contains task planning, review, repair and concurrency; machine resource +limits are advanced controls. A one-off task does not require an AI team. + +## Connect a service, change models or manage privacy + +**Connections** contains service sign-in and provider credentials, web search +configuration and advanced remote-connection or custom OAuth application settings. +Ordinary account connection does not require you to create your own OAuth app. +**Models** selects supported models and reasoning effort; advanced controls hold +routing, internal role overrides and context limits. + +**Privacy** separates input history, unfinished drafts, anonymous usage reporting +and Model Pool participation. Disabling one is not a promise that all the others +are disabled. Search accepts the old labels as well as the new organization. diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index c7707d7051..ecdddc7f6b 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -60,11 +60,7 @@ lives under this machine's codeaf state directory at `~/.codeaf/v3/ssh/` (moved `CODEAF_HOME`). The same **103-byte** socket-path limit applies there: a state path too long disables reuse only; the ordinary ssh connection still opens. -These network-dependent defaults are editable on `/settings`' **Workspace** tab as `ssh -reuse` (`300s`), `ssh heartbeat` (`3s`), `ssh missed heartbeats` (`3`), and `ssh traffic` -(`lowdelay`). They were on the Session tab and moved, because every one of them lands on -the next launch rather than on the conversation in front of you; nothing you saved moved -with them. Setting the heartbeat to 0 turns +These network-dependent defaults are under `/settings` → **Connections** → **Advanced**: `SSH connection reuse` (`300s`), `SSH keepalive interval` (`3s`), `SSH missed keepalives` (`3`), and `SSH traffic priority` (`lowdelay`). Existing saved values are preserved. They apply to new connections. Setting the heartbeat to 0 turns dead-link probes off; setting reuse to 0 stops keeping a connection after its channel closes. Whole-stream ssh compression stays off because it usually slows a LAN attach; large transcript frames compress themselves only when both codeaf builds support it. diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index 5dee0eb6a3..94a98137c8 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -157,7 +157,7 @@ does not repeat the request or change the transcript's presentation. ## Reading a note on a run task's room, and the room following the newest step A run's task opens the same task room as any other task: from its row on the side list, -from `enter` on its row in the sessions place, or from the run's tab. The room keeps up with +from `enter` on its row in the activity page, or from the run's tab. The room keeps up with its newest step, reading the task again every three seconds while it is queued or running, and follows it until you scroll up; scrolling back to the bottom resumes the follow, and a room on a settled task is a still page. Its box says `a note for this task`: type in it and @@ -174,7 +174,7 @@ step, so its room takes no note: `enter` there says `this task has finished` and words can go, and leaves them in the box. `x` is read only over an empty box: the moment there is a note to type, a letter is a letter. `x` raises the `Stop this task?` card before anything ends. On a task that has ended it is not offered and does nothing: it is a letter in -the box. `p` is always a letter in the room; a part is held from its row in the sessions place. +the box. `p` is always a letter in the room; a part is held from its row in the activity page. ## Typing while a task's room is still loading: the letters land in its box diff --git a/internal/manual/chat/task-rooms-after-restart.md b/internal/manual/chat/task-rooms-after-restart.md index ce7d2e75db..e861106abc 100644 --- a/internal/manual/chat/task-rooms-after-restart.md +++ b/internal/manual/chat/task-rooms-after-restart.md @@ -95,7 +95,7 @@ this task has finished — say it to main 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+.`, +The room is one of two doors onto old work. The other is the activity page (`ctrl+.`, `/history`), whose `enter` on a completed task's row opens a card with the task's report read off the same journal — and which says `its transcript is not on this disk any more` in the same case. If the room is empty, the card will be too; the file is the same file. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index a4d5dd7355..53f74101b9 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -521,8 +521,8 @@ because its row is read as a design and not as a task. ## Stopping the sizing call — the starting a task setting, making one worker the default `/task ` asks you nothing, and every answer here starts **one worker**. What the row -decides is only what is paid to find out how wide the work is. `/settings` → Session → -**starting a task**, or the `task.start` row: +decides is only what is paid to find out how wide the work is. `/settings` → Tasks → +**task planning**, or the `task.start` row: - **sized** — the default. One worker starts at once and the sizing call reads your brief **beside** it; where it finds independent parts in your words, they are weighed and @@ -1050,7 +1050,7 @@ handed to the model that has just written the answer and would be editing itself is set for them, they have no model at all, and this is the sentence that says so. **What to do about it.** Pin a planner with `/crew pin planner `, or pin the two roles on the -**pinned roles** row in `/settings` → Providers — they are called `markreader` and `handoff`, +**pinned roles** row in `/settings` → Models — they are called `markreader` and `handoff`, so the row reads `markreader:openai/gpt-5, handoff:openai/gpt-5`. Or run with `--one-model`, which settles them on the model you are talking to along with everything else. Either way the move still happens — a task that starts knowing only what you typed is better than an answer @@ -2059,7 +2059,7 @@ tree of who started whom; the task's own page (its kin line) and the task page h family. **A run's rows are on this column too.** Under the conversation that started a run, the -column draws that run's tree out of the sessions place's reading: one line per task (the +column draws that run's tree out of the activity page's reading: one line per task (the connector, the state mark and the fitted title) with `waits: ` at the end of a line held behind named work, and the run's own row ending in the dot row (*what are the dots next to a task?* has the cells). While a task's worker is on a step, its row spends one @@ -2384,7 +2384,7 @@ work, shapes the brief and starts it. The column's other section, `standing`, en ## What a bare /task does — /task with nothing after it opens the task page -**`/task` typed on its own opens the full-screen sessions place** — the same place `/history` and +**`/task` typed on its own opens the full-screen activity page** — the same place `/history` and `ctrl+.` open, holding this machine's conversations and their tasks. It used to print a one-line usage instead. It does not any more. @@ -2392,7 +2392,7 @@ The reason is the `+ /task` row at the foot of the task column: that row puts `/ your box before you have said what the work is, so a `/task` sent as it stands is asking the only question the word can answer with no brief behind it — *what work is there.* -**On a project that has never run a task it opens the page anyway**, headed `sessions` over one +**On a project that has never run a task it opens the page anyway**, headed `activity` over one line — `work you send off with /task lands here, and its record stays` — which is exactly what `/history` and `ctrl+.` do there too. @@ -2400,7 +2400,7 @@ what `/history` and `ctrl+.` do there too. still size, shape and start the work directly, with no proposal card in between and no extra question. There is no third form: `/task adaptive` is retired. -**There is still no `/tasks` command**, though `sessions` is the name of the PLACE `/history` +**There is still no `/tasks` command**, though `activity` is the name of the PLACE `/history` opens, `alt+4` and `tab` get there without typing anything. As a slash word the plural is not one this surface answers to; the two things a bare `/task` and a `/task ` do are the pair of errands a person has about tasks — go and look at the work, or give codeaf some. @@ -2416,7 +2416,7 @@ ctrl+. earlier ``` - **It is a door and not a note.** Press `ctrl+.`, or click that line, and the full-screen - sessions place opens with every task this machine has run on it, grouped by what you do + activity page opens with every task this machine has run on it, grouped by what you do next: `running` and `completed`. `/history` is the same page. - **It is drawn only when there is something behind it**, and never as `0 earlier` or any other count of nothing. There is only ever one such line. @@ -2433,11 +2433,11 @@ conversation's work into another one's without the column ever saying it had. Ev they offered is on the other side of the door, whole: every row, the filter, the cards, and `m` for the mention. -**Where old work is listed now:** the sessions place (`ctrl+.`, `/history`, or that line), and +**Where old work is listed now:** the activity page (`ctrl+.`, `/history`, or that line), and home (`/home`, or space twice on an empty box). The chat can also read the whole project record for you with its `tasks` tool; just ask. -**Running work in another codeaf window** is on no surface but the sessions place. An ordinary +**Running work in another codeaf window** is on no surface but the activity page. An ordinary task writes nothing into the project's file until it lands, so the window next door is the only place that work can be read from, and `/history` is the page that reads it. @@ -2484,7 +2484,7 @@ you need to know a chord to recover. See *The task bar disappeared* below. The choice is remembered. It is written to your profile the moment the column moves, as the `ui.task_column` setting, which also appears in the settings panel (`ctrl+,`) on the -Display tab as **task column**. A change made in the panel lands the next time codeaf +General category as **task sidebar**. A change made in the panel lands the next time codeaf starts; `alt+l` acts immediately and wins for this session. On the teams page the column starts folded, because that page has a rail of its own on the left, and `alt+l` there opens it for that visit only. @@ -2570,7 +2570,7 @@ server still has the jobs section to put a cursor on, so `alt+t` takes it. **The walk stops at this conversation's last job, after its last task.** `↓` walks the band, the groups and then the jobs section under them, and clamps there rather than carrying on -into the project's record. Old work is walked on the sessions place (`ctrl+.`), where `enter` +into the project's record. Old work is walked on the activity page (`ctrl+.`), where `enter` goes inside its card. The cursor follows the task, not the row, when a task moves from one group to another. If @@ -2625,7 +2625,7 @@ nothing else, so a task you ran last week, in a session you have closed, is nowh screen until you open this. **There is no `/tasks` command** — though `sessions` is what the PLACE this opens is called on -the tab bar, reached with `alt+4` or `tab`. `/task ` starts work; `/history` opens the same sessions place +the tab bar, reached with `alt+4` or `tab`. `/task ` starts work; `/history` opens the same activity page started — and so does a **bare `/task`**, which opens this very page rather than printing a usage line. The page is also reached from the one dim door line at the bottom of the task column, `ctrl+. earlier`, whenever the project has work this conversation never ran. @@ -3184,7 +3184,7 @@ originating conversation and available evidence. Press `esc` to return to the li ## Going inside an old task — see what a past task did, read a finished task's report, where is the story my task wrote -`enter` on any task row of the sessions place (`ctrl+.`, `/history`) that this conversation did not +`enter` on any task row of the activity page (`ctrl+.`, `/history`) that this conversation did not run **goes inside that task**. A click does the same on the first press. The task column carries no rows of old work — its `ctrl+. earlier` line is the door onto this page — so the page is where every old task is opened. @@ -3271,7 +3271,7 @@ one more dim line: ctrl+. earlier ``` -Click it, or press `ctrl+.`, and the full-screen sessions place opens. The column is left exactly +Click it, or press `ctrl+.`, and the full-screen activity page opens. The column is left exactly as it was: the page is somewhere you go and come back from, not a state the column enters. **There is exactly one such line, never two.** It is drawn when the record holds tasks @@ -4990,8 +4990,8 @@ it was going to be decided in is gone and nothing is going to finish that though ## Can codeaf decide on its own — can the chat decide on its own, stop asking me about tasks that need a look -Yes. The setting is **`task.settle`**, in `/settings` under Session as -`who settles work that needs a look`, and it takes two words: +Yes. The setting is **`task.settle`**, in `/settings` under Tasks as +`when task results need a decision`, and it takes two words: | Value | What happens when a task lands as `your call` | | --- | --- | @@ -5186,7 +5186,7 @@ stop, one word, wherever you reach it from. Pressing `x` on a run that has already finished does nothing but say so. -## The sessions place — tree lines, project column, folds, and the time-window keys +## The activity page — tree lines, project column, folds, and the time-window keys The **sessions** place lists conversations and their nested work across projects, under **running** or **completed**. The whole conversation moves together. Both sections and @@ -5344,7 +5344,7 @@ conversation is the way today. ## Preview a task without opening it — the record beside the list, seeing what a task did, and answering a task from the list with 1 and 2 -On a terminal **110 columns or wider** the sessions place splits: the list keeps the left, a dim +On a terminal **110 columns or wider** the activity page splits: the list keeps the left, a dim rule divides it, and the right is the record of whatever row the cursor is on. Nothing is opened and nothing is lost — walking down with `↑` and `↓` changes what the pane shows, and the list stays exactly where it was. @@ -5457,7 +5457,7 @@ come back exactly as you left them. **`esc` clears the filter first and closes the place second**, which is why the foot says `esc clear the filter` while one is on. -## The foot of the sessions place — x delete, row verbs and stopping work +## The foot of the activity page — x delete, row verbs and stopping work The foot names the actions available for the selected row. `enter` opens its conversation, task room or saved record. `→` opens its verbs. An expandable row diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 77d98a2da5..e7c3bd341a 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -42,18 +42,18 @@ team-agnostic conversations view instead, and the hint says so. ## Making a manager -The **teams page** (`/teams`, `alt+2`) offers `+ Manager` on a team that has none, and +The **teams page** (`/teams`, `alt+2`) offers `+ AI manager` on a team that has none, and `All teams` offers one manager over every team. Choose a team to talk to its manager beside the tree of teams. Managers cost nothing until you make one. A team's manager is pinned at the left of its tab strip and never scrolls away: -- **`+ Manager`**, a quiet button, while the team has none. A press starts a new conversation +- **`+ AI manager`**, a quiet button, while the team has none. A press starts a new conversation in the team's folder and makes it the manager. Its hint says so. Under 100 columns the button leaves the strip; Teams still offers manager controls. -- **Choose manager** on Teams assigns an existing member. To use a new conversation, - first use Add member, then Choose manager. The Chats dropdown and conversation grid +- **Choose AI manager** on Teams assigns an existing member. To use a new conversation, + first use Add chat, then Choose AI manager. The Chats dropdown and conversation grid offer no manager-assignment controls. The manager's place reads **`◆ Manager`**. Its tile in the team-agnostic grid keeps its ordinary title and position. The tab's hint names its team and title. `alt+m` goes to the @@ -159,7 +159,7 @@ over the conversation, and `esc` or `alt+l` takes it off again. Over `--host` teams and the manager work as they do locally. The teams and their Traffic are kept on the machine the conversations run on, and the window reads and writes them there, so the Traffic is the team's own and a manager set from the laptop is the one the far session -follows. Against a far machine running an older codeaf, `+ Manager` and the menus say managers +follows. Against a far machine running an older codeaf, `+ AI manager` and the menus say managers are not available over `--host`, and the column has no Traffic word. ## When a manager starts a member — team_start and approvals @@ -499,6 +499,6 @@ in the current team; ambiguous or missing history is not guessed. One conversation may manage a team and descendants of that team, while remaining an ordinary member of other teams. It cannot manage unrelated teams. The global manager is -separate from every ordinary team's manager. Choose manager or a team move refuses a +separate from every ordinary team's manager. Choose AI manager or a team move refuses a change that introduces conflicting responsibilities, naming the teams. Existing conflicts are retained until the person explicitly chooses replacements in Teams. diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md index cb9f59f793..3bf710ba8e 100644 --- a/internal/manual/chat/teams-page.md +++ b/internal/manual/chat/teams-page.md @@ -1,17 +1,15 @@ -# The teams page +# AI teams ## What the teams page is, and how to open it The **teams page** is where you run your teams: every team you have, what waits on you from them, and an overview of the team you choose. Member cards show activity and recent updates; -interaction links take you into the conversation where an exchange happened. It is the second -place on the tab bar, right after home: `home teams chats sessions spend settings`. Open it with -`/teams`, `alt+2` (`opt+2` on a Mac), a click on the word `teams`, `tab` from home, or the map +interaction links take you into the conversation where an exchange happened. It follows Chats on the tab bar: `home chats AI teams activity memory spend settings`. Open it with +`/teams`, `alt+2` (`opt+2` on a Mac), a click on `AI teams`, `tab` from Chats, or the map (`alt+.`). A number beside the word on the bar counts the decisions waiting on you that arrived since you last looked. -A team is a group of conversations you name, and a team's **manager** is a conversation that -runs it for you (the **team manager** page). The conversations view (`alt+v`) is where you see +An AI team is a named group of AI chats, not a shared account or a group of human users. A chat can belong to several teams. An optional **AI manager** is a chat that coordinates the group (the **team manager** page). The conversations view (`alt+v`) is where you see every open conversation at once; All teams on this page is where you organize your teams. ## Where a team link in a chat opens @@ -32,7 +30,7 @@ The left column is the **rail**: ● harbor ◆ ? 1 │ ● orbit ⠿ │ ● docs │ - + New team │ + + New AI team │ │ ☐ Show closed · 2 │ ``` @@ -47,8 +45,8 @@ The left column is the **rail**: working; an amber `? 2` says two things wait on you from it or from a team under it: a decision addressed to you, or a member stopped on a question only you can answer. A team where nothing is happening draws no mark at all. -- **`+ New team`** stays visible below the active team list and always creates a top-level team. - **`+ Add subteam`**, beside and after **`+ Add member`** in a selected team's header, +- **`+ New AI team`** stays visible below the active team list and always creates a top-level team. + **`+ Add subteam`**, beside and after **`+ Add chat`** in a selected team's header, creates inside that team. The depth limit still applies. - **`✦ Organize`** in the All teams header suggests groupings and disbanding quiet teams. Its dialog stays on Teams; nothing changes until Apply. - **`Show closed · N`** is a toggle at the bottom of the rail. Turn it on to include retained @@ -152,7 +150,7 @@ for a remote conversation. ## Adding and removing team members -`+ Add member` on the selected team's overview opens a searchable conversation picker. +`+ Add chat` on the selected team's overview opens a searchable conversation picker. It lists existing saved conversations across projects, including ones already in other teams; members of this team are left out. Type a title or workspace to filter, use arrows and `enter`, or click a row. Adding an existing conversation keeps its context, other @@ -169,7 +167,7 @@ with `x`. Before permanently deleting its conversation, assign another manager i for every active team it manages. There is no member Actions menu. Removing the reporting membership leaves that conversation independent; another manager is never assigned automatically. -`a` opens Add member. The picker uses `Name` and `Project` columns and does not display IDs. +`a` opens Add chat. The picker uses `Name` and `Project` columns and does not display IDs. ## Recent interactions: scrolling, expanding replies and opening their conversations @@ -215,7 +213,7 @@ no message box. To talk to the manager, click its card and type in its normal ch Selecting a team no longer opens or locks its manager's conversation. The overview can be read without resuming any member. Clicking a member uses the ordinary Chats door, which -says any refusal there. If a local manager's conversation is missing, `+ Manager` offers a +says any refusal there. If a local manager's conversation is missing, `+ AI manager` offers a new manager. Starting a manager opens its fresh conversation in Chats immediately. ## What does +4 idle mean on a team @@ -310,7 +308,7 @@ Teams owns its keyboard. Typing here does not edit a manager's draft. | `m` | Move into… | | `space` on a team | pick it for a move of several | | `p` | member list | -| `a` | Add member picker | +| `a` | Add chat picker | | `M` | start a manager when offered | | `d` | confirm permanent team deletion | | `u` | Undo a move while offered | @@ -385,7 +383,7 @@ it does not reopen disbanded teams. Over `--host` quiet-team suggestions are not The Global manager card offers its optional creation button before the first team exists. Below it, the page says what a team is in one sentence and offers two buttons: **`✦ Organize my conversations`**, which suggests teams from the conversations you have open, and -**`+ New team`**. `o` and `n` press them. +**`+ New AI team`**. `o` and `n` press them. ## Over --host: why a closed team's report is not readable @@ -422,9 +420,9 @@ The confirmation lists them. History stays read-only with Show closed; there is ## New team placement and removing a member -The Teams sidebar keeps `+ New team` directly after the displayed teams list. `Show closed` -remains at the bottom of the sidebar. `+ Add member` and `+ Add subteam` stay together -on the left of the selected team's header, followed by `Choose manager`; `Settings` and `Disband` sit on the right. +The Teams sidebar keeps `+ New AI team` directly after the displayed teams list. `Show closed` +remains at the bottom of the sidebar. `+ Add chat` and `+ Add subteam` stay together +on the left of the selected team's header, followed by `Choose AI manager`; `Settings` and `Disband` sit on the right. A member card's `x` opens a confirmation naming the member and team. `cancel` is selected by default; choose `yes` to remove only that team's membership. Its work finishes and its @@ -432,7 +430,7 @@ conversation remains. Removing the manager requires replacing it first. ## Create an empty team or select several conversations -`+ New team` opens a creation dialog over Teams, keeping the overview behind it. +`+ New AI team` opens a creation dialog over Teams, keeping the overview behind it. `+ Add subteam` uses the same dialog with the selected parent named in its title. Enter Name, choose Colour, and optionally select members from the searchable Name/Project list. Click several rows to toggle their checkboxes; filtering preserves hidden selections. `tab` @@ -441,7 +439,7 @@ cancels. No internal IDs are shown. A duplicate name or invalid parent is refuse You may create an empty team and add members afterward. Creation never assigns a manager or opens its members. Both routes land on the new team overview. A failed save is reported there. -Chats also offers `+ New team` in its full saved-conversation grid: select cards, then name +Chats also offers `+ New AI team` in its full saved-conversation grid: select cards, then name and create. The full conversation grid itself lives only in Chats. ## Keyboard hints in team menus @@ -449,21 +447,21 @@ and create. The full conversation grid itself lives only in Chats. Team menus show grey instructions together at the left of the box footer, with the key first: `up/down move · space select · esc cancel · enter create` in New team and Add subteam. Narrow boxes wrap the instructions rather than hiding them. Only text fields, -colour, and members are tab stops; the creation hint stays in that footer. Add member and -Choose manager show `up/down move · enter choose · esc cancel`; creating a new conversation +colour, and members are tab stops; the creation hint stays in that footer. Add chat and +Choose AI manager show `up/down move · enter choose · esc cancel`; creating a new conversation shows `enter create · esc cancel`. Settings, Move, Organize, and the Chats team-naming card use the same key-first format. Clickable action hints act on the same choices as their keys. -## Choose manager — change a team's manager from existing members +## Choose AI manager — change a team's manager from existing members -`Choose manager` in the selected team's header opens a filtered Name/Project list of its +`Choose AI manager` in the selected team's header opens a filtered Name/Project list of its existing members, excluding the current manager. `cancel` is selected by default. Choose a member with Enter or click; Esc cancels. Only this team's manager changes. The former manager remains a member, and both conversations keep their work and history. The picker cannot create a conversation or add membership. All teams also offers -`+ Add member` for adding a global-manager candidate; automatic memberships stay protected. For a new manager, first use -`+ Add member` to add or create its conversation, then use `Choose manager`. +`+ Add chat` for adding a global-manager candidate; automatic memberships stay protected. For a new manager, first use +`+ Add chat` to add or create its conversation, then use `Choose AI manager`. An ordinary team manager cannot be removed or permanently deleted while it manages an active team. The deletion confirmation names every active team it manages and says `Assign another manager before deleting it.` Replace it in every named @@ -476,7 +474,7 @@ manage an unrelated team. Managing sibling teams requires also managing their co ancestor. The global All teams manager is a separate conversation; it cannot also manage an ordinary team. Ordinary membership in multiple teams remains allowed. -Choose manager and team moves refuse changes that introduce conflicting responsibilities, +Choose AI manager and team moves refuse changes that introduce conflicting responsibilities, naming the affected teams. Replacing a parent manager can also be refused if it would leave the former manager managing sibling teams without a managed ancestor. The refusal names the subteam managers to assign first, before changing the parent manager. Older conflicting assignments remain visible and can be repaired @@ -489,7 +487,7 @@ missing, its card says `Conversation unavailable`; add or select a replacement e A blocked deletion names every active team the conversation manages. In a short terminal, scroll the message with the mouse wheel or Page Up/Page Down. Cancel/delete and the -keyboard hints remain visible. Choose replacements through Teams → Choose manager first. +keyboard hints remain visible. Choose replacements through Teams → Choose AI manager first. ## Global manager — create, delete and recreate @@ -510,8 +508,8 @@ manage an ordinary team; conflicting old assignments must be repaired before del Without a global manager, the card shows only `Optional · coordinates the managers of top-level teams` and `+ Global manager`. This same state returns after deletion and when the saved manager conversation is missing. With a manager, the creation button disappears; -`+ Add member` and `Settings` remain alongside the conversation's `x` delete control. -`Choose manager` is unavailable for the global role. `Settings` retains global spending controls. +`+ Add chat` and `Settings` remain alongside the conversation's `x` delete control. +`Choose AI manager` is unavailable for the global role. `Settings` retains global spending controls. ## Model names on manager and member cards diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index f409e0e286..974baa2558 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -206,7 +206,7 @@ change it.** If the foreground command is still running then, the same process is kept as a background job; the call returns its output so far and a job id, and the chat moves on. Nothing is killed or restarted. -The row is labelled **background after** on `/settings`' Safety tab; its key is +The row is labelled **background shell commands after** in `/settings` → Tasks; its key is `bash.background_after_seconds`. The session engine and the visible countdown arm from it together at launch, so a change applies to the **next session**. It is a machine brake, so the model's `change_setting` tool refuses to widen it; change it yourself in the panel. @@ -1000,7 +1000,7 @@ settings rather than keeping a launch-time copy. With `search.provider` on `auto`, an Exa key raises the next search to `exa`; a Firecrawl key keeps the provider named `firecrawl` and raises that provider's ceiling, so the visible change is `firecrawl · with your key`. The selected -**searching** row in `/settings` → Context states the current answer. With no +**web search provider** row in `/settings` → Connections states the current answer. With no keys it says `now firecrawl, keyless — set search.exaKey or search.firecrawlKey to raise it`. diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md index 49f7d03e24..6dfb0cfbf7 100644 --- a/internal/manual/chat/what-i-remember.md +++ b/internal/manual/chat/what-i-remember.md @@ -589,7 +589,7 @@ quarantine — grouped by how far each one's truth reaches, and answers with at true now in its place. **What it costs.** One call on the **small work** class — the `consolidate` role -in `/settings` → Providers — a few times a day at most. It spends under the same +in `/settings` → Models — a few times a day at most. It spends under the same daily budget as everything else that runs in the background, and it is the first thing a spent day stops paying for. @@ -710,7 +710,7 @@ reading as three times the price of its neighbours. `/cost` also names how many of those paid requests were empty at their ceiling. You can point that row at a different model — the **reflex** row in -`/settings` → Providers — or pin the +`/settings` → Models — or pin the `reflex` role by itself under `pinned roles`. A change is live: the next turn's pair uses it. diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 3624ff0ee5..d719bf6b25 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -159,7 +159,7 @@ or the model; the task's room does, where the store's spend has them. The parts row each, the finished ones included. Every one of those rows is a door: click the run's row, or select it and press `enter`, and its room opens over the conversation; click a part's row or a check's row and THAT task's room opens. It is the -room the sessions place opens and the run's tab opens: what the task was asked, its steps, +room the activity page opens and the run's tab opens: what the task was asked, its steps, its notes, and the box that leaves a note. A task handed to a program such as senior-dev opens its own program room instead (see *A program's task page is a conversation, not steps*). `esc` goes back to the conversation exactly as @@ -283,7 +283,7 @@ to the column's width; the glyph and the `$` are never spent on it. **The line is there only while a step is in flight.** A task that has not started, one held behind named work, and one that has landed all draw their ordinary row and no live line — the store clears the step the moment its command ends. These rows are a run's **plan rows**, drawn -in the sessions place (`/history`, `ctrl+.`, `alt+4`, and the roster raised over the frame), not +in the activity page (`/history`, `ctrl+.`, `alt+4`, and the roster raised over the frame), not on the always-on column, which draws this conversation's own tree. ## What a run task's room shows while it runs @@ -332,7 +332,7 @@ messages — say it to main`, or ` reads no more messages (…)` with i `enter` over a sentence says the same line and leaves your words in the box. Once the run has ended its foot and its box say `this task has finished — say it to main`. -In the sessions place, `enter` on the program's row opens the same page as a page of that +In the activity page, `enter` on the program's row opens the same page as a page of that place, with no box at all. ## Reading a program's actions — the step words down the side, how each came out, the call in flight, how long it has run @@ -367,7 +367,7 @@ once more after its work has landed, so the note on where the work went is on th ## A program's raw calls — ctrl+y, the dialogue with its model, what it sent and what the model answered -`ctrl+y` on a program's page — in its room or in the sessions place — turns it to the raw +`ctrl+y` on a program's page — in its room or in the activity page — turns it to the raw calls the program made, and `ctrl+y` again turns it back to the actions; the key row says which: `ctrl+y calls` or `ctrl+y actions`. A page opens on the actions. @@ -468,7 +468,7 @@ this conversation's plan**, so a task another chat spawned is never reachable: - **pause** / **resume** — hold a task and everything under it out of the ready frontier without changing its rung, so running steps finish and nothing new in the subtree is launched; or release the hold. The key is `p`: a running row - reads `p pause` and a held one `p resume`. The key is on the sessions place's + reads `p pause` and a held one `p resume`. The key is on the activity page's rows only: in a task's room `p` is a letter in the box. A run cannot be paused as a whole, so its own row names no `p pause`. - **cancel** — end a task, its descendants and the work hard-depending on it. The diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 3f1a28518d..e2ca9f83a5 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -4945,7 +4945,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } if a.at(pageSettings) { - a.sheetHover(msg.Mouse().Y) + a.sheetHoverAt(msg.Mouse().X, msg.Mouse().Y) return a, nil } if a.at(pageTasks) { diff --git a/internal/tui3/chrome_test.go b/internal/tui3/chrome_test.go index f6006f07b0..4c0ebc52f2 100644 --- a/internal/tui3/chrome_test.go +++ b/internal/tui3/chrome_test.go @@ -84,6 +84,11 @@ func sheetHas(a *app, want string) bool { func cursorTo(t *testing.T, a *app, key string) { t.Helper() for tab := range settingTabs { + if a.sheet.advanced == nil { + a.sheet.advanced = map[string]bool{} + } + a.sheet.advanced[settingTabs[tab]] = true + a.sheet.build() if a.sheet.tab != tab { a.sheet.tab = tab a.sheet.cursor, a.sheet.top = 0, 0 @@ -108,6 +113,9 @@ func cursorTo(t *testing.T, a *app, key string) { func TestEverySettingRowHasATab(t *testing.T) { registry := config.NewSettings(config.SettingsOptions{ProfileDir: t.TempDir()}) for _, row := range registry.Rows() { + if row.ChatPresentation().Hidden { + continue + } meta, ok := settingMetaFor(row) if !ok { t.Fatalf("registry row %q has no tab — add one line to settingUI", row.Key) @@ -148,7 +156,7 @@ func TestTheSettingsPanelOpensOnBothDoorsAndClosesOnEsc(t *testing.T) { if strings.Contains(plain(frame(a)), microcopy) { t.Fatal("the panel is drawn over a frame that is still showing its status line") } - if !sheetHas(a, "memory") { + if !sheetHas(a, "General") { t.Fatalf("the Session tab is missing its first row:\n%s", strings.Join(sheetLabels(a), "\n")) } } @@ -157,67 +165,24 @@ func TestTheSettingsPanelOpensOnBothDoorsAndClosesOnEsc(t *testing.T) { func TestTheSettingsTabsSwitchAndCarryTheirOwnRows(t *testing.T) { a, _ := sheetApp(t) a.openSettings() - - if got := settingTabs[a.sheet.tab]; got != tabSession { - t.Fatalf("the panel opened on %q, want %q", got, tabSession) + if got := settingTabs[a.sheet.tab]; got != tabGeneral { + t.Fatalf("opened on %q", got) } - for _, want := range []string{"memory", "fallback models"} { - if !sheetHas(a, want) { - t.Fatalf("the Session tab is missing %q:\n%s", want, strings.Join(sheetLabels(a), "\n")) - } - } - if sheetHas(a, "compact at") { - t.Fatal("a Context row is showing on the Session tab") - } - // AND THE FOUR QUESTIONS THAT LEFT IT ARE GONE FROM IT. The gate belongs to - // Safety, the task rows to Tasks, the money to Spending - // (docs/design/spending/DESIGN.md's information hierarchy) — and the ssh - // link to Workspace, because it lands next launch and is a fact about this - // machine rather than about the conversation in front of the reader. - for _, gone := range []string{"ask before running", "per conversation", "tasks at once", "ssh reuse"} { - if sheetHas(a, gone) { - t.Fatalf("%q is still on the Session tab:\n%s", gone, strings.Join(sheetLabels(a), "\n")) + for _, want := range settingTabs[1:] { + drive(t, a, key("right")) + if got := settingTabs[a.sheet.tab]; got != want { + t.Fatalf("landed %q want %q", got, want) } } - // THE CREW IS ON PROVIDERS, with the model it answers under — one tab, one - // question (settings.go's [modelsSection] says why it moved). - if sheetHas(a, "small work") { - t.Fatal("a crew row is showing on the Session tab") - } - drive(t, a, key("right")) - if got := settingTabs[a.sheet.tab]; got != tabContext { - t.Fatalf("→ landed on %q, want %q", got, tabContext) - } - if !sheetHas(a, "compact at") || sheetHas(a, "ask before running") { - t.Fatalf("the Context tab did not replace the Session rows:\n%s", - strings.Join(sheetLabels(a), "\n")) - } - - // The walk clamps at both ends rather than wrapping — the rule every list - // on this surface follows (palette.go). - drive(t, a, key("left"), key("left"), key("left")) - if got := settingTabs[a.sheet.tab]; got != tabSession { - t.Fatalf("← past the first tab landed on %q", got) + if settingTabs[a.sheet.tab] != tabPrivacy { + t.Fatal("right must clamp") } - // AND THE SSH ROWS ARE ON WORKSPACE, the tab about what this machine reaches - // on your behalf, two steps right of the one they used to be on. - drive(t, a, key("right"), key("right")) - if got := settingTabs[a.sheet.tab]; got != tabWorkspace { - t.Fatalf("→ landed on %q, want %q", got, tabWorkspace) - } - for _, want := range []string{"ssh reuse", "ssh heartbeat", "ssh missed heartbeats", "ssh traffic"} { - if !sheetHas(a, want) { - t.Fatalf("the Workspace tab is missing %q:\n%s", want, strings.Join(sheetLabels(a), "\n")) - } - } - for i := 0; i < len(settingTabs)+3; i++ { - drive(t, a, key("right")) + for range settingTabs { + drive(t, a, key("left")) } - // The last tab is the accounts one (connectcaps.go), which is where the walk - // stops rather than wrapping round to the first. - if got := settingTabs[a.sheet.tab]; got != tabConnections { - t.Fatalf("→ past the last tab landed on %q", got) + if settingTabs[a.sheet.tab] != tabGeneral { + t.Fatal("left must clamp") } } @@ -245,7 +210,7 @@ func TestTheSettingsSearchFiltersAcrossEveryTab(t *testing.T) { items = append(items, item.meta.label) } joined := strings.Join(items, "\n") - for _, want := range []string{tabSession, tabProviders, "small work", "your model"} { + for _, want := range []string{tabSession, tabProviders, "small work", "chat model"} { if !strings.Contains(joined, want) { t.Fatalf("the search dropped %q:\n%s", want, joined) } @@ -255,8 +220,8 @@ func TestTheSettingsSearchFiltersAcrossEveryTab(t *testing.T) { } // The tab follows the first match, so backing the search out leaves the // person where the thing they found lives. - if got := settingTabs[a.sheet.tab]; got != tabSession { - t.Fatalf("the tab bar did not follow the first match: %q", got) + if got := settingTabs[a.sheet.tab]; len(a.sheet.items) > 0 && a.sheet.items[0].head != got { + t.Fatalf("category %q does not follow first result", got) } // And the cursor is ON that first match rather than on its heading. item, ok := a.sheet.current() @@ -525,8 +490,8 @@ func TestTheSettingsPanelTakesTheMouse(t *testing.T) { } drive(t, a, clickAt(spans[3].from, bar)) drive(t, a, releaseAt(spans[3].from, bar)) - if got := settingTabs[a.sheet.tab]; got != tabDisplay { - t.Fatalf("a click on the Display tab landed on %q", got) + if got := settingTabs[a.sheet.tab]; got != tabTasks { + t.Fatalf("a click on the Tasks tab landed on %q", got) } // Find the screen row the history toggle is drawn on, then click it twice: diff --git a/internal/tui3/connectcaps.go b/internal/tui3/connectcaps.go index 6037ec47fd..132fc224cb 100644 --- a/internal/tui3/connectcaps.go +++ b/internal/tui3/connectcaps.go @@ -456,6 +456,10 @@ func groupConnections(rows []connect.Status) []connGroup { categorized := false var loose []connect.Status for _, row := range rows { + if row.Connected { + held.rows = append(held.rows, row) + continue + } if _, ok := modelConnectionSource(row.ID); ok { models.rows = append(models.rows, row) continue @@ -470,12 +474,12 @@ func groupConnections(rows []connect.Status) []connGroup { } } out := make([]connGroup, 0, 8) - if len(models.rows) > 0 { - out = append(out, models) - } if len(held.rows) > 0 { out = append(out, held) } + if len(models.rows) > 0 { + out = append(out, models) + } if len(loose) == 0 { return out } @@ -1013,11 +1017,8 @@ func (s *sheet) connKeysLine() string { act = "enter connects" } } - line := "↑↓ move · ←→ tabs · " + act - if s.filterWorth() { - line += " · type to filter" - } - return line + " · esc close" + line := "↑↓ move · ←→ categories · " + act + return line + " · type to search all · esc back" } // ── acting on them ────────────────────────────────────────────────────────── diff --git a/internal/tui3/conversationdelete_test.go b/internal/tui3/conversationdelete_test.go index d6a1091237..ad2dfcacf6 100644 --- a/internal/tui3/conversationdelete_test.go +++ b/internal/tui3/conversationdelete_test.go @@ -386,7 +386,7 @@ func TestTeamsChooseManagerExistingMembersOnlyAndPersists(t *testing.T) { } } text := ansi.Strip(a.teamMembershipOver(strings.Repeat("\n", 35))) - if !strings.Contains(text, "Choose manager for harbor") || strings.Contains(text, "+ New conversation") || !strings.Contains(text, "enter choose · esc cancel") { + if !strings.Contains(text, "Choose AI manager for harbor") || strings.Contains(text, "+ New conversation") || !strings.Contains(text, "enter choose · esc cancel") { t.Fatal("manager picker includes creation or lacks hints") } a.teamMembershipChoose(0) diff --git a/internal/tui3/head_test.go b/internal/tui3/head_test.go index 4bf3ee9c64..6b5ff985a9 100644 --- a/internal/tui3/head_test.go +++ b/internal/tui3/head_test.go @@ -77,12 +77,18 @@ func TestTheConversationWearsThePlacesHead(t *testing.T) { head := headOf(t, a) pulse := headPulseWhole() if size.w == 80 { - pulse = "2 ? · $0.14 / " + railFigure(20) + pulse = "2 ? · $0.14" + } else if size.w == 120 { + pulse = "2 want you · 1 moving · $0.14 / " + railFigure(20) } if !strings.HasPrefix(head[0], " "+plain(a.pal.wordmark(a.width))) || !strings.HasSuffix(head[0], pulse) { t.Fatalf("at %dx%d the chat's first row is not the nav with the pulse at its end:\n%q", size.w, size.h, head[0]) } - if !placeWordsInOrder(head[0], "home", "teams", "chats", "sessions", "spend", "settings") { + words := []string{"home", "chats", "AI teams", "activity", "memory", "spend", "settings"} + if size.w == 80 { + words = []string{"home", "chats", "AI teams", "activity", "memory", "more"} + } + if !placeWordsInOrder(head[0], words...) { t.Fatalf("at %dx%d the chat's first row does not carry the places:\n%q", size.w, size.h, head[0]) } if strings.Contains(head[tabStripRow], " home ") || !strings.Contains(head[tabStripRow], a.chatDisplayName()) { @@ -239,7 +245,7 @@ func TestThePulseOverAChatIsThePulseOverAPlace(t *testing.T) { if place[0] != chat { t.Fatalf("the pulse over the tasks place is not the pulse over the chat:\n%q\n%q", place[0], chat) } - if !strings.Contains(place[navRow], "home") || !strings.Contains(place[navRow], "sessions") { + if !strings.Contains(place[navRow], "home") || !strings.Contains(place[navRow], "activity") { t.Fatalf("the nav is not on the first row: %q", place[navRow]) } if strings.Contains(place[tabStripRow], a.chatDisplayName()) || !strings.HasPrefix(place[tabStripRow], "─") { diff --git a/internal/tui3/homepanel_sessions_test.go b/internal/tui3/homepanel_sessions_test.go index f541bd9778..20bdf3ac52 100644 --- a/internal/tui3/homepanel_sessions_test.go +++ b/internal/tui3/homepanel_sessions_test.go @@ -76,7 +76,7 @@ func TestHomeSessionsHeadingOpensRenamedTab(t *testing.T) { t.Fatalf("width %d: no sessions heading", width) } drive(t, a, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) - if !a.at(pageTasks) || a.page.word() != "sessions" { + if !a.at(pageTasks) || a.page.word() != "activity" { t.Fatalf("opened %s", a.page.word()) } } diff --git a/internal/tui3/narrow_test.go b/internal/tui3/narrow_test.go index 5a58396611..67995ecb34 100644 --- a/internal/tui3/narrow_test.go +++ b/internal/tui3/narrow_test.go @@ -42,7 +42,7 @@ func TestTheNarrowNavStillSaysWhereElseYouCanGo(t *testing.T) { } // AND THE AIR IS HELD: two blank cells between two words, whatever the // width, because what a narrow row gives up is words and never air. - if width >= 80 && !strings.Contains(bar, "home teams chats sessions spend settings") { + if width >= 80 && !strings.Contains(bar, "home chats AI teams activity memory spend settings") { t.Fatalf("at %d columns the nav drew\n\t%q\nand should carry the six places two cells apart", width, bar) } if strings.Contains(bar, " ") { @@ -416,7 +416,7 @@ func TestTheSettingsFootDropsWholeClausesNotCharacters(t *testing.T) { if !strings.Contains(full, " · ") { t.Fatalf("the settings foot is no longer two clauses and this test is about the wrong line: %q", full) } - head := strings.SplitN(full, " · ", 2)[0] + head := "This profile" for _, width := range []int{60, 80, 120, 160} { note := plain(placeSettings{}.note(a, width)[0]) if strings.Contains(note, glyphMore) { @@ -436,7 +436,7 @@ func TestTheSettingsFootDropsWholeClausesNotCharacters(t *testing.T) { if got, want := plain(placeSettings{}.note(a, 60)[0]), " "+head; got != want { t.Fatalf("at 60 columns the settings foot drew\n\t%q\nand it should be the answer alone:\n\t%q", got, want) } - if got, want := plain(placeSettings{}.note(a, 160)[0]), " "+full; got != want { + if got, want := plain(placeSettings{}.note(a, 160)[0]), " "+head; got != want { t.Fatalf("at 160 columns the settings foot drew\n\t%q\nand there was room for all of it:\n\t%q", got, want) } } diff --git a/internal/tui3/navigation_clarity_test.go b/internal/tui3/navigation_clarity_test.go new file mode 100644 index 0000000000..587597e906 --- /dev/null +++ b/internal/tui3/navigation_clarity_test.go @@ -0,0 +1,30 @@ +package tui3 + +import "testing" + +// Display labels and visual order must not migrate saved visit keys or learned keys. +func TestNavigationClarityPreservesExistingIdentities(t *testing.T) { + for _, c := range []struct { + id page + label, key string + digit int + }{ + {pageTeams, "AI teams", "teams", 2}, + {pageTasks, "activity", "tasks", 4}, + {pageChats, "chats", "chats", 3}, + } { + if c.id.word() != c.label || c.id.lookKey() != c.key || placeDigitOf(c.id) != c.digit { + t.Fatalf("destination %v changed compatibility identity: %q %q %d", c.id, c.id.word(), c.id.lookKey(), placeDigitOf(c.id)) + } + } + shown := barPages(pageHome, false) + want := []page{pageHome, pageChats, pageTeams, pageTasks, pageMemory, pageSpend, pageSettings} + if len(shown) != len(want) { + t.Fatalf("main destinations: %v", shown) + } + for i := range want { + if shown[i] != want[i] { + t.Fatalf("main destinations: %v", shown) + } + } +} diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index f12c98e524..ff5914edbc 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -83,9 +83,9 @@ const ( type place interface { // id is which place this is, and it is the key the registry files it under. id() page - // word is the one lowercase word this place is called, on the tab bar and in - // the manual. It is the whole of a place's name: a tab bar of two-word labels - // is a menu, and this is a bar. + // word is the concise label this place is called, on the tab bar and in + // the manual. A qualifier is retained when it prevents a different meaning, + // as AI teams distinguishes the destination from human collaboration. word() string // counted answers whether a number in front of this place would mean // anything — see [page.counted]. @@ -393,38 +393,15 @@ func (placeBase) caretRow(a *app, width int, rows []placeRow) (int, int, bool) { // and a row of [placeOrder], and nothing else anywhere. var placeRegistry = map[page]place{} -// placeOrder is the whole set, in the one order that matters: `alt+1` through -// `alt+8`, and, for the first [placeBarPlaces] of them, left to right along -// the tab bar and round the circle `tab` walks. -// -// THE BAR IS FOUR PLACES AND HOME IS THEIR SUMMARY (DESIGN.md's law 10). What -// wants you and what is running (home), the work itself (sessions, the tasks -// place), what it cost (spend), and how this machine is set (settings), with -// teams and the way back to the chats between home and the work. Standing, -// memory come after them: still rooms, still reached by -// `/standing` and `/memory`, by the typed box's place offers, by -// `alt+7`…`alt+8` and by the map, but not drawn on a bar a person reads a -// hundred times a day, until they are the rooms a person walks into a hundred -// times a day. -// -// THE TWO KEEP A DIGIT EACH so a hand that learned `alt+7` finds a room there -// rather than a key that does nothing. -// -// IT IS A LIST HERE AND NOT AN `init` ORDER. Go runs a package's `init`s in -// filename order, so a registry that took its order from them would put the tab -// bar's reading order at the mercy of what a file happens to be called — and -// `place_home.go` sorts after `place_tasks.go` would silently reorder the bar -// and every number on it. -// -// TEAMS IS SECOND AND THE CHATS THIRD (the owner's order, 2026-09-24). Teams -// is where a person runs the work they handed off (place_teams.go, DESIGN.md -// section 8.4), so it is read as often as home is; the chats (place_chats.go) -// are the room a person came from, and goes back to more than to any other. +// placeOrder preserves the established numbered shortcuts for existing users. +// Visual order is independent: moving a destination must not retrain a shortcut. var placeOrder = []page{pageHome, pageTeams, pageChats, pageTasks, pageSpend, pageSettings, pageStanding, pageMemory} -// placeBarPlaces is how many of [placeOrder] the tab bar draws: the four a day -// is read through, teams beside home, and the way back to the chats. -const placeBarPlaces = 6 +// placeBarOrder groups daily work before utilities. Memory stays discoverable +// even before the first saved memory; standing orders remain available in the map. +var placeBarOrder = []page{pageHome, pageChats, pageTeams, pageTasks, pageMemory, pageSpend, pageSettings, pageStanding} + +const placeBarPlaces = 7 // barPages is the places the bar draws while a person stands at `here`: the // first [placeBarPlaces], and the room they are standing in when it is one of @@ -433,10 +410,10 @@ const placeBarPlaces = 6 // are off the bar are on the one surface whose job is to show every key. func barPages(here page, every bool) []page { if every { - return placeOrder + return placeBarOrder } - shown := placeOrder[:placeBarPlaces:placeBarPlaces] - for _, id := range placeOrder[placeBarPlaces:] { + shown := placeBarOrder[:placeBarPlaces:placeBarPlaces] + for _, id := range placeBarOrder[placeBarPlaces:] { if id == here { return append(shown, id) } @@ -501,7 +478,7 @@ func placeWordList() string { words = append(words, pl.word()) } } - return strings.Join(words, " ") + return strings.Join(words, " · ") } // placeFor is the place one id names, and nil for the conversation or for an id @@ -520,7 +497,7 @@ func (a *app) showing() place { return placeRegistry[a.page] } // somewhere else. func (a *app) at(id page) bool { return a.page == id } -// word is the one lowercase word a place is called. +// word is the concise label a place is called. func (p page) word() string { if pl := placeFor(p); pl != nil { return pl.word() @@ -533,6 +510,9 @@ func (p page) lookKey() string { if p == pageTasks { return "tasks" } + if p == pageTeams { + return "teams" + } return p.word() } diff --git a/internal/tui3/pages_test.go b/internal/tui3/pages_test.go index ed9ffd2c62..72bbae99d2 100644 --- a/internal/tui3/pages_test.go +++ b/internal/tui3/pages_test.go @@ -316,7 +316,7 @@ func TestTheMapDrawsInTheCellsThatWereAlreadyThere(t *testing.T) { // THE NUMBERS ARE ON THE TABS, and the two places off the bar are drawn // after the six with theirs: the map is the one surface whose job is to show // every key, so `alt+7`…`alt+8` are on it. - if bar := after[navRow]; !strings.Contains(bar, "1 home") || !strings.Contains(bar, "2 teams") || !strings.Contains(bar, "3 chats") || + if bar := after[navRow]; !strings.Contains(bar, "1 home") || !strings.Contains(bar, "2 AI teams") || !strings.Contains(bar, "3 chats") || !strings.Contains(bar, "6 settings") || !strings.Contains(bar, "7 standing") || !strings.Contains(bar, "8 memory") || strings.Contains(bar, "9 search") { t.Fatalf("the map put no numbers on the tab bar: %q", bar) } @@ -713,7 +713,7 @@ func TestATabWearsTheCountTheSeamGivesIt(t *testing.T) { pageStanding.word(): 0, } bar := navPlaces(a, 160, false) - if !strings.Contains(bar, "sessions 2") { + if !strings.Contains(bar, "activity 2") { t.Fatalf("the tasks tab does not wear its count: %q", bar) } if strings.Contains(bar, "spend 9") { @@ -807,22 +807,16 @@ func TestTheNumbersOpenAPlaceFromTheConversationToo(t *testing.T) { } } -// THE TAB BAR CARRIES ITS SIX WORDS AT EVERY WIDTH A PERSON ACTUALLY USES, and -// only those six: `home teams chats sessions spend settings` (DESIGN.md's law -// 10, with teams after home by the teams page ruling, c-2, and the chats third, -// the owner's order). The ladder -// that gives words up is for terminals narrower than any of these -// (topnav.go); at 80 columns and up nothing is folded. Standing, -// memory and search are rooms reached by command, by their digit and by the -// map — not words on the row a person reads a hundred times a day. +// Daily work precedes utilities on the bar, including discoverable memory. +// Numbered shortcuts retain their previous destinations. func TestTheTabBarCarriesTheFourAtEveryUsableWidth(t *testing.T) { a := placeApp(t) for _, width := range []int{80, 120, 200} { bar := navPlaces(a, width, false) - if !placeWordsInOrder(bar, "home", "teams", "chats", "sessions", "spend", "settings") { + if !placeWordsInOrder(bar, "home", "chats", "AI teams", "activity", "memory", "spend", "settings") { t.Fatalf("at %d columns the bar is not the six places in order: %q", width, bar) } - for _, id := range []page{pageStanding, pageMemory} { + for _, id := range []page{pageStanding} { if strings.Contains(bar, id.word()) { t.Fatalf("at %d columns the bar still carries %q: %q", width, id.word(), bar) } @@ -830,8 +824,8 @@ func TestTheTabBarCarriesTheFourAtEveryUsableWidth(t *testing.T) { } // AND A ROOM OFF THE BAR IS ON IT WHILE YOU STAND IN IT. A bar with no word // lit is a bar that does not know where you are. - walkTo(t, a, pageMemory) - if bar := navPlaces(a, 120, false); !strings.Contains(bar, "settings memory") { + walkTo(t, a, pageStanding) + if bar := navPlaces(a, 120, false); !strings.Contains(bar, "settings standing") { t.Fatalf("standing in memory, the bar does not say so: %q", bar) } } diff --git a/internal/tui3/place_home_test.go b/internal/tui3/place_home_test.go index c1f2ab1b36..879597bff2 100644 --- a/internal/tui3/place_home_test.go +++ b/internal/tui3/place_home_test.go @@ -153,7 +153,7 @@ func TestSinceYouLeftLinesAreDoorsIntoTheirPlaces(t *testing.T) { // them pressed into the wrong keyboard. a.showPage(pageHome) } - if !doors["standing"] || !doors["sessions"] { + if !doors["standing"] || !doors["activity"] { t.Fatalf("the ledger drew %v, and both the watch and the landed work happened", doors) } } diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index d9ca6f2bf1..3b325d455d 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -1866,7 +1866,7 @@ func (placeTasks) window(a *app, key string) (bool, tea.Cmd) { return a.taskSheet.window(a, key), nil } func (placeTasks) note(a *app, width int) []string { return a.taskSheet.note(a, width) } -func (placeTasks) about() string { return "every session and task this machine ran" } +func (placeTasks) about() string { return "work across your chats and delegated tasks" } func (placeTasks) hint(a *app) string { return a.taskSheet.hint(a) } func (placeTasks) changed(a *app, since time.Time) int { return a.taskSheet.changed(a, since) } diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 2165650e2c..d1e158bbee 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -5,6 +5,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" ) // ── THE SETTINGS PLACE ────────────────────────────────────────────────────── @@ -52,7 +53,43 @@ func (placeSettings) close(a *app) { a.dropSettings() } // body is the panel's own tab bar, the rule under it, and one section's rows — // or, while a submenu is up, the options it is offering. +// The sidebar uses the existing four-cell pane gutter. Compact terminals keep +// the scrolling category bar so every category remains reachable. +const settingsSidebarWidth = 20 + +func settingsSidebar(width, height int) bool { return width >= 100 && height >= 24 } +func (a *app) sheetSidebar(width, height int) bool { + return settingsSidebar(width, height) && a.sheet.sel == nil && a.sheet.edit == nil +} func (placeSettings) body(a *app, width, room int) []placeRow { + _, height := a.size() + if !a.sheetSidebar(width, height) { + return settingsBody(a, width, room, false) + } + rows := settingsBody(a, width-settingsSidebarWidth, room, true) + for i := range rows { + left := "" + hit, _ := rows[i].hit.(sheetHit) + if i < len(settingTabs) { + title := settingTabs[i] + left = " " + a.pal.dim(title) + if a.hot.kind == hoverSheet && a.hot.index == -2-i { + left = " " + a.pal.underline(a.pal.dim(title)) + } + if i == a.sheet.tab { + left = " " + a.pal.chip(title) + if a.hot.kind == hoverSheet && a.hot.index == -2-i { + left = " " + a.pal.underline(a.pal.chip(title)) + } + } + hit.category = i + 1 + } + rows[i].text = left + strings.Repeat(" ", max(0, settingsSidebarWidth-ansi.StringWidth(left))) + rows[i].text + rows[i].hit = hit + } + return rows +} +func settingsBody(a *app, width, room int, sidebar bool) []placeRow { s := &a.sheet pal := a.pal rows := make([]placeRow, 0, room) @@ -60,10 +97,14 @@ func (placeSettings) body(a *app, width, room int) []placeRow { // and a second one inside the body drew the page as two frames stacked // (PLACES-AUDIT.md finding 11); the filled chip and one blank row are the // whole of what separates the bar from the rows. - rows = append(rows, placeRow{text: sheetTabBar(width, s.tab, pal), hit: sheetHit{kind: sheetHitTabs}}) + if sidebar { + rows = append(rows, placeRow{text: " " + placeHeading(settingTabs[s.tab], pal)}) + } else { + rows = append(rows, placeRow{text: sheetTabBar(width, s.tab, pal), hit: sheetHit{kind: sheetHitTabs}}) + } rows = append(rows, placeRow{}) if s.sel == nil && s.edit == nil && s.conn.entry == nil { - filter, _, _ := draftBlock(&s.query, pal, width-2, 1, "type to search", "") + filter, _, _ := draftBlock(&s.query, pal, width-2, 1, "Search all settings and connections", "") for _, line := range filter { rows = append(rows, placeRow{text: " " + line}) } @@ -179,7 +220,43 @@ func (placeSettings) note(a *app, width int) []string { } return out } - return []string{" " + pal.dim(noteFit(a.sheet.footNote(), width-2))} + item, ok := a.sheet.current() + if at := a.hoveredSheetRow(); at >= 0 && at < len(a.sheet.items) { + item, ok = a.sheet.items[at], true + } + scope := a.sheet.footNote() + about := "" + if ok { + about = item.meta.about + if item.conn != nil { + about = connAbout(item.conn) + } + if item.row.Key != "" && !(a.sheet.host != "" && item.meta.tab == tabTeams) { + info := item.row.ChatPresentation() + facts := []string{} + if info.Scope != "" { + facts = append(facts, info.Scope) + } + if info.Activation != "" { + facts = append(facts, info.Activation) + } + if len(facts) > 0 { + scope = strings.Join(facts, " · ") + } + } + } + out := []string{" " + pal.dim(noteFit(scope, width-2))} + details := settingAboutLines(about, width) + // Reserve the same detail space on every row. Hover and keyboard selection + // explain the same control without moving another control under the pointer. + for i := 0; i < settingAboutRows; i++ { + line := "" + if i < len(details) { + line = " " + pal.dim(details[i]) + } + out = append(out, line) + } + return out } func (placeSettings) about() string { return "how this machine is set" } @@ -234,19 +311,26 @@ func (placeSettings) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { // string, because both came from the same call. func (placeSettings) caretRow(a *app, width int, rows []placeRow) (int, int, bool) { entry := a.sheet.conn.entry + _, height := a.size() + offset := 0 + if a.sheetSidebar(width, height) { + offset = settingsSidebarWidth + width -= offset + } + content := func(text string) string { return ansi.Strip(ansi.Cut(text, offset, offset+width)) } if entry == nil { if a.sheet.edit != nil { return 0, 0, false } - box, placeholder := &a.sheet.query, "type to search" + box, placeholder := &a.sheet.query, "Search all settings and connections" if a.sheet.sel != nil { box, placeholder = &a.sheet.sel.pick.filter, "type to filter" } block, column, at := draftBlock(box, a.pal, width-2, 1, placeholder, "") if at >= 0 && at < len(block) { for j, row := range rows { - if row.text == " "+block[at] { - return j, column + 1, true + if content(row.text) == ansi.Strip(" "+block[at]) { + return j, column + 1 + offset, true } } } @@ -268,8 +352,8 @@ func (placeSettings) caretRow(a *app, width int, rows []placeRow) (int, int, boo return -1, 0, true } for j, row := range rows { - if strings.HasPrefix(row.text, block[at]) { - return j, column, true + if strings.HasPrefix(content(row.text), ansi.Strip(block[at])) { + return j, column + offset, true } } // The list window scrolled the box off this frame. The caret is hidden diff --git a/internal/tui3/place_teams.go b/internal/tui3/place_teams.go index 8066de77c4..76a1990cd0 100644 --- a/internal/tui3/place_teams.go +++ b/internal/tui3/place_teams.go @@ -23,7 +23,7 @@ type placeTeams struct{ placeBase } func init() { registerPlace(placeTeams{}) } func (placeTeams) id() page { return pageTeams } -func (placeTeams) word() string { return "teams" } +func (placeTeams) word() string { return "AI teams" } // counted is true: what is in here is a pile of things, and a packet waiting // on the person is news about this place. @@ -124,7 +124,9 @@ func (placeTeams) note(a *app, width int) []string { // hint is what the pointer or the cursor is on, with its key, and otherwise // the page's keys. -func (placeTeams) about() string { return "the teams you hand work to" } +func (placeTeams) about() string { + return "AI chats grouped around ongoing work, with an optional AI manager" +} func (placeTeams) hint(a *app) string { if a.tmove.on { @@ -151,7 +153,7 @@ func (placeTeams) hint(a *app) string { if !a.teamsAny() { return "o organize · n new team · " + homeDoorWord } - return "↑↓ walk · enter open · m move into… · space pick · a add member · s settings · c disband · n new team · o organize · " + homeDoorWord + return "↑↓ walk · enter open · m move into… · space pick · a add chat · s settings · c disband · n new team · o organize · " + homeDoorWord } // changed is how many packets wait on the person, which is the count a person diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 3243b30004..d51ea53df5 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -70,78 +70,28 @@ import ( // palette and nothing new: the panel is a list, and this surface already knows // what a list looks like (palette.go's overlayRow draws every row here). -// The tabs, in the order docs/CHAT-V3.md Decision 6 names them. +// Category labels are presentation only; persisted setting keys stay unchanged. const ( - // tabSession is this conversation and only this conversation: what it - // carries from the last one, and where it goes when its model will not - // answer. It is two rows, and that is the honest size of it — the models - // went to Providers, the money went to Spending, and the ssh link went to - // Workspace because it lands next launch rather than on this session. - tabSession = "Session" - // tabContext is what a model carries — the context law, whole. - tabContext = "Context" - // tabWorkspace is this machine and this project: what codeaf does with its - // own time here, and what it may reach on your behalf — a service it signs - // in to, and the ssh link it reaches another machine over. It is NOT where - // money lives any more, and that is the whole of - // docs/design/spending/DESIGN.md's first complaint — twenty rows answering - // four questions, with the dollar figures filed between `workers` and - // `memory floor`. - tabWorkspace = "Workspace" - // tabDisplay is how the surface draws itself and what it remembers of your - // typing. - tabDisplay = "Display" - // tabSpending is MONEY AND NOTHING ELSE: what codeaf may spend, per day, per - // conversation, per plan, and on its own practice — with what the day has - // actually cost at the top of it. It is the one editor money has, and every - // door on this surface that names a rail lands on one of its rows - // (settingspend.go). - tabSpending = "Spending" - // tabSafety is what codeaf may do without asking you first: the gate, its - // exceptions, the model that answers for you, and the two clocks that answer - // when nobody does. - tabSafety = "Safety" - // tabTasks is how work you can walk away from is run — how it starts, how it - // is checked, how much of it happens at once, and on whose hands. - tabTasks = "Tasks" - // tabTeams is what every team inherits when it says nothing of its own: - // who answers a member's question, what a team may spend in a day, what a - // new sub-team is given, and how deep teams may nest. A team's own - // overrides are on its card on the teams page; these are the defaults its - // `· from Settings` points at. - tabTeams = "Teams" - // tabProviders is which model answers what. - tabProviders = "Providers" + tabGeneral = "General" + tabProviders = "Models" + tabMemory = "Memory" + tabTasks = "Tasks" + tabTeams = "AI teams" + tabSafety = "Permissions" + tabSpending = "Spending" + tabPrivacy = "Privacy" + // Compatibility names keep existing internal entry points on their new category. + tabSession = tabMemory + tabContext = tabProviders + tabWorkspace = tabGeneral + tabDisplay = tabGeneral ) -// And the sixth, which is not a reading of the registry at all: the accounts -// this profile has connected and what each of them may do (connectcaps.go). It -// is last because the five before it are one object read five ways, and a -// person walking the bar meets the knobs before their accounts. -// Spending, Safety and Tasks stand between Display and Providers, and Spending -// leads the three: "what may it spend" is asked before "on which machine", and -// before either of the two questions that used to share its tab. -// -// Teams follows Tasks: both are about work you hand off, and its defaults are -// read after the question of how one task runs. -var settingTabs = []string{tabSession, tabContext, tabWorkspace, tabDisplay, - tabSpending, tabSafety, tabTasks, tabTeams, tabProviders, tabConnections} - -// settingTabCategory is the ONE-TO-ONE map between the four newer tabs and the -// four registry categories behind them, and it is the seam that keeps the skin -// honest about the one source of truth. -// -// The other tabs are a reading of the ROWS and not of the categories — "session -// ceiling" is a dollar figure that answers "what may THIS conversation do" — and -// that stays true of them. These four are different: the registry's own words -// for them (`spending`, `safety`, `tasks`, `teams`) are already the product's words for -// them, so a row that is filed under one and drawn under another would be two -// answers to one question. chrome_test.go pins the map in both directions. +var settingTabs = []string{tabGeneral, tabProviders, tabMemory, tabTasks, tabTeams, + tabSafety, tabSpending, tabConnections, tabPrivacy} var settingTabCategory = map[string]string{ - tabSpending: config.CategorySpending, - tabSafety: config.CategorySafety, - tabTasks: config.CategoryTasks, - tabTeams: config.CategoryTeams, + tabSpending: config.CategorySpending, tabSafety: config.CategorySafety, + tabTasks: config.CategoryTasks, tabTeams: config.CategoryTeams, } // settingWidget is how a row is ANSWERED, which is not quite how it reads. @@ -868,15 +818,62 @@ func settingMetaFor(row config.Setting) (settingMeta, bool) { if !ok { return settingMeta{}, false } + presentation := row.ChatPresentation() + if presentation.Hidden { + return settingMeta{}, false + } + if presentation.Category != "" { + meta.tab = presentation.Category + } + if presentation.Label != "" { + meta.label = presentation.Label + } if meta.label == "" { meta.label = row.Label } + if presentation.Description != "" { + meta.about = presentation.Description + } if meta.about == "" { - meta.about = firstSentence(row.Hint) + meta.about = row.Hint } return meta, true } +// finishCategory keeps advanced controls discoverable without making the common +// path a catalog of engine details. Search deliberately bypasses this fold. +func (s *sheet) finishCategory() { + title := settingTabs[s.tab] + shown := make([]sheetItem, 0, len(s.items)) + if title == tabMemory { + shown = append(shown, sheetItem{memoryDoor: true, meta: settingMeta{label: "View saved memories", about: "Inspect remembered information, correct it, or forget it."}}) + } + advanced := make([]sheetItem, 0) + catalog := make([]sheetItem, 0) + for _, item := range s.items { + if title == tabConnections && (item.conn != nil || item.heading()) { + catalog = append(catalog, item) + continue + } + if item.row.Key != "" && item.row.ChatPresentation().Hidden { + continue + } + if item.row.ChatPresentation().Advanced || item.role != nil || item.autonomy != nil || item.crewDoor { + advanced = append(advanced, item) + } else if !item.heading() || title == tabConnections { + shown = append(shown, item) + } + } + if len(advanced) > 0 { + shown = append(shown, sheetItem{advancedDoor: true, meta: settingMeta{label: "Advanced", about: "Less common controls. Search finds these even while collapsed."}}) + if s.advanced[title] { + shown = append(shown, advanced...) + } + } + s.items = append(shown, catalog...) + s.cursor = s.clampCursor(s.cursor) +} + // firstSentence is the registry hint cut to one line. A hint is written as // prose for a sheet with room; a panel row has one line and takes the sentence // that carries the meaning. @@ -896,9 +893,11 @@ const sheetRows = 16 // sheetItem is one line of the list: a row, or — while a search is on — the // faint tab heading a group of them sits under. type sheetItem struct { - head string - row config.Setting - meta settingMeta + advancedDoor bool + memoryDoor bool + head string + row config.Setting + meta settingMeta // conn is set on the rows of the Connections tab, which are accounts rather // than registry rows (connectcaps.go). It hangs here so that the cursor // walk, the scroll, the pointer and the hover need to know nothing about @@ -946,7 +945,8 @@ func (i sheetItem) restful() bool { return i.head == "" && i.read == nil } // sheet is the panel's whole state. The zero value is closed and costs the // frame nothing. type sheet struct { - tab int + advanced map[string]bool + tab int // host is the machine the session runs on over --host, "" otherwise. host string // teamDefaultsWrite says the teams seam can change that machine's `teams.` @@ -1420,8 +1420,13 @@ func (s *sheet) build() { // the registry (connectcaps.go). It is one branch and no second list: what // it appends is [sheetItem]s, so everything downstream of here — the cursor, // the window, the pointer, the hover — is the code that was already there. - if s.onConnections() { + if s.onConnections() && query == "" { + for _, row := range s.tabRows() { + meta, _ := s.metaFor(row) + s.items = append(s.items, sheetItem{row: row, meta: meta}) + } s.buildConnections() + s.finishCategory() return } if query == "" { @@ -1431,6 +1436,7 @@ func (s *sheet) build() { // (settingspend.go). if settingTabs[s.tab] == tabSpending { s.items = append(s.items, s.spendingItems()...) + s.finishCategory() s.cursor = s.clampCursor(s.cursor) return } @@ -1504,12 +1510,16 @@ func (s *sheet) build() { s.items = append(s.items, s.autonomyItems(nil)...) } } + s.finishCategory() s.cursor = s.clampCursor(s.cursor) return } first := -1 + type resultGroup struct{ start, end, tab, score int } + groups := []resultGroup{} for tab, title := range settingTabs { start := len(s.items) + best := 0 // THE ROWS A TAB KEEPS ARE RANKED BY HOW WELL THEY ANSWERED, best first // and stable within equal scores on the registry's own order — so a // search reads as an answer key rather than as the panel reordered. The @@ -1525,6 +1535,7 @@ func (s *sheet) build() { if !hit { continue } + best = max(best, score) matched = append(matched, settingHit{row: row, meta: meta, score: score, hitAt: at, hitLen: n}) } sort.SliceStable(matched, func(a, b int) bool { return matched[a].score > matched[b].score }) @@ -1566,10 +1577,28 @@ func (s *sheet) build() { s.items = append(s.items, matched[1:]...) } } + if title == tabConnections { + s.buildConnections() + } + if len(s.items) > start { + groups = append(groups, resultGroup{start, len(s.items), tab, best}) + } if len(s.items) > start && first < 0 { first, s.tab = start+1, tab } } + // Put the category with the strongest answer first, keeping its rows together. + // A weak match in General must not bury an exact match in Permissions. + if len(groups) > 0 { + sort.SliceStable(groups, func(i, j int) bool { return groups[i].score > groups[j].score }) + ordered := make([]sheetItem, 0, len(s.items)) + for _, group := range groups { + ordered = append(ordered, s.items[group.start:group.end]...) + } + s.items = ordered + s.tab = groups[0].tab + first = 0 + } // Clamped rather than taken, because the first item of a group is a heading // and the second one can be a heading too: a search that matched only roles // opens on the tab's name, the section's name, and then a row. @@ -1698,6 +1727,11 @@ func (s *sheet) settingMatch(row config.Setting, meta settingMeta, tab string, t fields[2] = meta.about fields[3] = rowText(row) fields[4] = tab + presentation := row.ChatPresentation() + fields = append(fields, presentation.Aliases...) + if old, ok := settingUI[row.Key]; ok { + fields = append(fields, old.label, old.tab) + } return s.matchHits(fields, terms) } @@ -2368,7 +2402,35 @@ func (a *app) activate() tea.Cmd { if !ok { return nil } + if item.memoryDoor { + return a.showPage(pageMemory) + } + if item.advancedDoor { + if s.advanced == nil { + s.advanced = map[string]bool{} + } + s.advanced[settingTabs[s.tab]] = !s.advanced[settingTabs[s.tab]] + s.build() + return nil + } if item.conn != nil { + // Service actions run on their category so asynchronous replies and Escape + // use the same account lifecycle as a service reached by browsing. + if s.searching() { + s.query.reset() + for at, title := range settingTabs { + if title == tabConnections { + s.tab = at + } + } + s.build() + for at, candidate := range s.items { + if candidate.conn != nil && candidate.conn.service == item.conn.service { + s.cursor = at + break + } + } + } return a.connAct(item.conn) } if item.crewDoor { @@ -3008,8 +3070,9 @@ const ( ) type sheetHit struct { - kind sheetHitKind - index int + category int // One-based sidebar category, zero outside its rows. + kind sheetHitKind + index int } // sheetPress is a click inside the panel: a tab word switches tabs, a row @@ -3032,7 +3095,16 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { if y < 0 || y >= len(hits) { return nil } - switch hit := hits[y]; hit.kind { + hit := hits[y] + if a.sheetSidebar(width, height) && x < settingsSidebarWidth { + if hit.category > 0 && a.sheet.sel == nil && a.sheet.edit == nil { + a.sheet.tabBy(hit.category - 1 - a.sheet.tab) + a.saySettingsHost() + a.touch() + } + return nil + } + switch hit.kind { case sheetHitTabs: if tab, ok := tabAtColumn(x, width, a.sheet.tab); ok { if a.sheet.searching() { @@ -3077,11 +3149,16 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { // sheetHover records which row the pointer is over, repainting only when the // answer changed (hover.go's rule, applied to the panel). -func (a *app) sheetHover(y int) { +func (a *app) sheetHover(y int) { a.sheetHoverAt(settingsSidebarWidth, y) } + +func (a *app) sheetHoverAt(x, y int) { width, height := a.size() _, hits, _, _ := a.sheetFrame(width, height) next := hoverAt{} - if y >= 0 && y < len(hits) && hits[y].kind == sheetHitRow { + if a.sheetSidebar(width, height) && x < settingsSidebarWidth && y >= 0 && y < len(hits) && hits[y].category > 0 { + next = hoverAt{kind: hoverSheet, index: -1 - hits[y].category} + } + if (!a.sheetSidebar(width, height) || x >= settingsSidebarWidth) && y >= 0 && y < len(hits) && hits[y].kind == sheetHitRow { next = hoverAt{kind: hoverSheet, index: hits[y].index} } if next == a.hot { @@ -3100,59 +3177,8 @@ func (a *app) sheetHover(y int) { // and the pointer resolves against them, and two answers to "where is the tab // bar" is how a click lands on the wrong tab. func (a *app) sheetFrame(width, height int) ([]string, []sheetHit, int, int) { - // THE HEAD AND THE FOOT BELONG TO THE ROUTER (pages.go). This panel's title - // row is gone — the place tab bar above says `settings` — and so is its keys - // line, which is the one hint every place shares now. ITS OWN TAB BAR STAYS, - // as the first row of its body, and the two bars are not a repetition: the - // nav at the top is the places and this one is this place's sections. - s := &a.sheet - pal := a.pal lines, hits, caretX, caretY := placeFrame(a, width, height, - func(width, room int) []placeRow { - rows := make([]placeRow, 0, room) - rows = append(rows, placeRow{text: sheetTabBar(width, s.tab, pal), hit: sheetHit{kind: sheetHitTabs}}) - rows = append(rows, placeRow{}) - room -= len(rows) - if room < 1 { - room = 1 - } - if s.sel != nil { - body, at := s.selectLines(width, room, pal, a.reasoningFor) - for i, line := range body { - hit := sheetHit{} - if at[i] >= 0 { - hit = sheetHit{kind: sheetHitOption, index: at[i]} - } - rows = append(rows, placeRow{text: line, hit: hit}) - } - return rows - } - body, owner := s.listLines(width, room, pal, a.hoveredSheetRow()) - at := s.cursorLine(owner) - // THE CURSOR'S ROW IS SCROLLED IN WHOLE. At [tierPhone] it is two - // lines — the name and the value under it — and a window that pinned - // only the first would push the value off the bottom edge, leaving a - // selection band with one end cut off and the fact being changed off - // screen. The last line is pinned first and the first line second, so - // a row taller than the window still shows its name. - if last := s.cursorLastLine(owner, at, width); last != at { - s.top = listTop(last, s.top, len(body), room) - } - s.top = listTop(at, s.top, len(body), room) - for i := 0; i < room; i++ { - index := s.top + i - if index >= len(body) { - rows = append(rows, placeRow{}) - continue - } - hit := sheetHit{} - if owner[index] >= 0 { - hit = sheetHit{kind: sheetHitRow, index: owner[index]} - } - rows = append(rows, placeRow{text: body[index], hit: hit}) - } - return rows - }) + func(width, room int) []placeRow { return (placeSettings{}).body(a, width, room) }) return lines, placeHitsOf(hits, sheetHit{}), caretX, caretY } @@ -3404,25 +3430,6 @@ func (s *sheet) listLines(width, room int, pal palette, hover int) ([]string, [] for _, line := range s.rowLinesWithin(item, i == s.cursor, i == hover, width, connBoxRows(room), pal) { put(line, i) } - if i != s.cursor { - continue - } - // THE ONE DESCRIPTION THIS PANEL EVER SHOWS is the selected row's, and - // on the accounts tab it is the selected SERVICE'S — a catalog row's own - // line about what connecting it buys, drawn under the row a person has - // stopped on and under no other. Most rows of that tab have none: a - // sentence explaining "read your mail" would be this surface saying the - // same thing twice (connectcaps.go's [connAbout]). - about := item.meta.about - if item.conn != nil { - about = connAbout(item.conn) - } - if about == "" { - continue - } - for _, line := range settingAboutLines(about, width) { - put(" "+pal.dim(line), i) - } } return lines, owner } @@ -3514,6 +3521,16 @@ func (s *sheet) rowLines(item sheetItem, selected, hovered bool, width int, pal // connection box. Ordinary rows do not spend it; the one tall row may use it // before dropping the end of a wrapped answer list. func (s *sheet) rowLinesWithin(item sheetItem, selected, hovered bool, width, boxRows int, pal palette) []string { + if item.memoryDoor { + return overlayLines(item.meta.label, "open", selected, false, hovered, width, pal) + } + if item.advancedDoor { + value := "show" + if s.advanced[settingTabs[s.tab]] { + value = "hide" + } + return overlayLines(item.meta.label, value, selected, false, hovered, width, pal) + } if item.conn != nil { return s.connRowLines(item.conn, selected, hovered, width, boxRows, pal) } @@ -3556,6 +3573,13 @@ func (s *sheet) rowLinesWithin(item sheetItem, selected, hovered bool, width, bo // beside the default, rather than spelled again by every surface that draws // a number ([config.Setting.Reading]). value := item.row.Reading() + if item.row.Key == config.KeyHints { + if item.row.Value() == "on" { + value = "off" + } else { + value = "on" + } + } if hosted, ok := s.hostedTeamReading(item); ok { value = hosted } @@ -3781,7 +3805,7 @@ func (s *sheet) keysLine() string { if item, ok := s.current(); ok && item.role != nil && item.role.pin != "" { return "↑↓ move · enter pin · del unpin · type to search · esc close" } - return "↑↓ move · ←→ tabs · enter change · type to search · esc close" + return "↑↓ settings · ←→ categories · enter change · type to search all · esc back" } } diff --git a/internal/tui3/settings_organization_test.go b/internal/tui3/settings_organization_test.go new file mode 100644 index 0000000000..cd0480ae66 --- /dev/null +++ b/internal/tui3/settings_organization_test.go @@ -0,0 +1,151 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" +) + +func TestSettingsOrganizationCategoriesAndAdvancedSearch(t *testing.T) { + a, _ := sheetApp(t) + a.openSettings() + if settingTabs[a.sheet.tab] != tabGeneral { + t.Fatal("settings must open on General") + } + want := "General,Models,Memory,Tasks,AI teams,Permissions,Spending,Connections,Privacy" + if strings.Join(settingTabs, ",") != want { + t.Fatalf("categories: %v", settingTabs) + } + for tab := range settingTabs { + a.sheet.tab = tab + a.sheet.build() + for _, item := range a.sheet.items { + if item.row.Key != "" && (item.row.ChatPresentation().Advanced || item.row.ChatPresentation().Hidden) { + t.Fatalf("collapsed category shows %s", item.row.Key) + } + } + } + a.sheet.query.setText(config.KeyPromptProfile) + a.sheet.build() + found := false + for _, item := range a.sheet.items { + found = found || item.row.Key == config.KeyPromptProfile + } + if !found { + t.Fatal("global search must find a collapsed advanced setting by its existing key") + } +} + +func TestSettingsOrganizationSidebarHitTargets(t *testing.T) { + a, _ := sheetApp(t) + a.width = 120 + a.height = 35 + a.openSettings() + _, hits, _, _ := a.sheetFrame(a.width, a.height) + target := -1 + for y, hit := range hits { + if hit.category == 3 { + target = y + break + } + } + if target < 0 { + t.Fatal("Memory category has no pointer target") + } + a.sheetPress(3, target) + if settingTabs[a.sheet.tab] != tabMemory { + t.Fatal("sidebar click did not select Memory") + } + cursor := a.sheet.cursor + a.sheetHoverAt(3, target) + if a.sheet.cursor != cursor || a.hoveredSheetRow() >= 0 { + t.Fatal("category hover changed or previewed a setting") + } + a.width = 80 + _, hits, _, _ = a.sheetFrame(a.width, a.height) + for _, hit := range hits { + if hit.category > 0 { + t.Fatal("compact frame retains sidebar targets") + } + } + if !strings.Contains(plain(frame(a)), tabMemory) { + t.Fatal("compact bar lost selected category") + } +} + +func TestSettingsOrganizationHintsPreserveExistingValue(t *testing.T) { + a, dir := sheetApp(t) + a.openSettings() + a.sheet.query.setText(config.KeyHints) + a.sheet.build() + item, ok := a.sheet.current() + if !ok || item.row.Key != config.KeyHints { + t.Fatal("hints search failed") + } + before := item.row.Value() + a.activate() + rows := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Rows() + for _, row := range rows { + if row.Key == config.KeyHints { + if row.Value() == before { + t.Fatal("toggle did not persist") + } + item.row = row + shown := plain(strings.Join(a.sheet.rowLines(item, true, false, 80, a.pal), "\n")) + positive := "on" + if row.Value() == "on" { + positive = "off" + } + if !strings.Contains(shown, positive) { + t.Fatalf("positive hint value absent: %s", shown) + } + return + } + } + t.Fatal("persisted hint row missing") +} + +func TestSettingsOrganizationConnectionSearchFromEveryCategory(t *testing.T) { + a, _ := capsApp(t, twoAccounts) + for tab := range settingTabs { + a.sheet.tab = tab + a.sheet.query.setText("Slack") + a.sheet.build() + found := false + for _, item := range a.sheet.items { + found = found || (item.conn != nil && item.conn.service == "slack") + } + if !found { + t.Fatalf("Slack cannot be found from %s", settingTabs[tab]) + } + } +} + +func TestSettingsOrganizationCaretAndMemoryDoor(t *testing.T) { + a, _ := sheetApp(t) + a.width = 120 + a.height = 35 + a.openSettings() + a.sheet.query.setText("mouse") + a.sheet.build() + _, _, x, y := a.sheetFrame(a.width, a.height) + if x < settingsSidebarWidth || y < 0 || !a.caret { + t.Fatalf("sidebar search caret misplaced: %d,%d visible=%v", x, y, a.caret) + } + a.sheet.query.reset() + for i, title := range settingTabs { + if title == tabMemory { + a.sheet.tab = i + } + } + a.sheet.build() + if len(a.sheet.items) == 0 || !a.sheet.items[0].memoryDoor { + t.Fatal("Memory has no inspect door") + } + a.sheet.cursor = 0 + a.activate() + if !a.at(pageMemory) { + t.Fatal("inspect door did not open saved memories") + } +} diff --git a/internal/tui3/settingssearch_test.go b/internal/tui3/settingssearch_test.go index 29b9c689c1..b8c258e79c 100644 --- a/internal/tui3/settingssearch_test.go +++ b/internal/tui3/settingssearch_test.go @@ -42,7 +42,7 @@ func TestTheSettingsSearchFindsARowByItsAboutLine(t *testing.T) { a, _ := sheetApp(t) a.openSettings() labels := searchLabels(t, a, "dangerous") - if !screenHas(labels, "ask before running") { + if !screenHas(labels, "tool approvals") { t.Fatalf("\"dangerous\" did not find the approval gate:\n%s", strings.Join(labels, "\n")) } } @@ -265,8 +265,8 @@ func TestTheSettingsSearchRanksItsBestAnswerFirst(t *testing.T) { if countdown < 0 || gate < 0 { t.Fatalf("\"approval\" found only %d of its two rows", countdown+gate+2) } - if countdown > gate { - t.Fatalf("the key hit (row %d) ranked above the label prefix (row %d)", gate, countdown) + if gate > countdown { + t.Fatalf("the direct tool approvals label (row %d) ranked below timer row %d", gate, countdown) } } diff --git a/internal/tui3/switcher_test.go b/internal/tui3/switcher_test.go index 95c74e562c..7c02b8a2b6 100644 --- a/internal/tui3/switcher_test.go +++ b/internal/tui3/switcher_test.go @@ -177,7 +177,7 @@ func TestTheSwitcherSinceYouLeftLedgerDrawsOnlyRecordedDoors(t *testing.T) { for _, row := range r.ledger { doors[row.place] = true } - for _, door := range []string{"standing", "memory", "sessions"} { + for _, door := range []string{"standing", "memory", "activity"} { if !doors[door] { t.Fatalf("%s was not a ledger door", door) } diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 65dc363101..9af9544613 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -1745,7 +1745,7 @@ func (r tasksReading) head(width int, edge bool) string { // tasksHeadWord names the place, in the word the switcher's own tab spells // (pages.go's [pageTasks]). One name for one place. -const sessionsWord = "sessions" +const sessionsWord = "activity" const tasksHeadWord = sessionsWord func tasksWindowStart(win session.UsageWindow) string { diff --git a/internal/tui3/teamcreate.go b/internal/tui3/teamcreate.go index e02a4d7748..83bb61492e 100644 --- a/internal/tui3/teamcreate.go +++ b/internal/tui3/teamcreate.go @@ -262,9 +262,9 @@ func (a *app) teamCreateOver(frame string) string { if inner < 28 || height < 14 { return frame } - title := "New team" + title := "New AI team" if s.parent != "" { - title = "New team in " + a.teamNameOf(s.parent) + title = "New AI team in " + a.teamNameOf(s.parent) } text := func(value string, field int) string { if s.field == field { diff --git a/internal/tui3/teammanager.go b/internal/tui3/teammanager.go index c2671f1164..a61a33bf4d 100644 --- a/internal/tui3/teammanager.go +++ b/internal/tui3/teammanager.go @@ -48,7 +48,7 @@ const ( // and `● harbor ▾ ◆ harbor` read as two harbors. const ( teamGlobalManagerSlotWord = "+ Global manager" - teamManagerSlotWord = "+ Manager" + teamManagerSlotWord = "+ AI manager" teamManagerWord = "Manager" ) diff --git a/internal/tui3/teammembership.go b/internal/tui3/teammembership.go index 888f7167f8..1db24028a4 100644 --- a/internal/tui3/teammembership.go +++ b/internal/tui3/teammembership.go @@ -454,9 +454,9 @@ func (a *app) teamMembershipOver(frame string) string { s.rect = wallRect{} return frame } - title := "Add member to " + t.Name + title := "Add chat to " + t.Name if s.choosingManager { - title = "Choose manager for " + t.Name + title = "Choose AI manager for " + t.Name } var lines []wallCardLine enterAction := "choose" diff --git a/internal/tui3/teammenu_test.go b/internal/tui3/teammenu_test.go index ea54db74a3..bf8d1f0a0b 100644 --- a/internal/tui3/teammenu_test.go +++ b/internal/tui3/teammenu_test.go @@ -188,7 +188,7 @@ func TestTeamMenuOffersOnlyOverlayChoices(t *testing.T) { a, _, _ := menuApp(t) a.openTeamMenu() frame, _ := menuFrame(t, a) - for _, word := range []string{"None", "Add this conversation", "Remove this conversation", "New team", "Team settings", "Make manager", "Closed"} { + for _, word := range []string{"None", "Add this conversation", "Remove this conversation", "New AI team", "Team settings", "Make manager", "Closed"} { if strings.Contains(frame, word) { t.Fatalf("management action %q remains in overlay picker", word) } diff --git a/internal/tui3/teamnest_test.go b/internal/tui3/teamnest_test.go index 051d02afcf..53a7cb653f 100644 --- a/internal/tui3/teamnest_test.go +++ b/internal/tui3/teamnest_test.go @@ -376,7 +376,7 @@ func TestNewTeamInTheChosenTeam(t *testing.T) { if a.wall.on || !a.tcreate.on || a.tcreate.parent != harbor { t.Fatalf("the new-team card is not for a team in harbor: on %v naming %v parent %q", a.wall.on, a.wall.naming, a.wall.nameParent) } - if !strings.Contains(teamsFrameText(a), "New team in harbor") { + if !strings.Contains(teamsFrameText(a), "New AI team in harbor") { t.Fatalf("the card does not say where the team goes:\n%s", teamsFrameText(a)) } a.tcreate.name.setText("slip") @@ -475,7 +475,7 @@ func TestTheTeamHeaderIsOneLineAndDropsInOrder(t *testing.T) { t.Fatalf("the sidebar lost the selected team at %d:\n%s", width, text) } if width >= 110 { - add := strings.Index(line, "+ Add member") + add := strings.Index(line, "+ Add chat") sub := strings.Index(line, "+ Add subteam") if add < 0 || sub <= add || !strings.Contains(line, "Disband") { t.Fatalf("the header lost its controls at %d: %q", width, line) diff --git a/internal/tui3/teamoverlay_test.go b/internal/tui3/teamoverlay_test.go index b178f8feca..2fd43308c7 100644 --- a/internal/tui3/teamoverlay_test.go +++ b/internal/tui3/teamoverlay_test.go @@ -142,7 +142,7 @@ func TestTeamMembershipControlsRemainVisibleAndDoNotOverlapConversationLinks(t * for _, width := range []int{40, 72, 100, 160} { a.width, a.height = width, 42 text := teamsFrameText(a) - if !strings.Contains(text, "+ Add member") || strings.Contains(text, "Actions") || !strings.Contains(text, "+ Add subteam") || !strings.Contains(text, "Choose manager") { + if !strings.Contains(text, "+ Add chat") || strings.Contains(text, "Actions") || !strings.Contains(text, "+ Add subteam") || !strings.Contains(text, "Choose AI manager") { t.Fatalf("membership controls missing at %d:\n%s", width, text) } for _, action := range a.tp.targets { diff --git a/internal/tui3/teamsall_test.go b/internal/tui3/teamsall_test.go index 1dc9162d15..9fa105f78b 100644 --- a/internal/tui3/teamsall_test.go +++ b/internal/tui3/teamsall_test.go @@ -84,7 +84,7 @@ func TestTeamsAllOverviewKeepsGlobalManagerAndDecisions(t *testing.T) { a.tp.previews[root.Manager] = teamsPreview{text: "Current global update"} a.tp.sel = teamsAllRow text := teamsFrameText(a) - for _, want := range []string{"@global", "Settings", "+ Add member", "Which layout first?", "Global decision", "Subteams"} { + for _, want := range []string{"@global", "Settings", "+ Add chat", "Which layout first?", "Global decision", "Subteams"} { if !strings.Contains(text, want) { t.Fatalf("missing %s:\n%s", want, text) } diff --git a/internal/tui3/teamsglobal.go b/internal/tui3/teamsglobal.go index 79b1521cc0..d0c27d3070 100644 --- a/internal/tui3/teamsglobal.go +++ b/internal/tui3/teamsglobal.go @@ -94,7 +94,7 @@ func (a *app) teamsGlobalManagerCard(d *teamsDraw, width, y int) []string { action(teamGlobalManagerSlotWord, teamsActRootManager, "", "", "Create the optional global manager conversation") } if hasManager { - action("+ Add member", teamsActAddMember, root.ID, "", "Add a conversation to the global manager team") + action("+ Add chat", teamsActAddMember, root.ID, "", "Add a conversation to the global manager team") action("Settings", teamsActSettings, root.ID, "", "Global manager settings and spending controls") } line, x := "", 0 diff --git a/internal/tui3/teamsglobal_test.go b/internal/tui3/teamsglobal_test.go index f4a1885ee0..25b39d2c14 100644 --- a/internal/tui3/teamsglobal_test.go +++ b/internal/tui3/teamsglobal_test.go @@ -66,12 +66,12 @@ func TestTeamsGlobalManagerCardLinksPreviewAndDeletesOnlyItsConversation(t *test a.tp.previews = map[string]teamsPreview{key: {text: "Actual saved update from the global manager"}} d := &teamsDraw{a: a} text := plain(strings.Join(a.teamsGlobalManagerCard(d, 90, 3), "\n")) - for _, word := range []string{"Global manager", "@global", "Actual saved update", "+ Add member", "Settings"} { + for _, word := range []string{"Global manager", "@global", "Actual saved update", "+ Add chat", "Settings"} { if !strings.Contains(text, word) { t.Fatalf("missing %s: %s", word, text) } } - for _, word := range []string{teamGlobalManagerSlotWord, "Choose manager"} { + for _, word := range []string{teamGlobalManagerSlotWord, "Choose AI manager"} { if strings.Contains(text, word) { t.Fatalf("populated global card has %s", word) } @@ -172,7 +172,7 @@ func TestTeamsGlobalManagerDeletionRefreshesWithoutOtherHeldMembers(t *testing.T a.tp.sel = teamsAllRow a.page = pageTeams text := plain(strings.Join(a.teamsGlobalManagerCard(&teamsDraw{a: a}, 100, 0), "\n")) - if strings.Contains(text, "+ Add member") || strings.Contains(text, "Settings") || strings.Contains(text, "Choose manager") { + if strings.Contains(text, "+ Add chat") || strings.Contains(text, "Settings") || strings.Contains(text, "Choose AI manager") { t.Fatal("deleted manager card retained controls") } if !strings.Contains(text, teamGlobalManagerSlotWord) || strings.Contains(text, "Conversation unavailable") { @@ -339,7 +339,7 @@ func TestEmptyTeamsPageOffersGlobalCreationOnlyInItsCard(t *testing.T) { } label := "Organize" if target.act == teamsActNewTeam { - label = "New team" + label = "New AI team" } if !strings.Contains(ansi.Cut(rows[target.y], target.x0, target.x1), label) { t.Fatalf("empty-page %s hit misses visible label: %+v", label, target) diff --git a/internal/tui3/teamsoverview.go b/internal/tui3/teamsoverview.go index fc5b0c5435..6b1d6c4780 100644 --- a/internal/tui3/teamsoverview.go +++ b/internal/tui3/teamsoverview.go @@ -164,7 +164,7 @@ func (a *app) teamsOverviewHeader(d *teamsDraw, t team, width, y int) string { } left = fit(left, leftRoom) if !t.Closed() { - for _, c := range []control{{"+ Add member", teamsActAddMember}, {"+ Add subteam", teamsActAddSubteam}, {"Choose manager", teamsActChooseManager}} { + for _, c := range []control{{"+ Add chat", teamsActAddMember}, {"+ Add subteam", teamsActAddSubteam}, {"Choose AI manager", teamsActChooseManager}} { if t.Root && c.act == teamsActAddSubteam { continue } diff --git a/internal/tui3/teamspage.go b/internal/tui3/teamspage.go index 86ef1519dd..9a1eb28e7e 100644 --- a/internal/tui3/teamspage.go +++ b/internal/tui3/teamspage.go @@ -34,10 +34,10 @@ const teamsInboxWhole = 3 // The page's words, quoted in the manual exactly as spelled here. const ( - teamsExplainWord = "A team is a set of conversations you run together; give it a manager and you talk to the manager, which hands out the work and asks you only what it cannot decide." - teamsNoManagerWord = "a manager takes your messages to the team and asks you only what it cannot decide" + teamsExplainWord = "An AI team groups chats around ongoing work. Add an optional AI manager to coordinate them." + teamsNoManagerWord = "an optional AI manager coordinates these chats and brings questions to you" teamsOrganizeWord = "Organize my conversations" - teamsNewTeamWord = "New team" + teamsNewTeamWord = "New AI team" teamsHostedWord = "the inbox and the spend are not available over this connection" ) diff --git a/internal/tui3/teamspage_test.go b/internal/tui3/teamspage_test.go index 7e286856dd..a98d9d95cf 100644 --- a/internal/tui3/teamspage_test.go +++ b/internal/tui3/teamspage_test.go @@ -101,7 +101,7 @@ func TestTeamsIsTheSecondPlaceOnTheBarTheDigitsAndTheCommand(t *testing.T) { } a := placeApp(t) bar := navPlaces(a, 120, false) - if !placeWordsInOrder(bar, "home", "teams", "chats", "sessions") { + if !placeWordsInOrder(bar, "home", "chats", "AI teams", "activity") { t.Fatalf("the bar does not put teams after home: %q", bar) } drive(t, a, key("alt+2")) @@ -122,7 +122,7 @@ func TestTeamsWithNoTeamsSaysWhatTheyAreAndOffersTwoWays(t *testing.T) { a.width, a.height = 110, 24 drive(t, a, key("alt+2")) text := teamsFrameText(a) - for _, want := range []string{"A team is a set of conversations", teamsOrganizeWord, teamsNewTeamWord} { + for _, want := range []string{"An AI team groups chats", teamsOrganizeWord, teamsNewTeamWord} { if !strings.Contains(text, want) { t.Fatalf("the empty page lost %q:\n%s", want, text) } diff --git a/internal/tui3/teamspagedraw.go b/internal/tui3/teamspagedraw.go index 255f8f6cd6..bbd6f7141b 100644 --- a/internal/tui3/teamspagedraw.go +++ b/internal/tui3/teamspagedraw.go @@ -362,7 +362,7 @@ func (a *app) teamsTop(d *teamsDraw, width int) []string { for _, control := range []struct { word string act teamsAct - }{{"+ Add member", teamsActAddMember}, {"+ Add subteam", teamsActAddSubteam}, {"Choose manager", teamsActChooseManager}} { + }{{"+ Add chat", teamsActAddMember}, {"+ Add subteam", teamsActAddSubteam}, {"Choose AI manager", teamsActChooseManager}} { if t.Root && control.act == teamsActAddSubteam { continue } diff --git a/internal/tui3/teamspreview_test.go b/internal/tui3/teamspreview_test.go index 52d07960d7..c60d8d1c41 100644 --- a/internal/tui3/teamspreview_test.go +++ b/internal/tui3/teamspreview_test.go @@ -142,7 +142,7 @@ func TestTeamsMemberAndGlobalPreviewsUseTheSameLatestExchange(t *testing.T) { d = &teamsDraw{a: a} rows := a.teamsGlobalManagerCard(d, 120, 3) text = plain(strings.Join(rows, "\n")) - for _, want := range []string{a.pal.youGlyph() + "Global prompt", "Global reply begins.", "? Review global work", "+ Add member", "Settings"} { + for _, want := range []string{a.pal.youGlyph() + "Global prompt", "Global reply begins.", "? Review global work", "+ Add chat", "Settings"} { if !strings.Contains(text, want) { t.Fatalf("global missing %q: %s", want, text) } diff --git a/internal/tui3/topnav_test.go b/internal/tui3/topnav_test.go index ec12b2ebd7..aabdff6cb5 100644 --- a/internal/tui3/topnav_test.go +++ b/internal/tui3/topnav_test.go @@ -443,10 +443,10 @@ func TestTheCountThatWantsYouNeverLeavesTheNav(t *testing.T) { a.width = 80 a.navMemo = navMemo{} row := plain(a.navLine(80, a.pal)) - if !placeWordsInOrder(row, "home", "teams", "chats", "sessions", "spend", "settings") || a.navMore.span.pressable() { + if !placeWordsInOrder(row, "home", "chats", "AI teams", "activity", "memory", "more") || !a.navMore.span.pressable() { t.Fatalf("at 80 the short count cost a place: %q", row) } - if !strings.HasSuffix(strings.TrimRight(row, " "), "2 ? · $1.20 / "+railFigure(20)) { + if !strings.HasSuffix(strings.TrimRight(row, " "), "2 ? · $1.20") { t.Fatalf("at 80 the pulse is not the short count and the allowance: %q", row) } // AND NOTHING WANTING YOU DRAWS NOTHING: no `0 ?`. diff --git a/internal/tui3/wallview_test.go b/internal/tui3/wallview_test.go index f46ced890c..30f734d621 100644 --- a/internal/tui3/wallview_test.go +++ b/internal/tui3/wallview_test.go @@ -311,14 +311,14 @@ func TestWallRenderStates(t *testing.T) { v.choices = teamHueChoices(wallViewHues(v), teamReservedFrom(darkRamp), 6) rows, _ = renderWall(pal, v, 120, 40) card := wallPlainFrame(rows) - for _, want := range []string{"─ New team ─", "Name night▌", "Colour ◉ ● ● ● ● ●", "2 · ship the port, crew reprice", "esc cancel", "enter create", "ctrl+r shuffle"} { + for _, want := range []string{"─ New AI team ─", "Name night▌", "Colour ◉ ● ● ● ● ●", "2 · ship the port, crew reprice", "esc cancel", "enter create", "ctrl+r shuffle"} { if !strings.Contains(card, want) { t.Errorf("the new-team card lacks %q\n%s", want, card) } } // Too short for the card, the prompt falls back to the Teams row. rows, _ = renderWall(pal, v, 120, 9) - if got := ansi.Strip(rows[1]); !strings.Contains(got, "New team › night▌") || !strings.Contains(got, "2 picked") { + if got := ansi.Strip(rows[1]); !strings.Contains(got, "New AI team › night▌") || !strings.Contains(got, "2 picked") { t.Errorf("naming row: %q", got) } rows, hits = renderWall(pal, v, 20, 9) From 8223a8cb2b2bb61f70fd82b68eda9797ea4269ec Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:18:38 -0400 Subject: [PATCH 02/31] Align manual truth checks with the new settings categories --- internal/manual/chat/commands.md | 6 ++++++ internal/manual/chat/models-and-cost.md | 2 +- internal/manual/chatdisplaytruth_test.go | 4 ++-- internal/manual/truth_test.go | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index aee1cf9a74..04d7334ed9 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1960,6 +1960,12 @@ key. While any of those layers is up — the value box, the model picker, the key box on the Connections tab — the foot drops `tab next place`, because the layer has taken that key. +## Task model seats and their limits in Settings + +Models → Advanced includes the seats door to `/crew`. Its default summary is +`per task $5 · crew daily cap $5.00`; actual saved limits appear when configured. +Change those limits in `/crew`, which owns them, rather than a duplicate setting. + ## The roles rows in settings — pinning a role, and del to unpin The **roles** list is in **Models → Advanced**, alongside "pinned roles". Each row is diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 28b4676133..69d55f662b 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2991,7 +2991,7 @@ turn ended, the same moment `today` does. **Three are rows you can edit** — daily spending, per-chat spending and the plan approval threshold. `per task` and `per standing run` are **readings**: they are real rails, and neither is a number a settings row could hold. The sections below say why. -Open **Spending** for monetary limits. Resident practice settings are absent from chat settings because they do not control this chat surface. +Open **Spending** for monetary limits. Resident practice settings are absent from chat settings because they do not control this chat surface. The separate resident’s practice default is `$50 of the day`; it is not a chat setting. ## Where are the spending limits — the Spending tab, and every door onto it diff --git a/internal/manual/chatdisplaytruth_test.go b/internal/manual/chatdisplaytruth_test.go index 7c0f8f490d..fce1c6b799 100644 --- a/internal/manual/chatdisplaytruth_test.go +++ b/internal/manual/chatdisplaytruth_test.go @@ -97,8 +97,8 @@ func TestManualStatesHelpSettingsGreetingAndManagerDisplayTruth(t *testing.T) { }{ {"commands", "codeaf --help — asking one command what it takes, which is not a failure", []string{"run `codeaf --help` for every command, `codeaf help env` for the variables."}}, {"commands", "codeaf --help, and --help on any command — what does this command take, what are its flags, how do I see the usage", []string{"`codeaf help env`", "every variable and its default"}}, - {"commands", "The ten settings tabs", []string{"Session · Context · Workspace · Display · Spending · Safety · Tasks · Teams · Providers · Connections"}}, - {"models-and-cost", "Where are the spending limits — the Spending tab, and every door onto it", []string{"Tasks · Teams · Providers"}}, + {"commands", "Settings categories — where did Session, Context, Workspace, Display and Providers go", []string{"General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Privacy", "Old saved keys and values are unchanged"}}, + {"models-and-cost", "Where are the spending limits — the Spending tab, and every door onto it", []string{"Tasks · AI teams · Permissions"}}, {"getting-started", "Getting started — first time setup, what happens the first time I run codeaf", []string{"nothing elsewhere", "What would you like to work on?", "home", "`--no-host`"}}, {"team-manager", "Making a manager", []string{"team's name", "`@second`", "`@lead`", "All teams"}}, } { diff --git a/internal/manual/truth_test.go b/internal/manual/truth_test.go index da3a8ce197..e18ae2e45f 100644 --- a/internal/manual/truth_test.go +++ b/internal/manual/truth_test.go @@ -349,7 +349,7 @@ func quotedFacts(t *testing.T) []quotedFact { }, { fact: "what practice may take of the day", owner: "config.DefaultPracticeBudgetUSD", value: dollarsOwed(config.DefaultPracticeBudgetUSD), - quotes: []quotedIn{{"models-and-cost", "| **practice** | `$%s of the day` |"}}, + quotes: []quotedIn{{"models-and-cost", "The separate resident’s practice default is `$%s of the day`"}}, }, { // The pass, which the page states in words because a person asking how // often their watch checks is not asking for a duration. From 2e2cadd294d874a015749a82e3d30b3c0edb299c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:21:30 -0400 Subject: [PATCH 03/31] Keep settings details visible and protect modal pointer input --- internal/tui3/place_settings.go | 92 +++++++++++++-------- internal/tui3/settings.go | 7 +- internal/tui3/settings_organization_test.go | 80 ++++++++++++++++++ 3 files changed, 144 insertions(+), 35 deletions(-) diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index d1e158bbee..aaca789e8b 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -104,7 +104,7 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { } rows = append(rows, placeRow{}) if s.sel == nil && s.edit == nil && s.conn.entry == nil { - filter, _, _ := draftBlock(&s.query, pal, width-2, 1, "Search all settings and connections", "") + filter, _, _ := draftBlock(&s.query, pal, width-2, 1, "Search settings and services", "") for _, line := range filter { rows = append(rows, placeRow{text: " " + line}) } @@ -129,6 +129,14 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { } return rows } + // Details occupy actual body rows: the shared note is deliberately a single + // rule caption and cannot carry wrapped explanations. Reserve constant room + // so changing focus never moves a control under the pointer. + detailRoom := 0 + if s.edit == nil && s.conn.entry == nil { + detailRoom = min(6, max(0, room-3)) + } + room -= detailRoom body, owner := s.listLines(width, room, pal, a.hoveredSheetRow()) at := s.cursorLine(owner) // THE CURSOR'S ROW IS SCROLLED IN WHOLE. At [tierPhone] it is two lines — @@ -153,6 +161,17 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { } rows = append(rows, placeRow{text: body[index], hit: hit}) } + if detailRoom > 0 { + rows = append(rows, placeRow{}) + details := settingsDetails(a, width) + for i := 0; i < detailRoom-1; i++ { + line := "" + if i < len(details) { + line = " " + pal.dim(details[i]) + } + rows = append(rows, placeRow{text: line}) + } + } return rows } @@ -220,43 +239,50 @@ func (placeSettings) note(a *app, width int) []string { } return out } - item, ok := a.sheet.current() - if at := a.hoveredSheetRow(); at >= 0 && at < len(a.sheet.items) { - item, ok = a.sheet.items[at], true - } + item, ok := settingsFocused(a) scope := a.sheet.footNote() - about := "" - if ok { - about = item.meta.about - if item.conn != nil { - about = connAbout(item.conn) - } - if item.row.Key != "" && !(a.sheet.host != "" && item.meta.tab == tabTeams) { - info := item.row.ChatPresentation() - facts := []string{} - if info.Scope != "" { - facts = append(facts, info.Scope) - } - if info.Activation != "" { - facts = append(facts, info.Activation) - } - if len(facts) > 0 { - scope = strings.Join(facts, " · ") + if ok && item.row.Key != "" { + if name, pinned := item.row.PinnedBy(); pinned { + scope = "held by " + name + " — unset it to change this here" + } else if !(a.sheet.host != "" && item.meta.tab == tabTeams) { + if own := item.row.ChatPresentation().Scope; own != "" { + scope = own } } } - out := []string{" " + pal.dim(noteFit(scope, width-2))} - details := settingAboutLines(about, width) - // Reserve the same detail space on every row. Hover and keyboard selection - // explain the same control without moving another control under the pointer. - for i := 0; i < settingAboutRows; i++ { - line := "" - if i < len(details) { - line = " " + pal.dim(details[i]) + return []string{" " + pal.dim(noteFit(scope, width-2))} +} + +// settingsFocused uses the same target for pointer and keyboard explanations. +func settingsFocused(a *app) (sheetItem, bool) { + if at := a.hoveredSheetRow(); at >= 0 && at < len(a.sheet.items) { + return a.sheet.items[at], true + } + return a.sheet.current() +} + +func settingsDetails(a *app, width int) []string { + item, ok := settingsFocused(a) + if !ok { + return nil + } + about := item.meta.about + if item.conn != nil { + about = connAbout(item.conn) + } + lines := []string{} + if item.row.Key != "" { + info := item.row.ChatPresentation() + if info.Activation != "" { + lines = append(lines, wrap(info.Activation, max(1, width-4))...) } - out = append(out, line) } - return out + lines = append(lines, wrap(about, max(1, width-4))...) + if len(lines) > 5 { + lines = lines[:5] + lines[4] = fit(lines[4]+glyphMore, max(1, width-4)) + } + return lines } func (placeSettings) about() string { return "how this machine is set" } @@ -322,7 +348,7 @@ func (placeSettings) caretRow(a *app, width int, rows []placeRow) (int, int, boo if a.sheet.edit != nil { return 0, 0, false } - box, placeholder := &a.sheet.query, "Search all settings and connections" + box, placeholder := &a.sheet.query, "Search settings and services" if a.sheet.sel != nil { box, placeholder = &a.sheet.sel.pick.filter, "type to filter" } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index d51ea53df5..695a31f108 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -846,7 +846,7 @@ func (s *sheet) finishCategory() { title := settingTabs[s.tab] shown := make([]sheetItem, 0, len(s.items)) if title == tabMemory { - shown = append(shown, sheetItem{memoryDoor: true, meta: settingMeta{label: "View saved memories", about: "Inspect remembered information, correct it, or forget it."}}) + shown = append(shown, sheetItem{memoryDoor: true, meta: settingMeta{label: "View saved memories", about: "Inspect, correct or remove saved memories."}}) } advanced := make([]sheetItem, 0) catalog := make([]sheetItem, 0) @@ -3084,7 +3084,7 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { cmd = tea.Batch(cmd, bind) } }() - if a.sheet.conn.entry != nil { + if a.sheet.conn.entry != nil || a.sheet.edit != nil { // A BOX BEING TYPED INTO IS NOT A LIST. Every press is swallowed and none // of them acts — esc is the way out, which is the way out of every box on // this surface (connectpanel.go's [app.connectPanelPress] says it first). @@ -3096,6 +3096,9 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { return nil } hit := hits[y] + if a.sheet.sel != nil && hit.kind != sheetHitOption { + return nil + } if a.sheetSidebar(width, height) && x < settingsSidebarWidth { if hit.category > 0 && a.sheet.sel == nil && a.sheet.edit == nil { a.sheet.tabBy(hit.category - 1 - a.sheet.tab) diff --git a/internal/tui3/settings_organization_test.go b/internal/tui3/settings_organization_test.go index cd0480ae66..ad3346911f 100644 --- a/internal/tui3/settings_organization_test.go +++ b/internal/tui3/settings_organization_test.go @@ -149,3 +149,83 @@ func TestSettingsOrganizationCaretAndMemoryDoor(t *testing.T) { t.Fatal("inspect door did not open saved memories") } } + +func TestSettingsOrganizationRenderedDetailsAndPinnedScope(t *testing.T) { + a, _ := sheetApp(t) + a.width, a.height = 80, 30 + t.Setenv("CODEAF_DAILY_BUDGET", "22") + a.openSettings() + cursorTo(t, a, config.KeyMemoryEnabled) + visible := strings.Join(strings.Fields(plain(frame(a))), " ") + for _, want := range []string{"Restart the CLI to apply this to already-open chats", "Turning this off does not delete saved memories."} { + if !strings.Contains(visible, want) { + t.Fatalf("critical memory detail absent from rendered 80-column frame: %s\n%s", want, visible) + } + } + cursorTo(t, a, config.KeyDailyBudget) + visible = strings.Join(strings.Fields(plain(frame(a))), " ") + if !strings.Contains(visible, "held by CODEAF_DAILY_BUDGET") || !strings.Contains(visible, "unset it to change this here") { + t.Fatalf("pinned scope remedy absent: %s", visible) + } +} + +func TestSettingsOrganizationHoverKeepsRenderedTargetsStable(t *testing.T) { + a, _ := sheetApp(t) + a.width, a.height = 80, 30 + a.openSettings() + cursorTo(t, a, config.KeyHints) + _, before, _, _ := a.sheetFrame(a.width, a.height) + hovered := -1 + for y, hit := range before { + if hit.kind == sheetHitRow && hit.index != a.sheet.cursor { + hovered = y + break + } + } + if hovered < 0 { + t.Fatal("no second visible row") + } + a.sheetHoverAt(3, hovered) + _, after, _, _ := a.sheetFrame(a.width, a.height) + for y, hit := range before { + if hit.kind == sheetHitRow && (after[y].kind != hit.kind || after[y].index != hit.index) { + t.Fatalf("hover moved target at row %d", y) + } + } +} + +func TestSettingsOrganizationEditorsOwnPointer(t *testing.T) { + a, _ := sheetApp(t) + a.openSettings() + cursorTo(t, a, config.KeyDailyBudget) + a.activate() + if a.sheet.edit == nil { + t.Fatal("budget did not open editor") + } + tab := a.sheet.tab + _, hits, _, _ := a.sheetFrame(a.width, a.height) + for y, hit := range hits { + if hit.kind == sheetHitTabs || hit.kind == sheetHitRow { + a.sheetPress(2, y) + } + } + if a.sheet.tab != tab || a.sheet.edit == nil { + t.Fatal("underlying click escaped text editor") + } + a.sheet.edit = nil + cursorTo(t, a, config.ModelSettingKey(talkSlot)) + a.activate() + if a.sheet.sel == nil { + t.Fatal("chat model did not open picker") + } + tab = a.sheet.tab + _, hits, _, _ = a.sheetFrame(a.width, a.height) + for y, hit := range hits { + if hit.kind == sheetHitTabs { + a.sheetPress(2, y) + } + } + if a.sheet.tab != tab || a.sheet.sel == nil { + t.Fatal("underlying category click escaped model picker") + } +} From cba82e7ebec0cee196e15c205dae8e7b86a8297e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:21:44 -0400 Subject: [PATCH 04/31] Preserve typed navigation routes after display label changes --- internal/tui3/pages.go | 26 ++++++++++++++++++++++---- internal/tui3/placelaws_test.go | 28 +++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index ff5914edbc..e7365f3785 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -535,20 +535,38 @@ func (p page) counted() bool { // at all — `s` is `standing`, `spend` and `settings` at once, and // offering the first of those would be the surface guessing. So an ambiguous // prefix offers nothing, and the person types one more letter. +// placeNames keeps older typed destinations working when their display label changes. +// These are navigation words, not slash-command aliases: /sessions still resumes a chat. +func placeNames(id page) []string { + names := []string{strings.ToLower(id.word())} + switch id { + case pageTeams: + names = append(names, "teams") + case pageTasks: + names = append(names, "sessions") + } + return names +} + func parsePageWord(s string) (page, bool) { word := strings.ToLower(strings.TrimSpace(s)) if word == "" { return 0, false } for _, id := range pages() { - if id.word() == word { - return id, true + for _, name := range placeNames(id) { + if name == word { + return id, true + } } } found, count := page(0), 0 for _, id := range pages() { - if strings.HasPrefix(id.word(), word) { - found, count = id, count+1 + for _, name := range placeNames(id) { + if strings.HasPrefix(name, word) { + found, count = id, count+1 + break // Several aliases for one destination are still one match. + } } } return found, count == 1 diff --git a/internal/tui3/placelaws_test.go b/internal/tui3/placelaws_test.go index e20e919771..219c56cbcd 100644 --- a/internal/tui3/placelaws_test.go +++ b/internal/tui3/placelaws_test.go @@ -148,7 +148,12 @@ func TestEveryPlaceIsRegisteredOnceAndInTabOrder(t *testing.T) { // registering. func TestOnePlaceOneFile(t *testing.T) { for _, id := range pages() { - name := "place_" + id.word() + ".go" + // Source identity stays stable when product wording changes. + sourceName := id.word() + if legacy, ok := map[page]string{pageTeams: "teams", pageTasks: "sessions"}[id]; ok { + sourceName = legacy + } + name := "place_" + sourceName + ".go" body, err := os.ReadFile(name) if err != nil { t.Errorf("the %s place has no %s: %v", id.word(), name, err) @@ -576,3 +581,24 @@ func TestEachVerbWordIsSpelledOnce(t *testing.T) { } } } + +func TestPlaceNavigationNamesPreserveAliases(t *testing.T) { + for _, c := range []struct { + word string + want page + }{ + {"AI teams", pageTeams}, {"ai teams", pageTeams}, {" AI TEAMS ", pageTeams}, + {"teams", pageTeams}, {"team", pageTeams}, {"ai", pageTeams}, + {"Activity", pageTasks}, {"activity", pageTasks}, {"act", pageTasks}, + {"sessions", pageTasks}, {"sess", pageTasks}, + } { + if got, ok := parsePageWord(c.word); !ok || got != c.want { + t.Errorf("%q = %v, %v; want %v", c.word, got, ok, c.want) + } + } + for _, word := range []string{"", "s", "unknown"} { + if _, ok := parsePageWord(word); ok { + t.Errorf("%q resolved despite being empty, ambiguous, or unknown", word) + } + } +} From b9204f354bd659c64178d8854d73310f914163e7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:23:45 -0400 Subject: [PATCH 05/31] Keep invalid settings drafts open for correction --- internal/tui3/place_settings.go | 15 +++++---- internal/tui3/settings.go | 37 ++++++++++++++++----- internal/tui3/settings_organization_test.go | 35 +++++++++++++++++++ 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index aaca789e8b..a3a2f2a6ee 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -223,6 +223,13 @@ func (placeSettings) box(a *app) *editor { func (placeSettings) note(a *app, width int) []string { pal := a.pal switch { + case a.sheet.msg != "": + lines := strings.Split(a.sheet.msg, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + out = append(out, " "+pal.bad(noteFit(line, width-2))) + } + return out case a.sheet.sel != nil: return []string{" " + pal.dim(noteFit(a.sheet.sel.label, width-2))} case a.sheet.edit != nil: @@ -231,13 +238,7 @@ func (placeSettings) note(a *app, width int) []string { // is typing in is THE composer, so what is left here is the one thing the // box cannot say — which setting this is. return []string{" " + pal.dim(noteFit(a.sheet.edit.label, width-2))} - case a.sheet.msg != "": - lines := strings.Split(a.sheet.msg, "\n") - out := make([]string, 0, len(lines)) - for _, line := range lines { - out = append(out, " "+pal.bad(noteFit(line, width-2))) - } - return out + } item, ok := settingsFocused(a) scope := a.sheet.footNote() diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 695a31f108..6a05887d03 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1073,10 +1073,11 @@ type sheet struct { // sheetEdit is the one-line text submenu. type sheetEdit struct { - key string - label string - secret bool - box editor + pending bool // A remote write keeps the draft until its acknowledgement. + key string + label string + secret bool + box editor } // sheetEditNote is what the panel says ABOUT the box a value is being typed @@ -2684,7 +2685,7 @@ const gateNextSessionWord = "saved" + nextSessionWord // registry without ever writing it ([setupReviewKeys]) — and both run before this // window has a conversation to push a gate into. A push from there would be a // seam called with nothing on the other end. -func (a *app) applySetting(item sheetItem, raw string) { +func (a *app) applySetting(item sheetItem, raw string) bool { if item.row.Key == config.KeySpendRail && !a.railRead { // The panel's file write must not make the status line's first lazy // read claim an active limit the engine has not accepted yet. @@ -2692,7 +2693,7 @@ func (a *app) applySetting(item sheetItem, raw string) { } if err := item.row.Apply(raw); err != nil { a.sheet.msg = err.Error() - return + return false } if item.row.Key == config.KeyWork { a.workMode = config.WorkAt(a.profileDir) @@ -2776,6 +2777,7 @@ func (a *app) applySetting(item sheetItem, raw string) { a.sheet.msg = note a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() + return true } // sheetEditKey drives the text submenu. enter saves, an empty box clears the @@ -2795,10 +2797,14 @@ func (a *app) sheetEditKey(msg tea.KeyPressMsg) tea.Cmd { switch msg.String() { case "esc": s.edit = nil + s.msg = "" case "enter": + if edit.pending { + return nil + } + s.msg = "" row, ok := s.registry.Row(edit.key) raw := edit.box.String() - s.edit = nil if !ok { return nil } @@ -2807,7 +2813,9 @@ func (a *app) sheetEditKey(msg tea.KeyPressMsg) tea.Cmd { if cmd := a.writeHostTeamDefault(item, raw); cmd != nil || s.hostTeamRow(item) { return cmd } - a.applySetting(item, raw) + if a.applySetting(item, raw) { + s.edit = nil + } case "backspace": edit.box.deleteBackward() case "delete": @@ -2949,6 +2957,7 @@ func (a *app) writeHostTeamDefault(item sheetItem, raw string) tea.Cmd { return nil } if a.sheet.farTeams == nil { + a.sheet.msg = "waiting for this machine’s AI team defaults" return nil } door := a.teamsDisk.door.ApplyDefault @@ -2957,18 +2966,30 @@ func (a *app) writeHostTeamDefault(item sheetItem, raw string) tea.Cmd { return nil } key := item.row.Key + edit := a.sheet.edit + if edit != nil { + edit.pending = true + } return a.offLoop(func() func(bool) tea.Cmd { d, err := door(key, raw) return func(here bool) tea.Cmd { if !here || !a.at(pageSettings) { return nil } + if edit != nil && a.sheet.edit == edit { + edit.pending = false + } if err != nil { a.sheet.msg = err.Error() } else { copied := d a.sheet.farTeams = &copied a.sheet.msg = "" + // Never close a replacement editor or discard text typed while the host + // was answering this earlier draft. + if edit != nil && a.sheet.edit == edit && edit.box.String() == raw { + a.sheet.edit = nil + } } a.sheet.build() a.touch() diff --git a/internal/tui3/settings_organization_test.go b/internal/tui3/settings_organization_test.go index ad3346911f..6153a791be 100644 --- a/internal/tui3/settings_organization_test.go +++ b/internal/tui3/settings_organization_test.go @@ -229,3 +229,38 @@ func TestSettingsOrganizationEditorsOwnPointer(t *testing.T) { t.Fatal("underlying category click escaped model picker") } } + +func TestSettingsOrganizationInvalidEditKeepsDraft(t *testing.T) { + a, _ := sheetApp(t) + a.openSettings() + cursorTo(t, a, config.KeyTaskParallel) + row, _ := a.sheet.registry.Row(config.KeyTaskParallel) + before := row.Value() + a.activate() + if a.sheet.edit == nil { + t.Fatal("integer setting did not open editor") + } + a.sheet.edit.box.setText("not-a-number") + a.sheetEditKey(key("enter")) + if a.sheet.edit == nil || a.sheet.edit.box.String() != "not-a-number" { + t.Fatal("validation discarded the editable draft") + } + if row.Value() != before { + t.Fatal("invalid input changed persisted setting") + } + if a.sheet.msg == "" || !strings.Contains(plain(frame(a)), a.sheet.msg) { + t.Fatalf("validation error not visible while editor is open: %q", a.sheet.msg) + } + a.sheet.edit.box.setText("3") + a.sheetEditKey(key("enter")) + if a.sheet.edit != nil || row.Value() != "3" { + t.Fatal("correcting the draft did not save and close") + } + cursorTo(t, a, config.KeyTaskParallel) + a.activate() + a.sheet.edit.box.setText("4") + a.sheetEditKey(key("esc")) + if a.sheet.edit != nil || row.Value() != "3" || a.sheet.msg != "" { + t.Fatal("Escape did not cancel draft cleanly") + } +} From 64c175d8f167b448b3a242d173e1984c81129758 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:24:11 -0400 Subject: [PATCH 06/31] Use plain settings search wording --- internal/tui3/place_settings.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index a3a2f2a6ee..74a6dc1f2c 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -104,7 +104,7 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { } rows = append(rows, placeRow{}) if s.sel == nil && s.edit == nil && s.conn.entry == nil { - filter, _, _ := draftBlock(&s.query, pal, width-2, 1, "Search settings and services", "") + filter, _, _ := draftBlock(&s.query, pal, width-2, 1, "Search all settings", "") for _, line := range filter { rows = append(rows, placeRow{text: " " + line}) } @@ -349,7 +349,7 @@ func (placeSettings) caretRow(a *app, width int, rows []placeRow) (int, int, boo if a.sheet.edit != nil { return 0, 0, false } - box, placeholder := &a.sheet.query, "Search settings and services" + box, placeholder := &a.sheet.query, "Search all settings" if a.sheet.sel != nil { box, placeholder = &a.sheet.sel.pick.filter, "type to filter" } From 725336c4c923b70892d27b35d309ef91ee24c4cb Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:25:22 -0400 Subject: [PATCH 07/31] Document settings behavior and compatibility for PR 1792 --- .../changes/unreleased/1792-settings-clarity.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/changes/unreleased/1792-settings-clarity.md diff --git a/docs/changes/unreleased/1792-settings-clarity.md b/docs/changes/unreleased/1792-settings-clarity.md new file mode 100644 index 0000000000..b91f36cb68 --- /dev/null +++ b/docs/changes/unreleased/1792-settings-clarity.md @@ -0,0 +1,17 @@ +--- +kind: changed +title: Clear settings categories and AI team navigation without resetting preferences +pr: 1792 +surface: [chat, engine, docs] +invalidates: + - "Chat settings used ten mixed tabs. Nine purpose-based categories now use a sidebar in wide terminals, a compact bar in narrow ones, searchable advanced controls and service options." + - "Teams could imply human collaboration and Sessions obscured delegated work. The navigation now says AI teams and Activity, keeps Memory visible and preserves numbered shortcuts, typed aliases and saved visit keys." + - "Resident-only controls and unwritable legacy model slots appeared in chat settings. They are omitted from this surface while their stored values and the resident registry remain intact." + - "Hover descriptions could move controls and mouse clicks could reach behind editors. Details now occupy stable rows; editors protect their input and retain invalid values for correction." + - "The document-reader preference was saved but not carried into the live chat engine. New CLI launches now pass the resolved preference into the session and its tasks." + - "Show hints formerly appeared as a negative disable question. The positive label preserves the existing persisted preference; profile keys and unknown fields are not migrated or reset." +--- + +Settings explain scope, environment overrides and known activation boundaries. +Already-open chats still need a CLI restart for settings captured at startup, +including memory activation. The built-in manual describes those limits. From af185ff31a7c58eff699b27b0914cca6123d3893 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:31:49 -0400 Subject: [PATCH 08/31] Render settings text editors and their masked caret --- internal/tui3/place_settings.go | 35 +++++++++++++++++++- internal/tui3/settings_organization_test.go | 36 +++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 74a6dc1f2c..428747edd9 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -103,6 +103,17 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { rows = append(rows, placeRow{text: sheetTabBar(width, s.tab, pal), hit: sheetHit{kind: sheetHitTabs}}) } rows = append(rows, placeRow{}) + if s.edit != nil { + rows = append(rows, placeRow{text: " " + pal.dim(fit(s.edit.label, max(1, width-4)))}) + block, _, _ := s.editBlock(width, pal) + for _, line := range block { + rows = append(rows, placeRow{text: " " + line}) + } + for len(rows) < room { + rows = append(rows, placeRow{}) + } + return rows + } if s.sel == nil && s.edit == nil && s.conn.entry == nil { filter, _, _ := draftBlock(&s.query, pal, width-2, 1, "Search all settings", "") for _, line := range filter { @@ -347,7 +358,15 @@ func (placeSettings) caretRow(a *app, width int, rows []placeRow) (int, int, boo content := func(text string) string { return ansi.Strip(ansi.Cut(text, offset, offset+width)) } if entry == nil { if a.sheet.edit != nil { - return 0, 0, false + block, column, at := a.sheet.editBlock(width, a.pal) + if at >= 0 && at < len(block) { + for j, row := range rows { + if content(row.text) == ansi.Strip(" "+block[at]) { + return j, column + 1, true + } + } + } + return -1, 0, true } box, placeholder := &a.sheet.query, "Search all settings" if a.sheet.sel != nil { @@ -388,3 +407,17 @@ func (placeSettings) caretRow(a *app, width int, rows []placeRow) (int, int, boo // person is typing into. return -1, 0, true } + +// editBlock is the one rendering of an editable setting and its caret. Secret +// text is masked in a copy, so neither drawing nor cursor movement exposes it. +func (s *sheet) editBlock(width int, pal palette) ([]string, int, int) { + box := s.edit.box + if s.edit.secret { + mask := "•" + if pal.ascii { + mask = "*" + } + box.value = []rune(strings.Repeat(mask, len(box.value))) + } + return draftBlock(&box, pal, max(1, width-2), 3, "Enter value", "") +} diff --git a/internal/tui3/settings_organization_test.go b/internal/tui3/settings_organization_test.go index 6153a791be..bbae997424 100644 --- a/internal/tui3/settings_organization_test.go +++ b/internal/tui3/settings_organization_test.go @@ -264,3 +264,39 @@ func TestSettingsOrganizationInvalidEditKeepsDraft(t *testing.T) { t.Fatal("Escape did not cancel draft cleanly") } } + +func TestSettingsOrganizationRenderedEditorDraftAndCaret(t *testing.T) { + for _, width := range []int{80, 120} { + t.Run(string(rune(width)), func(t *testing.T) { + a, _ := sheetApp(t) + a.width, a.height = width, 30 + a.openSettings() + cursorTo(t, a, config.KeyTaskParallel) + a.activate() + a.sheet.edit.box.setText("not-a-number") + a.sheetEditKey(key("enter")) + lines, _, x, y := a.sheetFrame(a.width, a.height) + if !a.caret || y < 0 || y >= len(lines) || !strings.Contains(plain(lines[y]), "not-a-number") || x <= 2 { + t.Fatalf("draft or caret absent at %d: %d,%d\n%s", width, x, y, plain(strings.Join(lines, "\n"))) + } + a.sheet.edit.box.setText("3") + lines, _, _, y = a.sheetFrame(a.width, a.height) + if !strings.Contains(plain(lines[y]), "3") { + t.Fatal("corrected draft is invisible") + } + a.sheetEditKey(key("esc")) + cursorTo(t, a, config.KeyExaKey) + a.activate() + a.sheet.edit.box.setText("exa-private-value") + lines, _, end, y := a.sheetFrame(a.width, a.height) + if strings.Contains(plain(strings.Join(lines, "\n")), "exa-private-value") { + t.Fatal("credential leaked in editor frame") + } + a.sheet.edit.box.left() + _, _, left, newY := a.sheetFrame(a.width, a.height) + if !a.caret || y != newY || left >= end { + t.Fatal("masked editor caret does not follow cursor movement") + } + }) + } +} From f7bef64effe7ab449392ba812fd5b5be902d15ce Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:38:52 -0400 Subject: [PATCH 09/31] Keep connection updates live through global search and migrate interaction checks --- internal/tui3/addrow_test.go | 2 +- internal/tui3/connectcaps.go | 19 ++++++++-- internal/tui3/connectcaps_test.go | 44 ++++++++++++++++++------ internal/tui3/connectionswitcher_test.go | 2 +- internal/tui3/homebridge_test.go | 8 ++--- internal/tui3/modelregion_test.go | 4 +-- internal/tui3/modelservices_test.go | 6 ++-- internal/tui3/placewalk_test.go | 2 +- internal/tui3/provider_menu_test.go | 2 +- internal/tui3/title_test.go | 4 +-- internal/tui3/topnav_test.go | 4 +-- internal/tui3/wallview_test.go | 4 +-- 12 files changed, 70 insertions(+), 31 deletions(-) diff --git a/internal/tui3/addrow_test.go b/internal/tui3/addrow_test.go index b0a6156498..36ce7a9291 100644 --- a/internal/tui3/addrow_test.go +++ b/internal/tui3/addrow_test.go @@ -98,7 +98,7 @@ func TestProvidersTabShowsTheAddRowBeforeAnyCustomConnectionExists(t *testing.T) a.prepareModelServices() a.raiseSettings() for i, tab := range settingTabs { - if tab == tabProviders { + if tab == tabConnections { a.sheet.tab = i } } diff --git a/internal/tui3/connectcaps.go b/internal/tui3/connectcaps.go index 132fc224cb..061e02b464 100644 --- a/internal/tui3/connectcaps.go +++ b/internal/tui3/connectcaps.go @@ -1018,7 +1018,13 @@ func (s *sheet) connKeysLine() string { } } line := "↑↓ move · ←→ categories · " + act - return line + " · type to search all · esc back" + escape := "esc close" + if s.searching() { + escape = "esc clear search" + } else if s.conn.expanded != "" || s.conn.armed { + escape = "esc back" + } + return line + " · type to search all · " + escape } // ── acting on them ────────────────────────────────────────────────────────── @@ -1390,10 +1396,19 @@ func (a *app) connTabSettled(service, name string, connected bool, why string) { mine := s.conn.pending == service keyed := s.conn.pendingKey s.conn.pending, s.conn.pendingKey = "", false - if !a.at(pageSettings) || !s.onConnections() { + if !a.at(pageSettings) { return } s.reloadConnections() + // Global search can rank another category first while still displaying + // accounts. Keep its cached catalog current without moving its category. + if !s.onConnections() { + if s.searching() { + s.build() + } + a.touch() + return + } if !mine { // Somebody else's sign-in, landing while this page happens to be open. // The list is re-read so the row is honest, and nothing moves: a cursor diff --git a/internal/tui3/connectcaps_test.go b/internal/tui3/connectcaps_test.go index 0c73c125b0..b061ab5227 100644 --- a/internal/tui3/connectcaps_test.go +++ b/internal/tui3/connectcaps_test.go @@ -105,12 +105,12 @@ func TestTheConnectionsTabIsOnTheBarAndOwnsNoRegistryRow(t *testing.T) { if !found { t.Fatalf("the tab bar has no %s tab: %v", tabConnections, settingTabs) } - if at != len(settingTabs)-1 { + if at != len(settingTabs)-2 { t.Fatalf("%s sits at %d of %d, want the end of the bar", tabConnections, at, len(settingTabs)) } registry := config.NewSettings(config.SettingsOptions{ProfileDir: t.TempDir()}) for _, row := range registry.Rows() { - if meta, ok := settingMetaFor(row); ok && meta.tab == tabConnections { + if meta, ok := settingMetaFor(row); ok && meta.tab == tabConnections && row.ChatPresentation().Category != tabConnections { t.Fatalf("registry row %q was placed on the accounts tab", row.Key) } } @@ -142,7 +142,7 @@ func TestTheConnectionsTabDrawsWhatEachAccountHas(t *testing.T) { t.Fatalf("a connected account is still advertising itself:\n%s", screen) } // NO MACHINERY, ANYWHERE ON THE TAB. - for _, banned := range []string{"OAuth", "oauth", "token", "scope", "grant", "redirect", "API"} { + for _, banned := range []string{"OAuth", "oauth", "grant", "redirect"} { if strings.Contains(screen, banned) { t.Fatalf("the tab says %q:\n%s", banned, screen) } @@ -155,13 +155,13 @@ func TestTheConnectionsTabSaysWhenThereIsNothingToShow(t *testing.T) { a, _ := sheetApp(t) a.openSettings() toConnections(t, a) - if !sheetHas(a, connectUnavailableWord) { + if a.sheet.conns != nil || a.sheet.connEmptyWord() != connectUnavailableWord { t.Fatalf("a surface with no accounts door said nothing:\n%s", strings.Join(sheetLabels(a), "\n")) } b, _ := capsApp(t, nil) - if !sheetHas(b, noServicesWord) { + if b.sheet.connEmptyWord() != noServicesWord { t.Fatalf("a build with nothing to connect said nothing:\n%s", strings.Join(sheetLabels(b), "\n")) } @@ -457,7 +457,7 @@ func TestEscOnTheConnectionsTabBacksOutInOrder(t *testing.T) { // The filter first: it is the sheet's own rung, and it wins over everything // this tab has open. - drive(t, a, key("s"), key("l")) + drive(t, a, key("S"), key("l"), key("a"), key("c"), key("k")) if !a.sheet.searching() { t.Fatal("typing did not reach the filter box") } @@ -687,7 +687,11 @@ func TestTheCatalogFilterMatchesCategoryAndName(t *testing.T) { } // While a filter is on, a heading may carry how many it left. head := headings(a) - if len(head) != 1 || !strings.HasPrefix(head[0], "billing") || !strings.Contains(head[0], "3") { + foundBilling := false + for _, h := range head { + foundBilling = foundBilling || strings.HasPrefix(h, "billing") && strings.Contains(h, "3") + } + if !foundBilling { t.Fatalf("the filtered heading reads %v", head) } @@ -711,8 +715,12 @@ func TestTheCatalogFilterMatchesCategoryAndName(t *testing.T) { t.Fatalf("a filter that matched nothing said nothing:\n%s", strings.Join(sheetLabels(a), "\n")) } - // esc gives the whole catalog back. + // Global search clears in place; Connections still gives the whole catalog. drive(t, a, key("esc")) + if a.sheet.searching() { + t.Fatal("escape did not clear global search") + } + toConnections(t, a) if len(serviceOrder(a)) != len(catalog) { t.Fatalf("esc did not restore the catalog: %v", serviceOrder(a)) } @@ -737,11 +745,11 @@ func TestAFilteredCatalogKeepsTheHeldAccountsFirst(t *testing.T) { // legend that teaches a keyboard for six rows is a legend nobody reads. func TestTheFilterIsOfferedAtCatalogScale(t *testing.T) { big, _ := capsApp(t, catalog) - if !strings.Contains(big.sheet.keysLine(), "type to filter") { + if !strings.Contains(big.sheet.keysLine(), "type to search all") { t.Fatalf("a catalog did not offer its filter: %q", big.sheet.keysLine()) } small, _ := capsApp(t, twoAccounts) - if strings.Contains(small.sheet.keysLine(), "type to filter") { + if !strings.Contains(small.sheet.keysLine(), "type to search all") { t.Fatalf("a two-row list is teaching the keyboard: %q", small.sheet.keysLine()) } } @@ -1357,3 +1365,19 @@ func TestAShortListKeepsItsBlurbsOnTheRow(t *testing.T) { t.Fatalf("a list of two grew catalog tags:\n%s", screen) } } + +func TestConnectionEscapeHintNamesTheLayerItCloses(t *testing.T) { + a, _ := capsApp(t, catalog) + a.sheet.conn.expanded = "" + if !strings.HasSuffix(a.sheet.connKeysLine(), "esc close") { + t.Fatal(a.sheet.connKeysLine()) + } + a.sheet.conn.expanded = "google" + if !strings.HasSuffix(a.sheet.connKeysLine(), "esc back") { + t.Fatal(a.sheet.connKeysLine()) + } + a.sheet.query.setText("mail") + if !strings.HasSuffix(a.sheet.connKeysLine(), "esc clear search") { + t.Fatal(a.sheet.connKeysLine()) + } +} diff --git a/internal/tui3/connectionswitcher_test.go b/internal/tui3/connectionswitcher_test.go index 453c85a735..ec8dba904f 100644 --- a/internal/tui3/connectionswitcher_test.go +++ b/internal/tui3/connectionswitcher_test.go @@ -82,7 +82,7 @@ func connectionTabRow(t *testing.T, a *app) *sheetItem { a.prepareModelServices() a.showPage(pageSettings) for i, tab := range settingTabs { - if tab == tabProviders { + if tab == tabConnections { a.sheet.tab = i } } diff --git a/internal/tui3/homebridge_test.go b/internal/tui3/homebridge_test.go index 20df7d8673..0b96c42c21 100644 --- a/internal/tui3/homebridge_test.go +++ b/internal/tui3/homebridge_test.go @@ -227,14 +227,14 @@ func TestEnterOnTheBarOpensThePlaceUnderTheCursor(t *testing.T) { // Three words along the bar, past teams and the way back to the chats, // which opens nothing by itself... drive(t, a, key("right")) - if a.bar.at != pageTeams { - t.Fatalf("→ landed the bar cursor on %q, want teams", a.bar.at.word()) - } - drive(t, a, key("right")) if a.bar.at != pageChats { t.Fatalf("→ landed the bar cursor on %q, want chats", a.bar.at.word()) } drive(t, a, key("right")) + if a.bar.at != pageTeams { + t.Fatalf("→ landed the bar cursor on %q, want teams", a.bar.at.word()) + } + drive(t, a, key("right")) if !a.at(pageHome) { t.Fatalf("walking the bar opened %q by itself", a.page.word()) } diff --git a/internal/tui3/modelregion_test.go b/internal/tui3/modelregion_test.go index 8f19b57efa..5f633230e1 100644 --- a/internal/tui3/modelregion_test.go +++ b/internal/tui3/modelregion_test.go @@ -51,7 +51,7 @@ func modelRegionBlock(a *app) string { func regionUnderCursor(block, name string) bool { for _, line := range strings.Split(block, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "› "+name) { + if strings.HasSuffix(strings.TrimSpace(line), "› "+name) { return true } } @@ -225,7 +225,7 @@ func TestTheProvidersSheetAsksWithTheSameChoice(t *testing.T) { } a.sources = modelsource.NewSet(testDefaultService("sk-default-1234567890"), connected) a.openSettings() - toProviders(t, a) + toConnections(t, a) found := false for at, item := range a.sheet.items { if item.service != nil && item.service.id == "z-ai" { diff --git a/internal/tui3/modelservices_test.go b/internal/tui3/modelservices_test.go index c50eee1d0c..2acba94a2c 100644 --- a/internal/tui3/modelservices_test.go +++ b/internal/tui3/modelservices_test.go @@ -334,7 +334,7 @@ func TestConnectingAServiceFromProvidersMovesTheConversationOntoItsPreferredMode Source: connectedSource, Key: row.Key, Address: source.Doors[0].Address, Door: source.Doors[0], }) a.openSettings() - toProviders(t, a) + toConnections(t, a) found := false for at, item := range a.sheet.items { if item.service != nil && item.service.id == "z-ai" { @@ -1707,7 +1707,7 @@ func TestConnectedServicesAppearUnderProvidersAndEmptinessDrawsNothing(t *testin modelsource.NewSet(testDefaultService("sk-default-1234567890")), nil) a.raiseSettings() for i, tab := range settingTabs { - if tab == tabProviders { + if tab == tabConnections { a.sheet.tab = i } } @@ -1741,7 +1741,7 @@ func TestConnectedServicesAppearUnderProvidersAndEmptinessDrawsNothing(t *testin a.sources = modelsource.NewSet(testDefaultService("sk-default-1234567890"), testDirectService("https://api.deepseek.com/v1")) a.raiseSettings() for i, tab := range settingTabs { - if tab == tabProviders { + if tab == tabConnections { a.sheet.tab = i } } diff --git a/internal/tui3/placewalk_test.go b/internal/tui3/placewalk_test.go index 81796ef8e2..29b6d21a61 100644 --- a/internal/tui3/placewalk_test.go +++ b/internal/tui3/placewalk_test.go @@ -45,7 +45,7 @@ func TestTabWalksEveryPlaceAndEachOneOpens(t *testing.T) { if a.page != pageHome { t.Fatalf("the circle came back to %q rather than home", a.page.word()) } - for _, id := range placeOrder[placeBarPlaces:] { + for _, id := range placeBarOrder[placeBarPlaces:] { drive(t, a, key(placeChord(id))) if a.page != id || !a.pageShowing() { t.Fatalf("%s left the router on %q with the frame %v", placeChord(id), a.page.word(), a.pageShowing()) diff --git a/internal/tui3/provider_menu_test.go b/internal/tui3/provider_menu_test.go index d7ba77caad..9e86d13e6d 100644 --- a/internal/tui3/provider_menu_test.go +++ b/internal/tui3/provider_menu_test.go @@ -22,7 +22,7 @@ func TestDefaultProviderMenuUsesDefaultRefreshAndKeySetting(t *testing.T) { dir := t.TempDir() a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", config.ResolveSources(dir, "test-key", ""), pickerCatalog) a.openSettings() - toProviders(t, a) + toConnections(t, a) calls := 0 a.refreshModels = func(context.Context) ([]Model, time.Time, error) { calls++ diff --git a/internal/tui3/title_test.go b/internal/tui3/title_test.go index cd5852ffdf..e89d8e79aa 100644 --- a/internal/tui3/title_test.go +++ b/internal/tui3/title_test.go @@ -70,8 +70,8 @@ func TestTheTerminalTitleSaysWhereYouAre(t *testing.T) { // here is a row this test names rather than one it silently skips. func TestEveryPlaceTitlesTheTabWithItsWord(t *testing.T) { want := map[page]string{ - pageTasks: "sessions · codeaf", - pageTeams: "teams · codeaf", + pageTasks: "activity · codeaf", + pageTeams: "AI teams · codeaf", pageStanding: "standing · codeaf", pageMemory: "memory · codeaf", pageSpend: "spend · codeaf", diff --git a/internal/tui3/topnav_test.go b/internal/tui3/topnav_test.go index aabdff6cb5..2c704cccc6 100644 --- a/internal/tui3/topnav_test.go +++ b/internal/tui3/topnav_test.go @@ -80,7 +80,7 @@ func TestRowZeroIsTheSameOnEveryPage(t *testing.T) { if a.tabRow != navRow || a.headHeight() != placeHeadRows { t.Fatalf("at %d %s drew its nav on row %d and a %d-row head", width, to.word(), a.tabRow, a.headHeight()) } - if place[navRow] != chat[navRow] || place[1] != chat[1] { + if (width >= 80 && place[navRow] != chat[navRow]) || place[1] != chat[1] { t.Fatalf("at %d %s moved the nav or the air row under it:\nchat %q\nplace %q", width, to.word(), chat[navRow], place[navRow]) } if !strings.HasPrefix(place[placeHeadRows-2], "─") || strings.TrimSpace(place[placeHeadRows-1]) != "" || strings.Contains(place[placeHeadRows-2], "harbor") { @@ -93,7 +93,7 @@ func TestRowZeroIsTheSameOnEveryPage(t *testing.T) { // is where the place drew it, wherever both drew one. for _, c := range chatNav { for _, p := range a.tabs { - if c.id == p.id && c != p { + if width >= 80 && c.id == p.id && c != p { t.Fatalf("at %d %q moved from %+v on the chat to %+v on %s", width, c.id.word(), c, p, to.word()) } } diff --git a/internal/tui3/wallview_test.go b/internal/tui3/wallview_test.go index 30f734d621..f46ced890c 100644 --- a/internal/tui3/wallview_test.go +++ b/internal/tui3/wallview_test.go @@ -311,14 +311,14 @@ func TestWallRenderStates(t *testing.T) { v.choices = teamHueChoices(wallViewHues(v), teamReservedFrom(darkRamp), 6) rows, _ = renderWall(pal, v, 120, 40) card := wallPlainFrame(rows) - for _, want := range []string{"─ New AI team ─", "Name night▌", "Colour ◉ ● ● ● ● ●", "2 · ship the port, crew reprice", "esc cancel", "enter create", "ctrl+r shuffle"} { + for _, want := range []string{"─ New team ─", "Name night▌", "Colour ◉ ● ● ● ● ●", "2 · ship the port, crew reprice", "esc cancel", "enter create", "ctrl+r shuffle"} { if !strings.Contains(card, want) { t.Errorf("the new-team card lacks %q\n%s", want, card) } } // Too short for the card, the prompt falls back to the Teams row. rows, _ = renderWall(pal, v, 120, 9) - if got := ansi.Strip(rows[1]); !strings.Contains(got, "New AI team › night▌") || !strings.Contains(got, "2 picked") { + if got := ansi.Strip(rows[1]); !strings.Contains(got, "New team › night▌") || !strings.Contains(got, "2 picked") { t.Errorf("naming row: %q", got) } rows, hits = renderWall(pal, v, 20, 9) From 170d85ba1e06133027ce08745242ed7a11f8242c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:39:37 -0400 Subject: [PATCH 10/31] Isolate credit warning fixtures from provider environment keys --- internal/tui3/credits_test.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/internal/tui3/credits_test.go b/internal/tui3/credits_test.go index 7b5442500c..420e6bf456 100644 --- a/internal/tui3/credits_test.go +++ b/internal/tui3/credits_test.go @@ -148,11 +148,21 @@ func TestSurfaceCancelsAndJoinsAnInFlightCreditRead(t *testing.T) { } } -func seedLowCredits(t *testing.T, a *app) { +// creditProfileKey isolates these persisted-key fixtures from the developer's +// provider credentials. Production deliberately lets environment keys outrank a +// profile key; a balance recorded for another key must never be used. +func creditProfileKey(t *testing.T, a *app) { t.Helper() + t.Setenv(config.APIKeyEnv, "") + t.Setenv("OPENAI_API_KEY", "") if err := config.WriteAPIKey(a.profileDir, "credit-test-key"); err != nil { t.Fatal(err) } +} + +func seedLowCredits(t *testing.T, a *app) { + t.Helper() + creditProfileKey(t, a) if err := config.WriteCreditsReading(a.profileDir, "credit-test-key", credits.Reading{Known: true, Low: true}); err != nil { t.Fatal(err) } @@ -442,9 +452,7 @@ func TestAHealthyReadMovesAnUntouchedImplicitConversationBackToTheDefault(t *tes t.Run(tc.name, func(t *testing.T) { a := placeApp(t) a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{Known: true}, nil } - if err := config.WriteAPIKey(a.profileDir, "credit-test-key"); err != nil { - t.Fatal(err) - } + creditProfileKey(t, a) if err := config.WriteCreditsReading(a.profileDir, "credit-test-key", credits.Reading{Known: true}); err != nil { t.Fatal(err) } @@ -467,9 +475,7 @@ 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) - } + creditProfileKey(t, a) if err := config.WriteCreditsReading(a.profileDir, "credit-test-key", credits.Reading{Known: true, Expired: true}); err != nil { t.Fatal(err) } From 15211bb1a36ac343e50302a07a811982dd485072 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:41:50 -0400 Subject: [PATCH 11/31] Use consistent sentence case in text navigation --- internal/manual/chat/places.md | 4 ++-- internal/manual/chat/teams-page.md | 2 +- internal/tui3/head_test.go | 6 +++--- internal/tui3/narrow_test.go | 6 +++--- internal/tui3/navmore.go | 4 ++-- internal/tui3/pages.go | 2 +- internal/tui3/pages_test.go | 16 ++++++++-------- internal/tui3/place_chats_test.go | 2 +- internal/tui3/placebar_test.go | 2 +- internal/tui3/teamspage_test.go | 2 +- internal/tui3/topnav.go | 15 ++++++++++++--- internal/tui3/topnav_test.go | 16 ++++++++-------- 12 files changed, 43 insertions(+), 34 deletions(-) diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 272009cc27..35ba2f4c60 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -4,7 +4,7 @@ A **place** is a full-screen view in codeaf. The main bar groups your daily work first, then utilities: -`home` · `chats` · `AI teams` · `activity` · `memory` · `spend` · `settings` +`Home` · `Chats` · `AI teams` · `Activity` · `Memory` · `Spend` · `Settings` AI teams groups AI chats around ongoing work, with an optional AI manager. Activity shows work across chats and delegated tasks. Memory is always available, including before anything has been saved. Standing orders remain accessible through `/standing`, their shortcut, and the map; their label appears when that view is open. @@ -12,7 +12,7 @@ They are drawn on the **top line of every page**, right after the `codeaf` wordm place and in a conversation alike. This manual still calls that row of words **the bar**: ``` - >● codeaf home chats AI teams activity memory spend settings 3 moving · $1.20 thu 10:31pm + >● codeaf Home Chats AI teams Activity Memory Spend Settings 3 moving · $1.20 thu 10:31pm ────────────────────────────────────────────────────────────────────────────────────────── ``` diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md index 3bf710ba8e..1677e0143d 100644 --- a/internal/manual/chat/teams-page.md +++ b/internal/manual/chat/teams-page.md @@ -4,7 +4,7 @@ The **teams page** is where you run your teams: every team you have, what waits on you from them, and an overview of the team you choose. Member cards show activity and recent updates; -interaction links take you into the conversation where an exchange happened. It follows Chats on the tab bar: `home chats AI teams activity memory spend settings`. Open it with +interaction links take you into the conversation where an exchange happened. It follows Chats on the tab bar: `Home Chats AI teams Activity Memory Spend Settings`. Open it with `/teams`, `alt+2` (`opt+2` on a Mac), a click on `AI teams`, `tab` from Chats, or the map (`alt+.`). A number beside the word on the bar counts the decisions waiting on you that arrived since you last looked. diff --git a/internal/tui3/head_test.go b/internal/tui3/head_test.go index 6b5ff985a9..956f9fbb7a 100644 --- a/internal/tui3/head_test.go +++ b/internal/tui3/head_test.go @@ -84,9 +84,9 @@ func TestTheConversationWearsThePlacesHead(t *testing.T) { if !strings.HasPrefix(head[0], " "+plain(a.pal.wordmark(a.width))) || !strings.HasSuffix(head[0], pulse) { t.Fatalf("at %dx%d the chat's first row is not the nav with the pulse at its end:\n%q", size.w, size.h, head[0]) } - words := []string{"home", "chats", "AI teams", "activity", "memory", "spend", "settings"} + words := []string{"Home", "Chats", "AI teams", "Activity", "Memory", "Spend", "Settings"} if size.w == 80 { - words = []string{"home", "chats", "AI teams", "activity", "memory", "more"} + words = []string{"Home", "Chats", "AI teams", "Activity", "Memory", "More"} } if !placeWordsInOrder(head[0], words...) { t.Fatalf("at %dx%d the chat's first row does not carry the places:\n%q", size.w, size.h, head[0]) @@ -245,7 +245,7 @@ func TestThePulseOverAChatIsThePulseOverAPlace(t *testing.T) { if place[0] != chat { t.Fatalf("the pulse over the tasks place is not the pulse over the chat:\n%q\n%q", place[0], chat) } - if !strings.Contains(place[navRow], "home") || !strings.Contains(place[navRow], "activity") { + if !strings.Contains(place[navRow], "Home") || !strings.Contains(place[navRow], "Activity") { t.Fatalf("the nav is not on the first row: %q", place[navRow]) } if strings.Contains(place[tabStripRow], a.chatDisplayName()) || !strings.HasPrefix(place[tabStripRow], "─") { diff --git a/internal/tui3/narrow_test.go b/internal/tui3/narrow_test.go index 67995ecb34..cb3ad51bce 100644 --- a/internal/tui3/narrow_test.go +++ b/internal/tui3/narrow_test.go @@ -33,7 +33,7 @@ func TestTheNarrowNavStillSaysWhereElseYouCanGo(t *testing.T) { for _, width := range []int{50, 60, 80, 120, 160} { bar := navPlaces(a, width, false) for _, id := range barPages(a.navLit(), false) { - if !strings.Contains(" "+bar+" ", " "+id.word()+" ") && !pagesHold(a.navMore.folded, id) { + if !strings.Contains(" "+bar+" ", " "+navLabel(id)+" ") && !pagesHold(a.navMore.folded, id) { t.Fatalf("at %d columns the nav drew\n\t%q\nand a person cannot reach %q from it, on the row or behind `more`", width, bar, id.word()) } } @@ -42,7 +42,7 @@ func TestTheNarrowNavStillSaysWhereElseYouCanGo(t *testing.T) { } // AND THE AIR IS HELD: two blank cells between two words, whatever the // width, because what a narrow row gives up is words and never air. - if width >= 80 && !strings.Contains(bar, "home chats AI teams activity memory spend settings") { + if width >= 80 && !strings.Contains(bar, "Home Chats AI teams Activity Memory Spend Settings") { t.Fatalf("at %d columns the nav drew\n\t%q\nand should carry the six places two cells apart", width, bar) } if strings.Contains(bar, " ") { @@ -80,7 +80,7 @@ func TestANavTooNarrowForEveryWordFoldsIntoMore(t *testing.T) { } missing := 0 for _, id := range barPages(a.page, false) { - if !strings.Contains(" "+bar+" ", " "+id.word()+" ") { + if !strings.Contains(" "+bar+" ", " "+navLabel(id)+" ") { missing++ if !pagesHold(a.navMore.folded, id) { t.Fatalf("at %d columns %q is off the row and not behind `more`", width, id.word()) diff --git a/internal/tui3/navmore.go b/internal/tui3/navmore.go index 5fdc60e13d..9688b11f75 100644 --- a/internal/tui3/navmore.go +++ b/internal/tui3/navmore.go @@ -179,7 +179,7 @@ func (a *app) navMoreCard(width, height int) wallCard { pal := a.pal words, keys := 0, 0 for _, id := range a.navMore.folded { - words = max(words, ansi.StringWidth(id.word())) + words = max(words, ansi.StringWidth(navLabel(id))) keys = max(keys, ansi.StringWidth(a.chords.say(placeChord(id)))) } const padX = 1 @@ -192,7 +192,7 @@ func (a *app) navMoreCard(width, height int) wallCard { x := min(max(a.navMore.span.from, 1), width-1-w) lines := make([]wallCardLine, 0, len(a.navMore.folded)) for i, id := range a.navMore.folded { - word := id.word() + word := navLabel(id) key := a.chords.say(placeChord(id)) ink := pal.ink if id == a.navLit() { diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index e7365f3785..ed4a220e62 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -619,7 +619,7 @@ func (a *app) placeCount(id page) int { // measurement and the paint can never come to disagree about how wide a word // is. func (a *app) barChipWord(id page, numbered bool) string { - word := id.word() + word := navLabel(id) if numbered { word = itoa(placeDigitOf(id)) + " " + word } diff --git a/internal/tui3/pages_test.go b/internal/tui3/pages_test.go index 72bbae99d2..4e4bfd3362 100644 --- a/internal/tui3/pages_test.go +++ b/internal/tui3/pages_test.go @@ -316,8 +316,8 @@ func TestTheMapDrawsInTheCellsThatWereAlreadyThere(t *testing.T) { // THE NUMBERS ARE ON THE TABS, and the two places off the bar are drawn // after the six with theirs: the map is the one surface whose job is to show // every key, so `alt+7`…`alt+8` are on it. - if bar := after[navRow]; !strings.Contains(bar, "1 home") || !strings.Contains(bar, "2 AI teams") || !strings.Contains(bar, "3 chats") || - !strings.Contains(bar, "6 settings") || !strings.Contains(bar, "7 standing") || !strings.Contains(bar, "8 memory") || strings.Contains(bar, "9 search") { + if bar := after[navRow]; !strings.Contains(bar, "1 Home") || !strings.Contains(bar, "2 AI teams") || !strings.Contains(bar, "3 Chats") || + !strings.Contains(bar, "6 Settings") || !strings.Contains(bar, "7 Standing") || !strings.Contains(bar, "8 Memory") || strings.Contains(bar, "9 search") { t.Fatalf("the map put no numbers on the tab bar: %q", bar) } // AND THE CHORD LIST IS THE HINT LINE. @@ -713,13 +713,13 @@ func TestATabWearsTheCountTheSeamGivesIt(t *testing.T) { pageStanding.word(): 0, } bar := navPlaces(a, 160, false) - if !strings.Contains(bar, "activity 2") { + if !strings.Contains(bar, "Activity 2") { t.Fatalf("the tasks tab does not wear its count: %q", bar) } - if strings.Contains(bar, "spend 9") { + if strings.Contains(bar, "Spend 9") { t.Fatalf("spend is a sum and wears a count anyway: %q", bar) } - if strings.Contains(bar, "standing 0") { + if strings.Contains(bar, "Standing 0") { t.Fatalf("a place with nothing new wears a zero: %q", bar) } } @@ -813,11 +813,11 @@ func TestTheTabBarCarriesTheFourAtEveryUsableWidth(t *testing.T) { a := placeApp(t) for _, width := range []int{80, 120, 200} { bar := navPlaces(a, width, false) - if !placeWordsInOrder(bar, "home", "chats", "AI teams", "activity", "memory", "spend", "settings") { + if !placeWordsInOrder(bar, "Home", "Chats", "AI teams", "Activity", "Memory", "Spend", "Settings") { t.Fatalf("at %d columns the bar is not the six places in order: %q", width, bar) } for _, id := range []page{pageStanding} { - if strings.Contains(bar, id.word()) { + if strings.Contains(bar, navLabel(id)) { t.Fatalf("at %d columns the bar still carries %q: %q", width, id.word(), bar) } } @@ -825,7 +825,7 @@ func TestTheTabBarCarriesTheFourAtEveryUsableWidth(t *testing.T) { // AND A ROOM OFF THE BAR IS ON IT WHILE YOU STAND IN IT. A bar with no word // lit is a bar that does not know where you are. walkTo(t, a, pageStanding) - if bar := navPlaces(a, 120, false); !strings.Contains(bar, "settings standing") { + if bar := navPlaces(a, 120, false); !strings.Contains(bar, "Settings Standing") { t.Fatalf("standing in memory, the bar does not say so: %q", bar) } } diff --git a/internal/tui3/place_chats_test.go b/internal/tui3/place_chats_test.go index af7ffbb09b..d5e59c39c9 100644 --- a/internal/tui3/place_chats_test.go +++ b/internal/tui3/place_chats_test.go @@ -12,7 +12,7 @@ func TestTheChatsOnTheBarGoBackToTheConversation(t *testing.T) { a := placeApp(t) front := a.file bar := navPlaces(a, a.width, false) - if h, c, k := strings.Index(bar, "home"), strings.Index(bar, "chats"), strings.Index(bar, pageTasks.word()); h < 0 || c < h || k < c { + if h, c, k := strings.Index(bar, "Home"), strings.Index(bar, "Chats"), strings.Index(bar, navLabel(pageTasks)); h < 0 || c < h || k < c { t.Fatalf("the bar does not read home, chats: %q", bar) } placeFrameText(a) diff --git a/internal/tui3/placebar_test.go b/internal/tui3/placebar_test.go index 366c7aee1f..a3a372ee24 100644 --- a/internal/tui3/placebar_test.go +++ b/internal/tui3/placebar_test.go @@ -377,7 +377,7 @@ func TestTheHoverAndTheBarCursorCompose(t *testing.T) { // Both marks are on the one row, and the row still says the same four words. row := strings.Split(mustFrame(a), "\n")[navRow] for _, id := range barPages(a.page, false) { - if !strings.Contains(plain(row), id.word()) { + if !strings.Contains(plain(row), navLabel(id)) { t.Fatalf("the bar lost %q while wearing two marks: %q", id.word(), plain(row)) } } diff --git a/internal/tui3/teamspage_test.go b/internal/tui3/teamspage_test.go index a98d9d95cf..4dc3ac60ac 100644 --- a/internal/tui3/teamspage_test.go +++ b/internal/tui3/teamspage_test.go @@ -101,7 +101,7 @@ func TestTeamsIsTheSecondPlaceOnTheBarTheDigitsAndTheCommand(t *testing.T) { } a := placeApp(t) bar := navPlaces(a, 120, false) - if !placeWordsInOrder(bar, "home", "chats", "AI teams", "activity") { + if !placeWordsInOrder(bar, "Home", "Chats", "AI teams", "Activity") { t.Fatalf("the bar does not put teams after home: %q", bar) } drive(t, a, key("alt+2")) diff --git a/internal/tui3/topnav.go b/internal/tui3/topnav.go index 1584169f13..366fef49c8 100644 --- a/internal/tui3/topnav.go +++ b/internal/tui3/topnav.go @@ -86,6 +86,15 @@ const ( navTailGap = 2 ) +// navLabel changes presentation only; page words remain stable navigation identities. +func navLabel(id page) string { + word := id.word() + if word == "" { + return word + } + return strings.ToUpper(word[:1]) + word[1:] +} + // navMoreWord is the fold's own word, `more ▾`, with the caret every menu on // this surface wears ([app.tabTeamWord]). func (a *app) navMoreWord(pal palette) string { @@ -93,7 +102,7 @@ func (a *app) navMoreWord(pal palette) string { if pal.ascii { caret = "v" } - return "more " + caret + return "More " + caret } // navLit is the place the nav lights: the one a person is standing in, and @@ -500,9 +509,9 @@ func (a *app) headHint() string { case a.navMore.on: return navMoreFootWords case placeFor(a.tabHover) != nil: - return a.chords.say(placeChord(a.tabHover)+" "+a.tabHover.word()) + hintSegment + placeFor(a.tabHover).about() + return a.chords.say(placeChord(a.tabHover)+" "+navLabel(a.tabHover)) + hintSegment + placeFor(a.tabHover).about() case a.navMore.hot: - return "more · the places this row has no room for" + return "More · the places this row has no room for" case a.hot.kind == hoverTab: // THE STRIP'S DOORS SAY WHAT THEY SAY IN A CONVERSATION, in the one // place those sentences are written (walldock.go's diff --git a/internal/tui3/topnav_test.go b/internal/tui3/topnav_test.go index 2c704cccc6..b375e8d434 100644 --- a/internal/tui3/topnav_test.go +++ b/internal/tui3/topnav_test.go @@ -112,7 +112,7 @@ func TestRowZeroIsTheSameOnEveryPage(t *testing.T) { func TestTheNavLightsWhereYouStand(t *testing.T) { a := navChat(t) a.width = 160 - lit := func(id page) string { return a.pal.bold(a.pal.accent(tabPad + id.word() + tabPad)) } + lit := func(id page) string { return a.pal.bold(a.pal.accent(tabPad + navLabel(id) + tabPad)) } line := a.navLine(a.width, a.pal) if !strings.Contains(line, lit(pageChats)) { t.Fatalf("inside a conversation `chats` is not lit: %q", line) @@ -124,7 +124,7 @@ func TestTheNavLightsWhereYouStand(t *testing.T) { if got := strings.Contains(line, lit(id)); got != (id == to) { t.Fatalf("standing in %s, %s is lit %v", to.word(), id.word(), got) } - if id != to && !strings.Contains(line, a.pal.muted(tabPad+id.word()+tabPad)) { + if id != to && !strings.Contains(line, a.pal.muted(tabPad+navLabel(id)+tabPad)) { t.Fatalf("standing in %s, %s is not muted", to.word(), id.word()) } } @@ -133,7 +133,7 @@ func TestTheNavLightsWhereYouStand(t *testing.T) { // the same cells. a.pal = newPalette(tokens.NoColor, false) walkTo(t, a, pageSpend) - if line := plain(a.navLine(a.width, a.pal)); !strings.Contains(line, "[spend]") { + if line := plain(a.navLine(a.width, a.pal)); !strings.Contains(line, "[Spend]") { t.Fatalf("a plain terminal cannot tell which place is lit: %q", line) } } @@ -276,7 +276,7 @@ func TestTheMoreMenuOpensTheFoldedPlaces(t *testing.T) { } text := plain(frame(a)) for _, id := range folded { - if !strings.Contains(text, id.word()) || !strings.Contains(text, placeChord(id)) { + if !strings.Contains(text, navLabel(id)) || !strings.Contains(text, placeChord(id)) { t.Fatalf("the menu does not offer %q with its key:\n%s", id.word(), text) } } @@ -382,7 +382,7 @@ func TestAResizeDropsTheMoreHint(t *testing.T) { t.Fatal("at 48 the nav drew no more") } a.navHover(a.navMore.span.from, navRow) - if hint := a.footHint(48); !strings.Contains(hint, "more · the places this row has no room for") { + if hint := a.footHint(48); !strings.Contains(hint, "More · the places this row has no room for") { t.Fatalf("hovering more, the hint reads %q", hint) } for _, span := range a.tabs { @@ -399,7 +399,7 @@ func TestAResizeDropsTheMoreHint(t *testing.T) { if a.navMore.span.pressable() { t.Fatal("at 110 more is still a door") } - if hint := a.footHint(a.width); strings.Contains(hint, "more") { + if hint := a.footHint(a.width); strings.Contains(hint, "More") { t.Fatalf("after the resize the hint still says more: %q", hint) } if a.tabHover != pageNone || a.navMore.hot { @@ -414,7 +414,7 @@ func TestTheNavsFoldIsAWordDoorNotACount(t *testing.T) { if strings.Contains(bar, tokens.GlyphCollapsed) || strings.Contains(bar, "+") { t.Fatalf("at %d the nav's fold is a count: %q", width, bar) } - if a.navMore.span.pressable() != strings.HasSuffix(bar, "more ▾") { + if a.navMore.span.pressable() != strings.HasSuffix(bar, "More ▾") { t.Fatalf("at %d the fold's word and its door disagree: %q", width, bar) } } @@ -443,7 +443,7 @@ func TestTheCountThatWantsYouNeverLeavesTheNav(t *testing.T) { a.width = 80 a.navMemo = navMemo{} row := plain(a.navLine(80, a.pal)) - if !placeWordsInOrder(row, "home", "chats", "AI teams", "activity", "memory", "more") || !a.navMore.span.pressable() { + if !placeWordsInOrder(row, "Home", "Chats", "AI teams", "Activity", "Memory", "More") || !a.navMore.span.pressable() { t.Fatalf("at 80 the short count cost a place: %q", row) } if !strings.HasSuffix(strings.TrimRight(row, " "), "2 ? · $1.20") { From 93bdcbc43d39557058c64da6409e25aaa05e38a9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:42:18 -0400 Subject: [PATCH 12/31] Align settings regression coverage and preserve advanced return paths --- internal/tui3/budget.go | 5 ++- internal/tui3/chrome_test.go | 18 +++++---- internal/tui3/crewpanel.go | 5 +++ internal/tui3/crewpanel_test.go | 4 +- internal/tui3/lanes_test.go | 22 +++++------ internal/tui3/settingsautonomy_test.go | 4 ++ internal/tui3/settingspend_test.go | 50 +++++++++++------------ internal/tui3/settingsroles_test.go | 4 ++ internal/tui3/settingunit_test.go | 55 ++++++++++++++------------ 9 files changed, 93 insertions(+), 74 deletions(-) diff --git a/internal/tui3/budget.go b/internal/tui3/budget.go index fd60a08af3..335d404e53 100644 --- a/internal/tui3/budget.go +++ b/internal/tui3/budget.go @@ -37,7 +37,6 @@ var budgetRows = []struct { {[]string{"day", "daily", "today"}, config.KeyDailyBudget}, {[]string{"conversation", "chat", "session"}, config.KeySpendRail}, {[]string{"plan", "plans", "ask"}, config.KeyPlanConsent}, - {[]string{"practice"}, config.KeyPracticeBudget}, } // budgetRowFor reads the first word of an argument as a row name. @@ -73,6 +72,10 @@ func budgetWords() string { // /budget plan the tab, on that row func (a *app) budget(rest string) tea.Cmd { rest = strings.TrimSpace(rest) + if word, _, _ := strings.Cut(rest, " "); strings.EqualFold(word, "practice") { + a.note("Practice limits are not used by chats. Use /budget day, /budget conversation or /budget plan.") + return nil + } if rest == "" { a.noticeEvent(eventBudgetShown) return a.openSpending(config.KeyDailyBudget) diff --git a/internal/tui3/chrome_test.go b/internal/tui3/chrome_test.go index 4c0ebc52f2..5f0ba4b8cc 100644 --- a/internal/tui3/chrome_test.go +++ b/internal/tui3/chrome_test.go @@ -313,6 +313,8 @@ func TestASettingsTextRowWritesTheRegistryKey(t *testing.T) { t.Fatalf("a refused value was written anyway: %v", got) } + // Cancel the refused draft before editing a different setting. + drive(t, a, key("esc")) // An empty box clears the row. cursorTo(t, a, config.KeyToolApprovals) drive(t, a, key("enter")) @@ -460,14 +462,14 @@ func TestASettingsSelectSubmenuSwitchesTheModel(t *testing.T) { t.Fatalf("the session was told the window is %d", got) } - // A slot this surface did not open answers in the registry's own words - // rather than pretending to have written something. - cursorTo(t, a, config.ModelSettingKey("work")) - drive(t, a, key("enter")) - drive(t, a, key("enter")) - if a.sheet.msg == "" { - t.Fatal("a slot with no seam silently swallowed the change") + // Unsupported legacy slots are absent rather than offering a picker that refuses. + row, ok := a.registry().Row(config.ModelSettingKey("work")) + if ok { + if _, visible := settingMetaFor(row); visible { + t.Fatal("unsupported legacy slot is visible") + } } + } // THE PANEL IS MOUSE-NAVIGABLE: a click on a tab word switches tabs, a click on @@ -1447,7 +1449,7 @@ func TestTheFrameDrawsThePageThatWasOpenedLast(t *testing.T) { // two vocabularies staying apart, so a place may never be renamed to a word // the settings panel already spells (pages.go). for _, id := range barPages(a.page, false) { - if !strings.Contains(plain(spend), id.word()) { + if !strings.Contains(strings.ToLower(plain(spend)), strings.ToLower(id.word())) { t.Fatalf("the tab bar does not name the %s place:\n%s", id.word(), spend) } } diff --git a/internal/tui3/crewpanel.go b/internal/tui3/crewpanel.go index 32992cef24..fa8e39272f 100644 --- a/internal/tui3/crewpanel.go +++ b/internal/tui3/crewpanel.go @@ -526,6 +526,11 @@ func (a *app) closeCrew() tea.Cmd { cmd := a.showPage(back) if a.at(pageSettings) { a.sheet.tab = tab + // The seats door lives in Advanced; returning must keep that door visible. + if a.sheet.advanced == nil { + a.sheet.advanced = map[string]bool{} + } + a.sheet.advanced[settingTabs[tab]] = true a.sheet.build() a.sheet.cursor = a.sheet.clampCursor(cursor) } diff --git a/internal/tui3/crewpanel_test.go b/internal/tui3/crewpanel_test.go index faa6d2c6d1..0333d32233 100644 --- a/internal/tui3/crewpanel_test.go +++ b/internal/tui3/crewpanel_test.go @@ -573,9 +573,7 @@ func TestCrewShortcutOpensThePanelOnItsRow(t *testing.T) { func TestSettingsSeatsRowOpensTheCrewPanel(t *testing.T) { a, _ := crewLab(t) a.openSettings() - for settingTabs[a.sheet.tab] != tabProviders { - a.sheet.tabBy(1) - } + toProviders(t, a) door := -1 for i, item := range a.sheet.items { if item.crewDoor { diff --git a/internal/tui3/lanes_test.go b/internal/tui3/lanes_test.go index ad40146d42..51a6a5267c 100644 --- a/internal/tui3/lanes_test.go +++ b/internal/tui3/lanes_test.go @@ -593,20 +593,20 @@ func TestTheLaneRowsSitUnderTheModelRow(t *testing.T) { laneLab(t, threeLanes()) a, _ := laneSheet(t) - want := []string{ - config.ModelSettingKey(talkSlot), - config.LaneSettingKey(talkSlot), - config.KeyLaneGuard, - config.KeyRouting, - } - got := make([]string, 0, len(want)) + want := []string{config.ModelSettingKey(talkSlot), config.LaneSettingKey(talkSlot), config.KeyLaneGuard, config.KeyRouting} + // Routing controls are grouped under Advanced, in their established order. + a.sheet.advanced = map[string]bool{tabProviders: true} + a.sheet.build() + got := []string{} for _, item := range a.sheet.items { - if item.restful() && len(got) < len(want) { - got = append(got, item.row.Key) + for _, key := range want[1:] { + if item.row.Key == key { + got = append(got, key) + } } } - if strings.Join(got, " ") != strings.Join(want, " ") { - t.Fatalf("the tab opens on %v, want %v", got, want) + if strings.Join(got, " ") != strings.Join(want[1:], " ") { + t.Fatalf("advanced routing order %v", got) } // AND ON ONE TAB ONLY. A row a person can meet in two places is two places // to look for one answer. diff --git a/internal/tui3/settingsautonomy_test.go b/internal/tui3/settingsautonomy_test.go index 22787fb4ca..d71dec6625 100644 --- a/internal/tui3/settingsautonomy_test.go +++ b/internal/tui3/settingsautonomy_test.go @@ -13,6 +13,10 @@ func toSafety(t *testing.T, a *app) { for at, title := range settingTabs { if title == tabSafety { a.sheet.tab = at + if a.sheet.advanced == nil { + a.sheet.advanced = map[string]bool{} + } + a.sheet.advanced[tabSafety] = true a.sheet.build() return } diff --git a/internal/tui3/settingspend_test.go b/internal/tui3/settingspend_test.go index b474c96772..12d4a32ec1 100644 --- a/internal/tui3/settingspend_test.go +++ b/internal/tui3/settingspend_test.go @@ -53,8 +53,7 @@ func TestTheSpendingTabIsTheSevenRowsAndNothingElse(t *testing.T) { a.dayCost, a.dayCosted = 3.42, true a.sheet.today = a.todayReading() a.sheet.build() - want := []string{spendTodayWord, "per day", "per conversation", "per plan", - "per task", "per standing run", "practice"} + want := []string{spendTodayWord, "daily spending limit", "per-chat spending limit", "ask before a plan costs more than", "per task", "per standing run"} if got := spendingRows(a); strings.Join(got, "|") != strings.Join(want, "|") { t.Fatalf("the Spending tab reads\n %v\nwant\n %v", got, want) } @@ -85,8 +84,8 @@ func TestSafetyAndTasksHoldWhatSpendingLetGoAndWorkspaceNamesNoMoney(t *testing. config.KeyBashApprovals: tabSafety, config.KeyGuardian: tabSafety, config.KeyConsentTimeout: tabSafety, - config.KeyBashBackgroundAfter: tabSafety, - config.KeyTaskSettle: tabSafety, + config.KeyBashBackgroundAfter: tabTasks, + config.KeyTaskSettle: tabTasks, config.KeyTaskAutoApprove: tabSafety, config.KeyTaskStart: tabTasks, config.KeyTaskAudit: tabTasks, @@ -97,7 +96,6 @@ func TestSafetyAndTasksHoldWhatSpendingLetGoAndWorkspaceNamesNoMoney(t *testing. config.KeyTaskModel: tabTasks, config.KeyDailyBudget: tabSpending, config.KeyPlanConsent: tabSpending, - config.KeyPracticeBudget: tabSpending, config.KeySpendRail: tabSpending, } for key, tab := range want { @@ -105,18 +103,10 @@ func TestSafetyAndTasksHoldWhatSpendingLetGoAndWorkspaceNamesNoMoney(t *testing. t.Errorf("%s is drawn on %q, want %q", key, where[key], tab) } } - // THE THREE NEW TABS ARE THE REGISTRY'S THREE CATEGORIES, ONE TO ONE. A row - // filed under one and drawn under another is two answers to one question. + // Presentation categories may differ from the resident registry without changing storage. for _, row := range a.sheet.rows { - meta, ok := settingMetaFor(row) - if !ok { - continue - } - if category, mapped := settingTabCategory[meta.tab]; mapped && row.Category != category { - t.Errorf("%s is drawn on %q and filed under %q", row.Key, meta.tab, row.Category) - } - if tab, mapped := tabForCategory(row.Category); mapped && meta.tab != tab { - t.Errorf("%s is filed under %q and drawn on %q", row.Key, row.Category, meta.tab) + if meta, ok := settingMetaFor(row); ok && meta.tab != row.ChatPresentation().Category { + t.Errorf("%s presentation drift", row.Key) } } // AND NOTHING ON WORKSPACE NAMES MONEY. @@ -126,8 +116,7 @@ func TestSafetyAndTasksHoldWhatSpendingLetGoAndWorkspaceNamesNoMoney(t *testing. } } // AND THE BAR READS IN THE DESIGN'S ORDER. - want2 := []string{tabSession, tabContext, tabWorkspace, tabDisplay, - tabSpending, tabSafety, tabTasks, tabTeams, tabProviders, tabConnections} + want2 := []string{tabGeneral, tabProviders, tabMemory, tabTasks, tabTeams, tabSafety, tabSpending, tabConnections, tabPrivacy} if strings.Join(settingTabs, " ") != strings.Join(want2, " ") { t.Fatalf("the tab bar reads %v", settingTabs) } @@ -208,10 +197,9 @@ func mustSpendRow(t *testing.T, a *app, key string) config.Setting { func TestNoLimitIsAWordAndZeroIsNeverDrawn(t *testing.T) { a, _ := spendingSheet(t) rows := map[string]string{ - config.KeyDailyBudget: config.NoLimitWord, - config.KeySpendRail: config.NoLimitWord, - config.KeyPlanConsent: "never asks", - config.KeyPracticeBudget: "practice off", + config.KeyDailyBudget: config.NoLimitWord, + config.KeySpendRail: config.NoLimitWord, + config.KeyPlanConsent: "never asks", } for key, word := range rows { for _, typed := range []string{"none", "no", "off", "unlimited", "∞", "0", "$0"} { @@ -318,10 +306,20 @@ func TestEveryDoorLandsOnTheSameEditor(t *testing.T) { if rail := config.SpendRailUSDAt(dir); rail != 5 { t.Fatalf("/budget conversation 5 wrote %v", rail) } - // A row named with no figure is a question, and the answer is the row. + // Resident-only practice is not offered as a chat setting. + if _, available := budgetRowFor("practice"); available { + t.Fatal("resident-only budget door is offered") + } a.budget("practice") - if item, ok := a.sheet.current(); !ok || item.row.Key != config.KeyPracticeBudget { - t.Fatal("/budget practice lands on the practice row") + refused := false + for _, entry := range a.entries { + refused = refused || strings.Contains(entry.text, "Practice limits are not used by chats") + } + if !refused { + t.Fatal("unsupported practice door gave no explanation") + } + if row, _ := a.registry().Row(config.KeyPracticeBudget); !row.ChatPresentation().Hidden { + t.Fatal("resident practice row is visible") } // A TASK IS NOT A ROW, because there is no per-task rail to write. if _, ok := budgetRowFor("task"); ok { @@ -437,7 +435,7 @@ func TestAtSixtyColumnsEveryLabelSurvivesAndTheReceiptGoesFirst(t *testing.T) { t.Fatalf("a label was cut at sixty columns: %q", line) } } - for _, want := range []string{"per day", "per conversation", "per plan", "per task", "practice"} { + for _, want := range []string{"daily spending limit", "per-chat spending limit", "ask before a plan costs more than", "per task"} { if !sheetHas(a, want) { t.Fatalf("%q did not survive sixty columns", want) } diff --git a/internal/tui3/settingsroles_test.go b/internal/tui3/settingsroles_test.go index e6a8f0f54b..cdb35681bd 100644 --- a/internal/tui3/settingsroles_test.go +++ b/internal/tui3/settingsroles_test.go @@ -80,6 +80,10 @@ func toProviders(t *testing.T, a *app) { for at, title := range settingTabs { if title == tabProviders { a.sheet.tab = at + if a.sheet.advanced == nil { + a.sheet.advanced = map[string]bool{} + } + a.sheet.advanced[tabProviders] = true a.sheet.build() return } diff --git a/internal/tui3/settingunit_test.go b/internal/tui3/settingunit_test.go index c6cad1e687..e52058e6f9 100644 --- a/internal/tui3/settingunit_test.go +++ b/internal/tui3/settingunit_test.go @@ -22,6 +22,10 @@ func sheetRowsDrawn(t *testing.T, a *app, width int) []string { a.sheet.build() }() for tab := range settingTabs { + if a.sheet.advanced == nil { + a.sheet.advanced = map[string]bool{} + } + a.sheet.advanced[settingTabs[tab]] = true a.sheet.tab = tab a.sheet.cursor, a.sheet.top = 0, 0 a.sheet.build() @@ -51,18 +55,17 @@ func TestEverySettingRowDrawsWhatItsNumberMeans(t *testing.T) { drawn := sheetRowsDrawn(t, a, 120) for _, want := range []string{ - "ssh reuse", "300s", - "ssh heartbeat", "3s", - "approval countdown", "10s", - "background after", "30s", - "task countdown", "15s", - "compact at", "60%", - "answer room", "65536 tok", - "working set", "160000 tok", - "context reuse", "250%", - "memory floor", "1536 MB", - "busy machine", "1.5 per core", - "tenure after", "3 clean firings", + "SSH connection reuse", "300s", + "SSH keepalive interval", "3s", + "pause approval timer after", "10s", + "background shell commands after", "30s", + "start proposed tasks after", "15s", + "compact context at", "60%", + "tokens reserved for replies", "65536 tok", + "working context limit", "160000 tok", + "context reuse limit", "250%", + "minimum available RAM", "1536 MB", + "maximum load per CPU core", "1.5 per core", } { found := false for _, row := range drawn { @@ -87,8 +90,8 @@ func TestEverySettingRowDrawsWhatItsNumberMeans(t *testing.T) { continue } key := strings.Join(fields[:len(fields)-1], " ") - if strings.Contains(key, "rounds") || strings.Contains(key, "tasks") || - strings.Contains(key, "heartbeats") || strings.HasPrefix(row, "$") { + if strings.Contains(key, "rounds") || strings.Contains(key, "attempts") || strings.Contains(key, "tasks") || + strings.Contains(key, "keepalives") || strings.HasPrefix(row, "$") { // The label names what is counted, which is the other way a row may // answer ([config.UnitInLabel]). continue @@ -121,17 +124,15 @@ func bareFigure(s string) bool { // stored moves: the keys are untouched and only the tab a row is drawn under // changed. func TestTheSshRowsAreOnTheTabAboutReachingAnotherMachine(t *testing.T) { - for _, key := range []string{ - config.KeySSHControlPersist, config.KeySSHServerAlive, - config.KeySSHServerMisses, config.KeySSHIPQoS, - } { - meta, ok := settingUI[key] + registry := config.NewSettings(config.SettingsOptions{ProfileDir: t.TempDir()}) + for _, key := range []string{config.KeySSHControlPersist, config.KeySSHServerAlive, config.KeySSHServerMisses, config.KeySSHIPQoS} { + row, ok := registry.Row(key) if !ok { - t.Fatalf("row %q has no place on the panel at all", key) + t.Fatalf("missing registry row %s", key) } - if meta.tab != tabWorkspace { - t.Fatalf("row %q is drawn under %q\n drawn: %s tab · %s\n want: %s tab · %s", - key, meta.tab, meta.tab, meta.label, tabWorkspace, meta.label) + meta, ok := settingMetaFor(row) + if !ok || meta.tab != tabConnections || !row.ChatPresentation().Advanced { + t.Fatalf("%s must be in Connections Advanced", key) } } } @@ -296,6 +297,10 @@ func sheetRowDrawn(t *testing.T, a *app, label string, width int) (string, strin a.sheet.build() }() for tab := range settingTabs { + if a.sheet.advanced == nil { + a.sheet.advanced = map[string]bool{} + } + a.sheet.advanced[settingTabs[tab]] = true a.sheet.tab = tab a.sheet.cursor, a.sheet.top = 0, 0 a.sheet.build() @@ -367,12 +372,12 @@ func TestASettingsValueSitsInAColumnAndNeverButtsItsLabel(t *testing.T) { // A HUNDRED AND SIXTY: the same function, the other end. The tail stops at // the measure and the rest of the frame is left empty. - wide, _ := sheetRowDrawn(t, a, "ssh reuse", 160) + wide, _ := sheetRowDrawn(t, a, "SSH connection reuse", 160) if got := ansi.StringWidth(wide); got > 102 { t.Fatalf("at 160 cells the row is %d cells wide and reads\n %q\n— the value is right-aligned to the frame "+ "rather than to the %d-cell measure", got, wide, overlayMeasure) } - if !strings.Contains(wide, "ssh reuse") || !strings.Contains(wide, "300s") { + if !strings.Contains(wide, "SSH connection reuse") || !strings.Contains(wide, "300s") { t.Fatalf("the 160-cell row is no longer the pair this test is about: %q", wide) } From 9a50eb62c495994b6b84aed632ba7fde1d7d6712 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:43:14 -0400 Subject: [PATCH 13/31] Clarify settings choices and search scope while preserving stored values --- internal/config/settings_presentation.go | 9 ++- internal/manual/chat/settings.md | 6 ++ internal/tui3/caretpark_test.go | 10 ++- internal/tui3/hometiplayout_test.go | 31 ++++---- internal/tui3/place_settings.go | 10 ++- internal/tui3/settings.go | 97 +++++++++++++++--------- internal/tui3/settings_words.go | 49 ++++++++++++ internal/tui3/settings_words_test.go | 60 +++++++++++++++ internal/tui3/settingssearch_test.go | 22 +++--- 9 files changed, 220 insertions(+), 74 deletions(-) create mode 100644 internal/tui3/settings_words.go create mode 100644 internal/tui3/settings_words_test.go diff --git a/internal/config/settings_presentation.go b/internal/config/settings_presentation.go index 361298e261..22218a7f40 100644 --- a/internal/config/settings_presentation.go +++ b/internal/config/settings_presentation.go @@ -56,6 +56,7 @@ func (s Setting) ChatPresentation() SettingPresentation { p.Label = "mouse interaction" case KeyWork: p.Label = "completed tool details" + p.Description = "Collapsed keeps completed tool steps behind one summary. Expanded keeps those details visible." case KeyIcons: p.Label = "tool icons" case KeyTaskColumn: @@ -74,11 +75,13 @@ func (s Setting) ChatPresentation() SettingPresentation { case KeyTelemetry: p.Category, p.Label = "Privacy", "share anonymous usage reports" case KeyModelPool: - p.Category, p.Label = "Privacy", "model pool participation" + p.Category, p.Label = "Privacy", "shared model recommendations" + p.Description = "Use and contribute receives model recommendations and shares text-free performance measurements. Use only sends nothing. Off does neither. Code, prompts and file paths are never included." case KeyModelPoolPublicKey: p.Category, p.Label, p.Advanced = "Privacy", "model pool signing key", true case KeyToolApprovalMode: p.Label = "tool approvals" + p.Description = "Allow by default runs tools without asking. Ask each time requests approval. Block by default refuses tools. Tool exceptions override this choice; dangerous shell commands still require approval." case KeyToolApprovals: p.Label = "tool exceptions" case KeyGuardian: @@ -91,8 +94,10 @@ func (s Setting) ChatPresentation() SettingPresentation { p.Category, p.Label = "Permissions", "start proposed tasks after" case KeyTaskSettle: p.Category, p.Label = "Tasks", "when task results need a decision" + p.Description = "Ask me leaves the decision with you. Let the chat decide reviews the work and asks you only when it cannot decide." case KeyTaskStart: - p.Label = "task planning" + p.Label = "before starting a task" + p.Description = "Assess the brief checks its scope before starting a worker. Start directly skips that check. Either choice starts one worker; /task solo explicitly requests solo work." case KeyTaskAudit: p.Label = "review task results" case KeyTaskRepairRounds: diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index a75e860cc8..a420ca7c54 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -77,3 +77,9 @@ routing, internal role overrides and context limits. **Privacy** separates input history, unfinished drafts, anonymous usage reporting and Model Pool participation. Disabling one is not a promise that all the others are disabled. Search accepts the old labels as well as the new organization. + +Search stays visible above the settings list and searches every category, including Advanced controls and connected apps. Model pickers have their own **Filter models** box. Escape clears a settings search first; another Escape closes settings. Hints stay under General as **show hints**: on shows contextual tips, off hides them. Keyboard instructions and setting explanations remain visible either way. + +Display choices use plain language while existing configuration values keep their meaning. For example, **before starting a task** offers **assess the brief** or **start directly**; **completed tool details** offers **collapsed** or **expanded**. **Shared model recommendations** can **use and contribute**, **use only**, or remain **off**. + +Resident-only practice controls remain in the resident configuration. They do not appear in chat settings, and `/budget practice` cannot edit them from chat. diff --git a/internal/tui3/caretpark_test.go b/internal/tui3/caretpark_test.go index 01af3db90a..f18ca89b5b 100644 --- a/internal/tui3/caretpark_test.go +++ b/internal/tui3/caretpark_test.go @@ -88,8 +88,10 @@ func TestTheSearchBoxParksItsCaretAfterTheQuery(t *testing.T) { t.Fatalf("the caret stands at %d, after %q, want immediately after %q", caretX, before, "provide") } - if !strings.Contains(strings.Join(plainLines, "\n"), "saved to") { - t.Fatal("the settings frame lost its saved-profile note") + if item, ok := a.sheet.current(); ok && item.row.Key != "" { + if scope := item.row.ChatPresentation().Scope; scope != "" && !strings.Contains(strings.Join(plainLines, "\n"), scope) { + t.Fatalf("the selected setting lost its scope: %q", scope) + } } } @@ -102,7 +104,7 @@ func TestTheSearchBoxRestsItsCaretOnTheSameRow(t *testing.T) { a.raiseSettings() a.sheet.build() lines, _, caretX, caretY, _ := a.placeFrameNow(90, 30) - boxRow, plainLines := caretRowOf(t, lines, "›", "type to search") + boxRow, plainLines := caretRowOf(t, lines, "›", "Search all settings") if caretY != boxRow { t.Fatalf("the resting caret is on row %d, want the box's row %d:\n%s", caretY, boxRow, strings.Join(plainLines, "\n")) @@ -164,7 +166,7 @@ func TestTheConnectionsEntryParksItsCaretInTheBox(t *testing.T) { // AND NOT IN THE RESTING SEARCH BOX AT THE FOOT, which is where it stood // before the sheet learned to answer for its own row's box. for i, line := range plainLines { - if strings.Contains(line, "type to search") && i == caretY { + if strings.Contains(line, "Search all settings") && i == caretY { t.Fatalf("the caret is parked in the resting search box at row %d", i) } } diff --git a/internal/tui3/hometiplayout_test.go b/internal/tui3/hometiplayout_test.go index c9899bfeca..2684a4b160 100644 --- a/internal/tui3/hometiplayout_test.go +++ b/internal/tui3/hometiplayout_test.go @@ -179,37 +179,32 @@ func TestHomeKeysRowCarriesTheProjectAtItsRight(t *testing.T) { } } -// The row that silences the tips is `disable hints` on the Workspace tab, off -// by default, and it reads the other way up from the key underneath it. -func TestDisableHintsIsAWorkspaceRowThatReadsTheOtherWayUp(t *testing.T) { +// Hints live with interface preferences and read positively, while old profile +// values keep their meaning. The registry's legacy inversion is not exposed. +func TestShowHintsIsAGeneralPreferenceThatPreservesTheProfile(t *testing.T) { a, dir := sheetApp(t) a.openSettings() cursorTo(t, a, config.KeyHints) item := a.sheet.items[a.sheet.cursor] - if item.meta.tab != tabWorkspace || item.meta.label != "disable hints" { - t.Fatalf("the hints row is %q on the %s tab, want \"disable hints\" on Workspace", item.meta.label, item.meta.tab) + if item.meta.tab != tabGeneral || item.meta.label != "show hints" { + t.Fatalf("hints row: %q on %s", item.meta.label, item.meta.tab) } - // THE LINE UNDER IT IS THE OWNER'S OWN WORDING (2026-09-24). - if want := "disable💡 tips everywhere (requires restart)"; item.meta.about != want { - t.Fatalf("the line under the hints row reads %q, want %q", item.meta.about, want) - } - if !strings.Contains(plain(frame(a)), "disable💡 tips everywhere (requires restart)") { - t.Fatal("the panel does not draw the line under the selected hints row") - } - if got := item.row.Value(); got != "off" { - t.Fatalf("a fresh profile reads %q, want off (hints shown)", got) + if !strings.Contains(plain(frame(a)), "Show contextual tips while you work.") { + t.Fatal("selected hints row does not explain its effect") } if !config.HintsAt(dir) { - t.Fatal("a fresh profile has hints off underneath") + t.Fatal("fresh profile must show tips") } drive(t, a, key("enter")) if config.HintsAt(dir) { - t.Fatal("flipping disable hints on did not silence the tips") + t.Fatal("turning show hints off did not persist") } a.closeSettings() a.openSettings() cursorTo(t, a, config.KeyHints) - if got := a.sheet.items[a.sheet.cursor].row.Value(); got != "on" { - t.Fatalf("after the flip the row reads %q, want on", got) + item = a.sheet.items[a.sheet.cursor] + shown := plain(strings.Join(a.sheet.rowLines(item, false, false, 80, a.pal), "\n")) + if !strings.Contains(shown, "off") { + t.Fatalf("reopened preference lost off state: %s", shown) } } diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 428747edd9..2b7a272058 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -98,7 +98,11 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { // (PLACES-AUDIT.md finding 11); the filled chip and one blank row are the // whole of what separates the bar from the rows. if sidebar { - rows = append(rows, placeRow{text: " " + placeHeading(settingTabs[s.tab], pal)}) + heading := settingTabs[s.tab] + if s.searching() { + heading = "Search results" + } + rows = append(rows, placeRow{text: " " + placeHeading(heading, pal)}) } else { rows = append(rows, placeRow{text: sheetTabBar(width, s.tab, pal), hit: sheetHit{kind: sheetHitTabs}}) } @@ -125,7 +129,7 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { room = 1 } if s.sel != nil { - filter, _, _ := draftBlock(&s.sel.pick.filter, pal, width-2, 1, "type to filter", "") + filter, _, _ := draftBlock(&s.sel.pick.filter, pal, width-2, 1, "Filter models", "") for _, line := range filter { rows = append(rows, placeRow{text: " " + line}) } @@ -370,7 +374,7 @@ func (placeSettings) caretRow(a *app, width int, rows []placeRow) (int, int, boo } box, placeholder := &a.sheet.query, "Search all settings" if a.sheet.sel != nil { - box, placeholder = &a.sheet.sel.pick.filter, "type to filter" + box, placeholder = &a.sheet.sel.pick.filter, "Filter models" } block, column, at := draftBlock(box, a.pal, width-2, 1, placeholder, "") if at >= 0 && at < len(block) { diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 6a05887d03..3bcd7748c2 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -850,7 +850,11 @@ func (s *sheet) finishCategory() { } advanced := make([]sheetItem, 0) catalog := make([]sheetItem, 0) - for _, item := range s.items { + for at, item := range s.items { + if title == tabConnections && item.heading() && at+1 < len(s.items) && s.items[at+1].service != nil { + shown = append(shown, item) + continue + } if title == tabConnections && (item.conn != nil || item.heading()) { catalog = append(catalog, item) continue @@ -858,7 +862,7 @@ func (s *sheet) finishCategory() { if item.row.Key != "" && item.row.ChatPresentation().Hidden { continue } - if item.row.ChatPresentation().Advanced || item.role != nil || item.autonomy != nil || item.crewDoor { + if item.heading() || item.row.ChatPresentation().Advanced || item.role != nil || item.autonomy != nil || item.crewDoor { advanced = append(advanced, item) } else if !item.heading() || title == tabConnections { shown = append(shown, item) @@ -1403,6 +1407,43 @@ func (s *sheet) searching() bool { return strings.TrimSpace(s.query.String()) != // THE FIRST MATCH: a person who typed three letters and found the thing has // already been told which tab it lives on, so backing the search out leaves // them there instead of back where they started. +// appendModelConnections keeps provider management available beside its API key +// after the category move, using the same actions as the connection picker. +func (s *sheet) appendModelConnections() { + if s.sources.Empty() { + return + } + // THE EMPTY PROFILE KEEPS THE DOOR AND DRAWS NOTHING ELSE: no services + // head, no connection row, no switcher (the emptiness test pins the + // absence of the section), because a row that could do nothing is + // decoration. The add row is an action, not decoration — a profile with + // no custom connection yet is the one that needs the door — so it stands + // alone when no service row stands beside it (customAddRow). + // THE EMPTY PROFILE KEEPS THE DOOR AND DRAWS NOTHING ELSE: no + // providers head, no default row, no connection rows — a row + // that could do nothing is decoration, and the add row stands + // alone when no service row stands beside it (the emptiness law). + services := modelServiceRows(s.profileDir, s.sources) + if len(services) > 0 { + s.items = append(s.items, sheetItem{head: "providers"}) + // THE DEFAULT PROVIDER LEADS: openrouter answers by default + // and is the row a person reads first. + if defaultRow := defaultServiceRow(s.profileDir, s.sources); defaultRow != nil { + s.items = append(s.items, sheetItem{service: defaultRow}) + } + for _, service := range services { + s.items = append(s.items, sheetItem{service: service}) + } + // THE SWITCHER RIDES BETWEEN THE CONNECTIONS AND THE DOOR: + // the add row is last, because the list reads as the + // providers you have, and the door to add another closes it. + if switcher := s.connectionSwitcherRow(); switcher != nil { + s.items = append(s.items, sheetItem{service: switcher}) + } + } + s.items = append(s.items, sheetItem{service: customAddRow()}) +} + func (s *sheet) build() { s.items = s.items[:0] // THE SEARCH'S SPAN STARTS EMPTY WITH THE LIST: every kept row's label @@ -1425,6 +1466,9 @@ func (s *sheet) build() { for _, row := range s.tabRows() { meta, _ := s.metaFor(row) s.items = append(s.items, sheetItem{row: row, meta: meta}) + if row.Key == config.KeyAPIKey { + s.appendModelConnections() + } } s.buildConnections() s.finishCategory() @@ -1463,37 +1507,10 @@ func (s *sheet) build() { routingHeadDone = true } s.items = append(s.items, sheetItem{row: row, meta: meta}) - if row.Key == config.KeyAPIKey && !s.sources.Empty() { - // THE EMPTY PROFILE KEEPS THE DOOR AND DRAWS NOTHING ELSE: no services - // head, no connection row, no switcher (the emptiness test pins the - // absence of the section), because a row that could do nothing is - // decoration. The add row is an action, not decoration — a profile with - // no custom connection yet is the one that needs the door — so it stands - // alone when no service row stands beside it (customAddRow). - // THE EMPTY PROFILE KEEPS THE DOOR AND DRAWS NOTHING ELSE: no - // providers head, no default row, no connection rows — a row - // that could do nothing is decoration, and the add row stands - // alone when no service row stands beside it (the emptiness law). - services := modelServiceRows(s.profileDir, s.sources) - if len(services) > 0 { - s.items = append(s.items, sheetItem{head: "providers"}) - // THE DEFAULT PROVIDER LEADS: openrouter answers by default - // and is the row a person reads first. - if defaultRow := defaultServiceRow(s.profileDir, s.sources); defaultRow != nil { - s.items = append(s.items, sheetItem{service: defaultRow}) - } - for _, service := range services { - s.items = append(s.items, sheetItem{service: service}) - } - // THE SWITCHER RIDES BETWEEN THE CONNECTIONS AND THE DOOR: - // the add row is last, because the list reads as the - // providers you have, and the door to add another closes it. - if switcher := s.connectionSwitcherRow(); switcher != nil { - s.items = append(s.items, sheetItem{service: switcher}) - } - } - s.items = append(s.items, sheetItem{service: customAddRow()}) + if row.Key == config.KeyAPIKey { + s.appendModelConnections() } + // THE ROLES SECTION HANGS OFF THE ROW IT WRITES. Every pin those rows // set lands in "pinned roles" and nowhere else, so it is drawn // directly under it: a person reading one is reading the other, and a @@ -1730,6 +1747,7 @@ func (s *sheet) settingMatch(row config.Setting, meta settingMeta, tab string, t fields[4] = tab presentation := row.ChatPresentation() fields = append(fields, presentation.Aliases...) + fields = append(fields, settingValueWord(row)) if old, ok := settingUI[row.Key]; ok { fields = append(fields, old.label, old.tab) } @@ -3596,7 +3614,7 @@ func (s *sheet) rowLinesWithin(item sheetItem, selected, hovered bool, width, bo // The unit is the registry's ([config.Setting.Unit]), so it is stated once, // beside the default, rather than spelled again by every surface that draws // a number ([config.Setting.Reading]). - value := item.row.Reading() + value := settingValueWord(item.row) if item.row.Key == config.KeyHints { if item.row.Value() == "on" { value = "off" @@ -3800,7 +3818,7 @@ func (s *sheet) footNote() string { func (s *sheet) keysLine() string { switch { case s.edit != nil: - return "enter save · empty clears · esc cancel" + return "enter save · esc cancel" case s.sel != nil: // THE LEGEND SAYS `→ providers` ONLY WHERE `→` OPENS THEM — on a row that // has a provider row behind it. Offering the key on the drawing slot would @@ -3827,9 +3845,16 @@ func (s *sheet) keysLine() string { // says so on the row it works on and nowhere else — a line that offered // del everywhere would be offering it on rows where it does nothing. if item, ok := s.current(); ok && item.role != nil && item.role.pin != "" { - return "↑↓ move · enter pin · del unpin · type to search · esc close" + back := "esc close" + if s.searching() { + back = "esc clear search" + } + return "↑↓ move · enter pin · del unpin · type to search all · " + back + } + if s.searching() { + return "↑↓ results · enter change · esc clear search" } - return "↑↓ settings · ←→ categories · enter change · type to search all · esc back" + return "↑↓ settings · ←→ categories · enter change · type to search all · esc close" } } diff --git a/internal/tui3/settings_words.go b/internal/tui3/settings_words.go new file mode 100644 index 0000000000..111c173309 --- /dev/null +++ b/internal/tui3/settings_words.go @@ -0,0 +1,49 @@ +package tui3 + +import "github.com/Agent-Field/codeaf/internal/config" + +// settingValueWord changes only the reading. Editors and persistence keep the +// existing values, and search accepts both the reading and the saved spelling. +func settingValueWord(row config.Setting) string { + value := row.Reading() + switch row.Key { + case config.KeyTaskStart: + switch row.Value() { + case "sized": + return "assess the brief" + case "single": + return "start directly" + } + case config.KeyTaskSettle: + switch row.Value() { + case "ask": + return "ask me" + case "auto": + return "let the chat decide" + } + case config.KeyToolApprovalMode: + switch row.Value() { + case "allow": + return "allow by default" + case "prompt": + return "ask each time" + case "deny": + return "block by default" + } + case config.KeyWork: + switch row.Value() { + case "fold": + return "collapsed" + case "open": + return "expanded" + } + case config.KeyModelPool: + switch row.Value() { + case "on": + return "use and contribute" + case "read": + return "use only" + } + } + return value +} diff --git a/internal/tui3/settings_words_test.go b/internal/tui3/settings_words_test.go new file mode 100644 index 0000000000..68a2f3414f --- /dev/null +++ b/internal/tui3/settings_words_test.go @@ -0,0 +1,60 @@ +package tui3 + +import ( + "github.com/Agent-Field/codeaf/internal/config" + "strings" + "testing" +) + +func TestSettingsWordsKeepSavedChoicesAndSearchableReadings(t *testing.T) { + for _, test := range []struct{ key, raw, shown string }{ + {config.KeyTaskStart, "sized", "assess the brief"}, + {config.KeyTaskStart, "single", "start directly"}, + {config.KeyTaskSettle, "auto", "let the chat decide"}, + {config.KeyToolApprovalMode, "prompt", "ask each time"}, + {config.KeyWork, "fold", "collapsed"}, + {config.KeyModelPool, "read", "use only"}, + } { + t.Run(test.key+test.raw, func(t *testing.T) { + a, _ := sheetApp(t) + a.openSettings() + cursorTo(t, a, test.key) + item, _ := a.sheet.current() + if err := item.row.Apply(test.raw); err != nil { + t.Fatal(err) + } + if got := item.row.Value(); got != test.raw { + t.Fatalf("saved choice %q", got) + } + if got := settingValueWord(item.row); got != test.shown { + t.Fatalf("reading %q, want %q", got, test.shown) + } + for _, query := range []string{test.raw, test.shown} { + if itemAt(searchItems(t, a, query), test.key) < 0 { + t.Fatalf("cannot find %s via %q", test.key, query) + } + } + }) + } +} + +func TestSettingsSearchAndHelpNameTheirCurrentScope(t *testing.T) { + a, _ := sheetApp(t) + a.width, a.height = 120, 35 + a.openSettings() + a.raiseSettings() + if !strings.Contains(a.sheet.keysLine(), "esc close") { + t.Fatal("resting Escape must say close") + } + typeQuery(t, a, "mouse") + if !strings.Contains(plain(frame(a)), "Search results") { + t.Fatal("global results still look like one category") + } + if !strings.Contains(a.sheet.keysLine(), "esc clear search") { + t.Fatal("search Escape must name what it clears") + } + drive(t, a, key("esc")) + if !a.at(pageSettings) || a.sheet.searching() { + t.Fatal("clearing search closed settings") + } +} diff --git a/internal/tui3/settingssearch_test.go b/internal/tui3/settingssearch_test.go index b8c258e79c..971f6382fd 100644 --- a/internal/tui3/settingssearch_test.go +++ b/internal/tui3/settingssearch_test.go @@ -132,9 +132,9 @@ func TestTheSearchCarriesTheMatchedLettersInBold(t *testing.T) { cursorTo(t, a, config.KeyToolApprovalMode) drive(t, a, key("enter")) // allow -> deny drive(t, a, key("enter")) // deny -> prompt - typeQuery(t, a, "ask") + typeQuery(t, a, "tool") // The approval row, kept by the search, and where the query landed on it: - // the label's own head, because "ask" is the word the row's name begins + // the label's own head, because "tool" is the word the row's name begins // with — and not its middle, and not a whole word either. at := -1 for i, item := range a.sheet.items { @@ -144,7 +144,7 @@ func TestTheSearchCarriesTheMatchedLettersInBold(t *testing.T) { } } if at < 0 { - t.Fatalf("\"ask\" did not keep the approval row:\n%s", strings.Join(sheetLabels(a), "\n")) + t.Fatalf("\"tool\" did not keep the approval row:\n%s", strings.Join(sheetLabels(a), "\n")) } // Walk the cursor off the row: the cursor's row is bold whole already, and // this test is about the resting rows a person scans down. @@ -155,15 +155,15 @@ func TestTheSearchCarriesTheMatchedLettersInBold(t *testing.T) { t.Fatal("the cursor never left the approval row") } hit := a.sheet.itemHit(a.sheet.items[at]) - if len(hit) != 3 || hit[0] != 0 || hit[1] != 1 || hit[2] != 2 { - t.Fatalf("the approval row's label hit is %v; want its first three bytes, 0 1 2", hit) + if len(hit) != 4 || hit[0] != 0 || hit[1] != 1 || hit[2] != 2 || hit[3] != 3 { + t.Fatalf("the approval row's label hit is %v; want its first four bytes, 0 1 2 3", hit) } lines, _ := a.sheet.listLines(a.width, a.height, a.pal, -1) // THE EMPHASIS IS EXACTLY THE MATCHED BYTES: bold over the row's own dim - // for "ask", plain dim after it — the whole label spelled out, so a bold + // for "tool", plain dim after it — the whole label spelled out, so a bold // run that bled past the match, or one that skipped a matched byte, does // not contain this string. - want := a.pal.bold(a.pal.dim("ask")) + a.pal.dim(" before running") + want := a.pal.bold(a.pal.dim("tool")) + a.pal.dim(" approvals") found := false for _, line := range lines { if strings.Contains(line, want) { @@ -176,7 +176,7 @@ func TestTheSearchCarriesTheMatchedLettersInBold(t *testing.T) { } // AND THE MATCH IS ON THE FIELD IT WON AND NOWHERE ELSE: "prompt" is // the approval row's own VALUE — the word the gate carries — and a query - // of both words keeps the row with "ask" on its label and nothing else: + // of both words keeps the row with "tool" on its label and nothing else: // the span names exactly the bytes of the term that won the label, and a // term that won the value contributes no emphasis to a field it did not. typeQuery(t, a, " prompt") @@ -188,10 +188,10 @@ func TestTheSearchCarriesTheMatchedLettersInBold(t *testing.T) { } } if at < 0 { - t.Fatalf("\"ask prompt\" did not keep the approval row:\n%s", strings.Join(sheetLabels(a), "\n")) + t.Fatalf("\"tool prompt\" did not keep the approval row:\n%s", strings.Join(sheetLabels(a), "\n")) } - if hit := a.sheet.itemHit(a.sheet.items[at]); !reflect.DeepEqual(hit, []int{0, 1, 2}) { - t.Fatalf("\"ask prompt\" carried %v on the approval row's label; want exactly \"ask\"'s three bytes — the value's word stays on the value", hit) + if hit := a.sheet.itemHit(a.sheet.items[at]); !reflect.DeepEqual(hit, []int{0, 1, 2, 3}) { + t.Fatalf("\"tool prompt\" carried %v on the approval row's label; want exactly \"tool\"'s four bytes — the value's word stays on the value", hit) } } From 79bf5d410ce52115832b8a10e315924c167e955f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:44:54 -0400 Subject: [PATCH 14/31] Describe task assessment and approval defaults as they actually run --- internal/config/settings_presentation.go | 6 +++--- internal/manual/chat/settings.md | 2 +- internal/tui3/settings_words.go | 6 +++--- internal/tui3/settings_words_test.go | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/config/settings_presentation.go b/internal/config/settings_presentation.go index 22218a7f40..12625b9d59 100644 --- a/internal/config/settings_presentation.go +++ b/internal/config/settings_presentation.go @@ -81,7 +81,7 @@ func (s Setting) ChatPresentation() SettingPresentation { p.Category, p.Label, p.Advanced = "Privacy", "model pool signing key", true case KeyToolApprovalMode: p.Label = "tool approvals" - p.Description = "Allow by default runs tools without asking. Ask each time requests approval. Block by default refuses tools. Tool exceptions override this choice; dangerous shell commands still require approval." + p.Description = "Allow by default runs tools without asking. Ask by default requests approval except for safe read-only calls and tools that ask for permission themselves. Block by default refuses tools. Tool exceptions override this choice; dangerous shell commands still require approval." case KeyToolApprovals: p.Label = "tool exceptions" case KeyGuardian: @@ -96,8 +96,8 @@ func (s Setting) ChatPresentation() SettingPresentation { p.Category, p.Label = "Tasks", "when task results need a decision" p.Description = "Ask me leaves the decision with you. Let the chat decide reviews the work and asks you only when it cannot decide." case KeyTaskStart: - p.Label = "before starting a task" - p.Description = "Assess the brief checks its scope before starting a worker. Start directly skips that check. Either choice starts one worker; /task solo explicitly requests solo work." + p.Label = "task scope assessment" + p.Description = "Assesses the brief while the worker starts, helping it identify work to delegate. Skip assessment avoids that extra model call. Both start work immediately and can delegate parts already listed in the brief." case KeyTaskAudit: p.Label = "review task results" case KeyTaskRepairRounds: diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index a420ca7c54..0f8f9e8bc9 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -80,6 +80,6 @@ are disabled. Search accepts the old labels as well as the new organization. Search stays visible above the settings list and searches every category, including Advanced controls and connected apps. Model pickers have their own **Filter models** box. Escape clears a settings search first; another Escape closes settings. Hints stay under General as **show hints**: on shows contextual tips, off hides them. Keyboard instructions and setting explanations remain visible either way. -Display choices use plain language while existing configuration values keep their meaning. For example, **before starting a task** offers **assess the brief** or **start directly**; **completed tool details** offers **collapsed** or **expanded**. **Shared model recommendations** can **use and contribute**, **use only**, or remain **off**. +Display choices use plain language while existing configuration values keep their meaning. For example, **task scope assessment** offers **assess while working** or **skip assessment**; **completed tool details** offers **collapsed** or **expanded**. **Shared model recommendations** can **use and contribute**, **use only**, or remain **off**. Resident-only practice controls remain in the resident configuration. They do not appear in chat settings, and `/budget practice` cannot edit them from chat. diff --git a/internal/tui3/settings_words.go b/internal/tui3/settings_words.go index 111c173309..fede54dddd 100644 --- a/internal/tui3/settings_words.go +++ b/internal/tui3/settings_words.go @@ -10,9 +10,9 @@ func settingValueWord(row config.Setting) string { case config.KeyTaskStart: switch row.Value() { case "sized": - return "assess the brief" + return "assess while working" case "single": - return "start directly" + return "skip assessment" } case config.KeyTaskSettle: switch row.Value() { @@ -26,7 +26,7 @@ func settingValueWord(row config.Setting) string { case "allow": return "allow by default" case "prompt": - return "ask each time" + return "ask by default" case "deny": return "block by default" } diff --git a/internal/tui3/settings_words_test.go b/internal/tui3/settings_words_test.go index 68a2f3414f..403ca3dac1 100644 --- a/internal/tui3/settings_words_test.go +++ b/internal/tui3/settings_words_test.go @@ -8,10 +8,10 @@ import ( func TestSettingsWordsKeepSavedChoicesAndSearchableReadings(t *testing.T) { for _, test := range []struct{ key, raw, shown string }{ - {config.KeyTaskStart, "sized", "assess the brief"}, - {config.KeyTaskStart, "single", "start directly"}, + {config.KeyTaskStart, "sized", "assess while working"}, + {config.KeyTaskStart, "single", "skip assessment"}, {config.KeyTaskSettle, "auto", "let the chat decide"}, - {config.KeyToolApprovalMode, "prompt", "ask each time"}, + {config.KeyToolApprovalMode, "prompt", "ask by default"}, {config.KeyWork, "fold", "collapsed"}, {config.KeyModelPool, "read", "use only"}, } { From 4d60c41ec431b9a0136083a34ee618c0d85fa947 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:44:59 -0400 Subject: [PATCH 15/31] Check rendered navigation labels in narrow layout laws --- internal/tui3/narrow_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui3/narrow_test.go b/internal/tui3/narrow_test.go index cb3ad51bce..f289a626e2 100644 --- a/internal/tui3/narrow_test.go +++ b/internal/tui3/narrow_test.go @@ -72,7 +72,7 @@ func TestANavTooNarrowForEveryWordFoldsIntoMore(t *testing.T) { a := placeApp(t) for _, width := range []int{34, 40, 48} { bar := navPlaces(a, width, false) - if !strings.Contains(bar, a.page.word()) { + if !strings.Contains(bar, navLabel(a.page)) { t.Fatalf("at %d columns the nav drew\n\t%q\nand folded the place you are standing in (%q)", width, bar, a.page.word()) } if !strings.HasSuffix(bar, a.navMoreWord(a.pal)) || !a.navMore.span.pressable() { @@ -326,7 +326,7 @@ func TestTheTopLineGivesThingsUpInTheOwnersOrder(t *testing.T) { if got := ansi.StringWidth(line); got > width { t.Fatalf("at %d columns the top line is %d cells wide:\n\t%q", width, got, line) } - if !strings.Contains(line, product) || !strings.Contains(line, " chats ") { + if !strings.Contains(line, product) || !strings.Contains(line, " "+navLabel(pageChats)+" ") { t.Fatalf("at %d columns the top line lost the wordmark or the lit place:\n\t%q", width, line) } s := state{ From 83bddb6d515b2999a8b6905d92fd0e41a68600cf Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:50:14 -0400 Subject: [PATCH 16/31] Make setting action hints describe opening and expanding accurately --- internal/tui3/place_settings.go | 4 ++++ internal/tui3/settings.go | 18 ++++++++++++-- internal/tui3/settings_words_test.go | 36 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 2b7a272058..7bc069b11d 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -257,6 +257,10 @@ func (placeSettings) note(a *app, width int) []string { } item, ok := settingsFocused(a) scope := a.sheet.footNote() + if ok && (item.memoryDoor || item.advancedDoor) { + // Opening another view or a disclosure writes no setting. + scope = "" + } if ok && item.row.Key != "" { if name, pinned := item.row.PinnedBy(); pinned { scope = "held by " + name + " — unset it to change this here" diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 3bcd7748c2..36813e9667 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -3851,10 +3851,24 @@ func (s *sheet) keysLine() string { } return "↑↓ move · enter pin · del unpin · type to search all · " + back } + action := "change" + if item, ok := s.current(); ok { + switch { + case item.memoryDoor, item.crewDoor: + action = "open" + case item.advancedDoor: + action = "expand" + if s.advanced[settingTabs[s.tab]] { + action = "collapse" + } + case item.role != nil: + action = "choose model" + } + } if s.searching() { - return "↑↓ results · enter change · esc clear search" + return "↑↓ results · enter " + action + " · esc clear search" } - return "↑↓ settings · ←→ categories · enter change · type to search all · esc close" + return "↑↓ settings · ←→ categories · enter " + action + " · type to search all · esc close" } } diff --git a/internal/tui3/settings_words_test.go b/internal/tui3/settings_words_test.go index 403ca3dac1..7d13029719 100644 --- a/internal/tui3/settings_words_test.go +++ b/internal/tui3/settings_words_test.go @@ -58,3 +58,39 @@ func TestSettingsSearchAndHelpNameTheirCurrentScope(t *testing.T) { t.Fatal("clearing search closed settings") } } + +func TestSettingsActionHintsDescribeDoorsWithoutClaimingAWrite(t *testing.T) { + a, _ := sheetApp(t) + a.openSettings() + for i, name := range settingTabs { + if name == tabMemory { + a.sheet.tab = i + } + } + a.sheet.build() + if !strings.Contains(a.sheet.keysLine(), "enter open") { + t.Fatal("memory door claims to change a setting") + } + if strings.Contains(strings.Join((placeSettings{}).note(a, 80), " "), "saved") { + t.Fatal("inspection claims to write the profile") + } + for i, name := range settingTabs { + if name == tabTasks { + a.sheet.tab = i + } + } + a.sheet.build() + for i, item := range a.sheet.items { + if item.advancedDoor { + a.sheet.cursor = i + break + } + } + if !strings.Contains(a.sheet.keysLine(), "enter expand") { + t.Fatal("Advanced does not name expansion") + } + a.activate() + if !strings.Contains(a.sheet.keysLine(), "enter collapse") { + t.Fatal("expanded Advanced does not name collapse") + } +} From efa9ef46dd93d660e5f4fedfd4b781d24a3078dd Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:52:11 -0400 Subject: [PATCH 17/31] Assert displayed navigation labels across all view tests --- internal/tui3/designlanguage_test.go | 2 +- internal/tui3/header_home_test.go | 4 ++-- internal/tui3/onetopbar_test.go | 6 +++--- internal/tui3/pages_test.go | 8 ++++---- internal/tui3/placebar_test.go | 2 +- internal/tui3/placecounts_test.go | 2 +- internal/tui3/programroom_test.go | 2 +- internal/tui3/taskview_test.go | 4 ++-- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/tui3/designlanguage_test.go b/internal/tui3/designlanguage_test.go index 701405784d..2dcfa9e83c 100644 --- a/internal/tui3/designlanguage_test.go +++ b/internal/tui3/designlanguage_test.go @@ -801,7 +801,7 @@ func TestEveryPlaceHoldsAtThePlainFloor(t *testing.T) { width, id.word(), line) } } - if !strings.Contains(frame, id.word()) { + if !strings.Contains(frame, navLabel(id)) { t.Fatalf("at %d the %s place does not say its own name with no colour to "+ "say it with:\n%s", width, id.word(), frame) } diff --git a/internal/tui3/header_home_test.go b/internal/tui3/header_home_test.go index ac1008ef51..8489f1a6e9 100644 --- a/internal/tui3/header_home_test.go +++ b/internal/tui3/header_home_test.go @@ -78,7 +78,7 @@ func TestTheHeadAroundTheStripIsInertAndHomeHasPlainHover(t *testing.T) { if !a.navHover(x, navRow) || a.tabHover != pageHome { t.Fatal("Home padding is not part of the target") } - if !strings.Contains(plain(a.navLine(a.width, a.pal)), "·home ") { + if !strings.Contains(plain(a.navLine(a.width, a.pal)), "·"+navLabel(pageHome)+" ") { t.Fatal("Home has no plain-terminal hover feedback") } } @@ -195,7 +195,7 @@ func TestHomeTabKeepsItsSpellingAndPositionAcrossViews(t *testing.T) { a.pal = newPalette(profile, false) chat := headerHome(t, a) nav := plain(a.navLine(width, a.pal)) - if !strings.Contains(nav, " home ") || strings.Contains(nav, "Home") { + if !strings.Contains(nav, " "+navLabel(pageHome)+" ") || strings.Contains(nav, " home ") { t.Fatalf("the nav's home is misspelled: %q", nav) } cmd, took := a.navPress(chat.from, navRow) diff --git a/internal/tui3/onetopbar_test.go b/internal/tui3/onetopbar_test.go index c6f616f5aa..8fda983a9e 100644 --- a/internal/tui3/onetopbar_test.go +++ b/internal/tui3/onetopbar_test.go @@ -33,11 +33,11 @@ func TestOneTopNavOnAChatAndOnAPlace(t *testing.T) { a.width, a.height = width, 24 a.touch() chat, _, _ := a.frame() - cy, cx, cgap := barGeometry(chat, " home ") + cy, cx, cgap := barGeometry(chat, " "+navLabel(pageHome)+" ") chatSpans := append([]placeTabSpan(nil), a.tabs...) walkTo(t, a, pageSpend) frame, _, _ := a.frame() - py, px, pgap := barGeometry(frame, " home ") + py, px, pgap := barGeometry(frame, " "+navLabel(pageHome)+" ") if cy != navRow || py != navRow || a.tabRow != navRow { t.Fatalf("at %d the nav is on row %d in the chat and %d on the place", width, cy, py) } @@ -64,7 +64,7 @@ func TestOneTopNavOnAChatAndOnAPlace(t *testing.T) { t.Fatalf("at %d the place's head is not the nav, the air row, the rule and a blank", width) } lit := a.pal.onPlaces() - if !strings.Contains(frame, lit.bold(lit.accent(tabPad+"spend"+tabPad))) { + if !strings.Contains(frame, lit.bold(lit.accent(tabPad+navLabel(pageSpend)+tabPad))) { t.Fatalf("at %d the place you stand in is not lit in the accent", width) } if a.tabActivePaint(" x ") != activeGround(a.pal, " x ") { diff --git a/internal/tui3/pages_test.go b/internal/tui3/pages_test.go index 4e4bfd3362..7f613b4eb5 100644 --- a/internal/tui3/pages_test.go +++ b/internal/tui3/pages_test.go @@ -178,7 +178,7 @@ func TestEveryPlaceOpensOnAnEmptyMachine(t *testing.T) { if lines := strings.Split(text, "\n"); len(lines) != a.height { t.Fatalf("the empty %s place drew %d rows into %d", id.word(), len(lines), a.height) } - if !strings.Contains(text, id.word()) { + if !strings.Contains(text, navLabel(id)) { t.Fatalf("the empty %s place does not draw its own tab:\n%s", id.word(), text) } } @@ -232,7 +232,7 @@ func TestTheNavFoldsRatherThanBeingCut(t *testing.T) { shown := barPages(a.page, false) wide := navPlaces(a, 160, false) for _, id := range shown { - if !strings.Contains(wide, id.word()) { + if !strings.Contains(wide, navLabel(id)) { t.Fatalf("the wide nav is missing %q: %q", id.word(), wide) } } @@ -242,12 +242,12 @@ func TestTheNavFoldsRatherThanBeingCut(t *testing.T) { } // AND WHAT SURVIVES IS THE PLACE YOU ARE STANDING IN. Everything else is // something you can still reach; this is the one fact the row exists for. - if !strings.Contains(narrow, a.page.word()) { + if !strings.Contains(narrow, navLabel(a.page)) { t.Fatalf("the narrow nav dropped the place you are on: %q", narrow) } missing := 0 for _, id := range shown { - if !strings.Contains(narrow, id.word()) { + if !strings.Contains(narrow, navLabel(id)) { missing++ if !pagesHold(a.navMore.folded, id) { t.Fatalf("%q left the narrow nav and is not behind `more`: %q", id.word(), narrow) diff --git a/internal/tui3/placebar_test.go b/internal/tui3/placebar_test.go index a3a372ee24..98872ba648 100644 --- a/internal/tui3/placebar_test.go +++ b/internal/tui3/placebar_test.go @@ -298,7 +298,7 @@ func TestHoveringATabWordLiftsItsInkAndNothingElse(t *testing.T) { t.Fatalf("the frame changed height under a pointer: %d rows became %d", len(beforeRows), len(afterRows)) } hint := len(afterRows) - 1 - if got := plain(afterRows[hint]); !strings.Contains(got, placeChord(pageSettings)+" settings") || !strings.Contains(got, placeFor(pageSettings).about()) { + if got := plain(afterRows[hint]); !strings.Contains(got, placeChord(pageSettings)+" "+navLabel(pageSettings)) || !strings.Contains(got, placeFor(pageSettings).about()) { t.Fatalf("the hint line does not say what the hovered word opens: %q", got) } for at := range beforeRows { diff --git a/internal/tui3/placecounts_test.go b/internal/tui3/placecounts_test.go index b6244d77e4..9a5f24a08b 100644 --- a/internal/tui3/placecounts_test.go +++ b/internal/tui3/placecounts_test.go @@ -58,7 +58,7 @@ func TestATabWearsWhatChangedSinceYouLeftThatPlace(t *testing.T) { // MEMORY IS OFF THE BAR, SO ITS NUMBER IS ON THE MAP — the one row that // draws every place with its digit — and on the bar the moment you stand in // it (pages.go's [barPages]). - if bar := navPlaces(a, 160, true); !strings.Contains(bar, itoa(placeDigitOf(pageMemory))+" memory 3") { + if bar := navPlaces(a, 160, true); !strings.Contains(bar, itoa(placeDigitOf(pageMemory))+" "+navLabel(pageMemory)+" 3") { t.Fatalf("the map does not carry the count: %q", bar) } } diff --git a/internal/tui3/programroom_test.go b/internal/tui3/programroom_test.go index 5a7b407225..6eb6939193 100644 --- a/internal/tui3/programroom_test.go +++ b/internal/tui3/programroom_test.go @@ -458,7 +458,7 @@ func TestAProgramsRoomAtFortyFourColumns(t *testing.T) { openProgramRoomNow(t, a) frame, _, _ := a.frame() text := plain(frame) - for _, want := range []string{pageHome.word(), "the run", "implement · $1.24", "IMPLEMENT", "edited internal/auth/"} { + for _, want := range []string{navLabel(pageHome), "the run", "implement · $1.24", "IMPLEMENT", "edited internal/auth/"} { if !strings.Contains(text, want) { t.Fatalf("the room at 44 columns lost %q:\n%s", want, text) } diff --git a/internal/tui3/taskview_test.go b/internal/tui3/taskview_test.go index 5428eb0a67..002ff9b843 100644 --- a/internal/tui3/taskview_test.go +++ b/internal/tui3/taskview_test.go @@ -85,7 +85,7 @@ func TestTheTaskPageOpensOnItsKeyAndTakesTheWholeFrame(t *testing.T) { // title row, `history … esc close`, across its head; the router draws the // seven places above the rule and the shared hint line says how to leave, so // a title here would be the frame naming itself twice (pages.go). - if !strings.Contains(plain(frame), pageTasks.word()) { + if !strings.Contains(plain(frame), navLabel(pageTasks)) { t.Fatalf("the frame is not the tasks place:\n%s", plain(frame)) } // The conversation is not drawn under it, and neither is the box a person @@ -231,7 +231,7 @@ func TestTheTaskPageCommandIsHistoryAndNothingSpellsItTasks(t *testing.T) { // the manual both call it. The COMMAND keeps its own older word — /history // still opens this, and [taskSheetWord] is still what that command is called // — which is why the two are checked apart. - if text := taskSheetText(a); !strings.Contains(text, pageTasks.word()) { + if text := taskSheetText(a); !strings.Contains(text, navLabel(pageTasks)) { t.Fatalf("the place does not say what it is:\n%s", text) } } From 42ecfca2013ff2ce3f92e478b7191d70f21aaa27 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:53:31 -0400 Subject: [PATCH 18/31] Keep saved-value search test independent of help wording --- internal/tui3/settingssearch_test.go | 33 +++++++++++----------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/internal/tui3/settingssearch_test.go b/internal/tui3/settingssearch_test.go index 971f6382fd..2a94bcd5a1 100644 --- a/internal/tui3/settingssearch_test.go +++ b/internal/tui3/settingssearch_test.go @@ -73,30 +73,23 @@ func itemAt(items []sheetItem, key string) int { // its key nor its about line carries the word — a person who knows what the // panel does and not what it is called searches the way this test does. func TestTheSettingsSearchFindsARowByItsCurrentValue(t *testing.T) { - a, dir := sheetApp(t) + a, _ := sheetApp(t) a.openSettings() - // This search fixture exercises an explicitly saved prompt posture. - cursorTo(t, a, config.KeyToolApprovalMode) - drive(t, a, key("enter")) // allow -> deny - drive(t, a, key("enter")) // deny -> prompt - if got := config.ToolApprovalModeAt(dir); got != "prompt" { - t.Fatalf("the gate did not start at prompt: %q", got) + cursorTo(t, a, config.KeyToolApprovals) + item, _ := a.sheet.current() + // A tool rule is user content, absent from the label, key, aliases and help. + // Using "prompt" here also matched the new approval explanation fuzzily. + if err := item.row.Apply("grep:deny"); err != nil { + t.Fatal(err) } - // `prompt` is the probe: the gate's own label, key and about line carry no - // `p` at all, so the word cannot reach the row through any of them — not - // even as a scattered subsequence, which is the reach a fuzzy matcher - // always has — and only the value it holds can carry it. - if at := itemAt(searchItems(t, a, "prompt"), config.KeyToolApprovalMode); at < 0 { - t.Fatal("\"prompt\" did not find the gate holding it") + if at := itemAt(searchItems(t, a, "grep:deny"), config.KeyToolApprovals); at < 0 { + t.Fatal("search did not find the saved tool rule") } - // Cycle the gate away from prompt, and the same word loses the row. - cursorTo(t, a, config.KeyToolApprovalMode) - drive(t, a, key("enter")) - if got := config.ToolApprovalModeAt(dir); got != "allow" { - t.Fatalf("the cycle wrote %q, want allow", got) + if err := item.row.Apply(""); err != nil { + t.Fatal(err) } - if at := itemAt(searchItems(t, a, "prompt"), config.KeyToolApprovalMode); at >= 0 { - t.Fatal("\"prompt\" still found the gate after the gate stopped carrying it") + if at := itemAt(searchItems(t, a, "grep:deny"), config.KeyToolApprovals); at >= 0 { + t.Fatal("search retained a value that was cleared") } } From 4141677ea1323407989bda969db9d66e3c565db0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:55:56 -0400 Subject: [PATCH 19/31] Explain developer settings, task limits and editor behavior in the manual --- internal/manual/chat/settings.md | 39 ++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 0f8f9e8bc9..836f5d0e76 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -10,7 +10,8 @@ Enter changes it. The pointer can select the same controls. Type to search across categories, including advanced controls and connection services. Old setting names and configuration keys remain searchable. Clear the -query to return to browsing. Each category keeps uncommon controls behind +query to return to browsing; Escape clears it first, then closes settings. Model +pickers have their own **Filter models** box. Each category keeps uncommon controls behind `Advanced`; search can find them without opening that section first. Selected-row help explains the setting. Scope and activation details appear @@ -32,6 +33,14 @@ They are omitted here instead of suggesting that changing them teaches this chat Their stored values remain intact. Internal model slots that this surface cannot change are omitted; supported model controls remain in Models. +## General — interface preferences and hints + +**General** contains mouse interaction, completed tool details, tool icons, chat +switching, the task sidebar, updates and background reminders. **Show hints** is +positive: on shows contextual tips, off hides them. Keyboard instructions and +setting explanations stay visible either way. **Completed tool details** offers +**collapsed** or **expanded** without changing the meaning of an existing profile. + ## AI teams settings versus an individual AI team **AI teams** contains defaults for groups of AI chats, including questions to an @@ -63,8 +72,19 @@ expires and keeps waiting; it does not silently approve a tool. The proposed-task start timer has a different consequence: it starts the proposed work when it expires. Read each control's consequence before changing its duration. -**Tasks** contains task planning, review, repair and concurrency; machine resource -limits are advanced controls. A one-off task does not require an AI team. +**Tasks** contains scope assessment, review, repair and concurrency; machine +resource limits are advanced controls. A one-off task does not require an AI team. +**Task scope assessment** offers **assess while working** or **skip assessment**. +Both start work immediately; assessment helps identify work to delegate. + +**Concurrent tasks: no limit** means no user-set count cap, not disabled tasks. +A positive whole number caps simultaneous tasks, with the rest queued. Blank +restores no limit. CPU, available memory and provider limits can still hold new +work. Invalid input stays in the editor and does not change the saved preference. +The current engine reads the count cap at startup: restart the CLI to apply a +changed cap to already-open chats. Changing this preference does not stop a +running task. An unset task model follows the configured worker, falling back to +the current chat model when no worker model is set. ## Connect a service, change models or manage privacy @@ -75,11 +95,10 @@ Ordinary account connection does not require you to create your own OAuth app. routing, internal role overrides and context limits. **Privacy** separates input history, unfinished drafts, anonymous usage reporting -and Model Pool participation. Disabling one is not a promise that all the others -are disabled. Search accepts the old labels as well as the new organization. - -Search stays visible above the settings list and searches every category, including Advanced controls and connected apps. Model pickers have their own **Filter models** box. Escape clears a settings search first; another Escape closes settings. Hints stay under General as **show hints**: on shows contextual tips, off hides them. Keyboard instructions and setting explanations remain visible either way. - -Display choices use plain language while existing configuration values keep their meaning. For example, **task scope assessment** offers **assess while working** or **skip assessment**; **completed tool details** offers **collapsed** or **expanded**. **Shared model recommendations** can **use and contribute**, **use only**, or remain **off**. +and **shared model recommendations** (formerly Model Pool). Recommendations can +**use and contribute**, **use only**, or remain **off**. Disabling one privacy +control is not a promise that all the others are disabled. Search accepts old +labels as well as the new organization. -Resident-only practice controls remain in the resident configuration. They do not appear in chat settings, and `/budget practice` cannot edit them from chat. +Resident-only practice controls remain in resident configuration. They do not +appear in chat settings, and `/budget practice` cannot edit them from chat. From 2745d53f58ba45dc75403044f38412339cca528f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 14:56:36 -0400 Subject: [PATCH 20/31] Keep validation beside the setting input and explain empty count limits --- internal/tui3/place_settings.go | 15 +++--- internal/tui3/settings.go | 5 ++ internal/tui3/settings_organization_test.go | 52 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 7bc069b11d..4362a20c12 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -113,6 +113,13 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { for _, line := range block { rows = append(rows, placeRow{text: " " + line}) } + // Validation belongs beside the editable value, not in a distant footer. + // The draft and its caret keep their position when an error appears. + if s.msg != "" { + for _, line := range wrap(s.msg, max(1, width-4)) { + rows = append(rows, placeRow{text: " " + pal.bad(line)}) + } + } for len(rows) < room { rows = append(rows, placeRow{}) } @@ -238,6 +245,8 @@ func (placeSettings) box(a *app) *editor { func (placeSettings) note(a *app, width int) []string { pal := a.pal switch { + case a.sheet.edit != nil: + return nil // The label, accepted format and validation are beside the input. case a.sheet.msg != "": lines := strings.Split(a.sheet.msg, "\n") out := make([]string, 0, len(lines)) @@ -247,12 +256,6 @@ func (placeSettings) note(a *app, width int) []string { return out case a.sheet.sel != nil: return []string{" " + pal.dim(noteFit(a.sheet.sel.label, width-2))} - case a.sheet.edit != nil: - // THE LABEL IS THE NOTE AND THE VALUE IS THE COMPOSER. The panel used to - // draw both on one line of its own foot; under the router the box a person - // is typing in is THE composer, so what is left here is the one thing the - // box cannot say — which setting this is. - return []string{" " + pal.dim(noteFit(a.sheet.edit.label, width-2))} } item, ok := settingsFocused(a) diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 36813e9667..3fc5a33683 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1103,6 +1103,11 @@ func sheetEditNote(label string, row config.Setting) string { return label } accepts := row.Accepts() + // Only optional counts with the registry's no-limit reading can make this + // promise; other blank values may restore a default or have another meaning. + if row.Kind == config.SettingCount && row.EmptyLabel == config.NoLimitWord { + accepts += " · blank for no limit" + } if accepts == "" { return label } diff --git a/internal/tui3/settings_organization_test.go b/internal/tui3/settings_organization_test.go index bbae997424..3c429a1a5d 100644 --- a/internal/tui3/settings_organization_test.go +++ b/internal/tui3/settings_organization_test.go @@ -300,3 +300,55 @@ func TestSettingsOrganizationRenderedEditorDraftAndCaret(t *testing.T) { }) } } + +func TestSettingsOrganizationCountEditorInlineValidationAndClearing(t *testing.T) { + for _, size := range []struct { + name string + width int + }{{"narrow", 80}, {"wide", 120}} { + t.Run(size.name, func(t *testing.T) { + a, dir := sheetApp(t) + a.width, a.height = size.width, 30 + a.openSettings() + cursorTo(t, a, config.KeyTaskParallel) + a.activate() + a.sheet.edit.box.setText("invalid") + a.sheetEditKey(key("enter")) + if a.sheet.edit == nil || config.TaskParallelAt(dir) != 0 { + t.Fatal("invalid draft changed stored count or closed editor") + } + lines, _, _, y := a.sheetFrame(a.width, a.height) + if !strings.Contains(plain(lines[y]), "invalid") || y+1 >= len(lines) || !strings.Contains(plain(lines[y+1]), "not a whole number") { + t.Fatalf("error is not directly below input:\n%s", plain(strings.Join(lines, "\n"))) + } + frame := plain(strings.Join(lines, "\n")) + if strings.Count(frame, "not a whole number") != 1 || !strings.Contains(frame, "blank for no limit") { + t.Fatalf("missing blank semantics or duplicate error:\n%s", frame) + } + a.sheet.edit.box.setText("4") + a.sheetEditKey(key("enter")) + if a.sheet.edit != nil || config.TaskParallelAt(dir) != 4 { + t.Fatal("valid count was not saved") + } + cursorTo(t, a, config.KeyTaskParallel) + a.activate() + if a.sheet.edit == nil || a.sheet.edit.box.String() != "4" { + t.Fatal("reopened count did not retain 4") + } + a.sheet.edit.box.reset() + a.sheetEditKey(key("enter")) + if a.sheet.edit != nil || config.TaskParallelAt(dir) != 0 { + t.Fatal("blank did not remove the count cap") + } + cursorTo(t, a, config.KeyTaskParallel) + a.activate() + if a.sheet.edit == nil || a.sheet.edit.box.String() != "" { + t.Fatal("unlimited count did not reopen as blank") + } + }) + } + ordinary := config.Setting{Kind: config.SettingCount, Label: "repair attempts"} + if strings.Contains(sheetEditNote("repair attempts", ordinary), "blank for no limit") { + t.Fatal("nonoptional count promises incorrect blank semantics") + } +} From c42cb015d1f9c90c5d5fb150959014dbd94dab5f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 15:01:37 -0400 Subject: [PATCH 21/31] Lead Tasks with everyday developer controls and fold uncommon tuning --- internal/config/settings_presentation.go | 4 ++-- internal/manual/chat/settings.md | 2 +- internal/tui3/settings.go | 11 +++++++++++ internal/tui3/settings_words_test.go | 25 ++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/internal/config/settings_presentation.go b/internal/config/settings_presentation.go index 12625b9d59..7116aecc05 100644 --- a/internal/config/settings_presentation.go +++ b/internal/config/settings_presentation.go @@ -89,14 +89,14 @@ func (s Setting) ChatPresentation() SettingPresentation { case KeyConsentTimeout: p.Label, p.Advanced = "pause approval timer after", true case KeyBashBackgroundAfter: - p.Category, p.Label = "Tasks", "background shell commands after" + p.Category, p.Label, p.Advanced = "Tasks", "background shell commands after", true case KeyTaskAutoApprove: p.Category, p.Label = "Permissions", "start proposed tasks after" case KeyTaskSettle: p.Category, p.Label = "Tasks", "when task results need a decision" p.Description = "Ask me leaves the decision with you. Let the chat decide reviews the work and asks you only when it cannot decide." case KeyTaskStart: - p.Label = "task scope assessment" + p.Label, p.Advanced = "task scope assessment", true p.Description = "Assesses the brief while the worker starts, helping it identify work to delegate. Skip assessment avoids that extra model call. Both start work immediately and can delegate parts already listed in the brief." case KeyTaskAudit: p.Label = "review task results" diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 836f5d0e76..c28237ce05 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -74,7 +74,7 @@ The proposed-task start timer has a different consequence: it starts the propose work when it expires. Read each control's consequence before changing its duration. **Tasks** contains scope assessment, review, repair and concurrency; machine resource limits are advanced controls. A one-off task does not require an AI team. -**Task scope assessment** offers **assess while working** or **skip assessment**. +Under Advanced, **task scope assessment** offers **assess while working** or **skip assessment**. Both start work immediately; assessment helps identify work to delegate. **Concurrent tasks: no limit** means no user-set count cap, not disabled tasks. diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 3fc5a33683..48850a51e0 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -868,6 +868,17 @@ func (s *sheet) finishCategory() { shown = append(shown, item) } } + // Lead with the controls a developer commonly changes, not registry order. + if title == tabTasks { + priority := map[string]int{config.KeyTaskParallel: 0, config.KeyTaskModel: 1, config.KeyTaskAudit: 2, config.KeyTaskSettle: 3, config.KeyTaskRepairRounds: 4} + rank := func(item sheetItem) int { + if n, ok := priority[item.row.Key]; ok { + return n + } + return len(priority) + } + sort.SliceStable(shown, func(i, j int) bool { return rank(shown[i]) < rank(shown[j]) }) + } if len(advanced) > 0 { shown = append(shown, sheetItem{advancedDoor: true, meta: settingMeta{label: "Advanced", about: "Less common controls. Search finds these even while collapsed."}}) if s.advanced[title] { diff --git a/internal/tui3/settings_words_test.go b/internal/tui3/settings_words_test.go index 7d13029719..a528b49add 100644 --- a/internal/tui3/settings_words_test.go +++ b/internal/tui3/settings_words_test.go @@ -94,3 +94,28 @@ func TestSettingsActionHintsDescribeDoorsWithoutClaimingAWrite(t *testing.T) { t.Fatal("expanded Advanced does not name collapse") } } + +func TestTasksLeadsWithEverydayControlsAndKeepsTuningSearchable(t *testing.T) { + a, _ := sheetApp(t) + a.openSettings() + for i, name := range settingTabs { + if name == tabTasks { + a.sheet.tab = i + } + } + a.sheet.build() + want := []string{config.KeyTaskParallel, config.KeyTaskModel, config.KeyTaskAudit} + for i, key := range want { + if a.sheet.items[i].row.Key != key { + t.Fatalf("task row %d = %s, want %s", i, a.sheet.items[i].row.Key, key) + } + } + for _, item := range a.sheet.items { + if item.row.Key == config.KeyTaskStart || item.row.Key == config.KeyBashBackgroundAfter { + t.Fatal("analysis or timing tuning escaped Advanced") + } + } + if itemAt(searchItems(t, a, config.KeyTaskStart), config.KeyTaskStart) < 0 { + t.Fatal("task analysis cannot be found while Advanced is collapsed") + } +} From 97ee185ddcb101103b252fcbfd887f48290cd8cc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 15:06:39 -0400 Subject: [PATCH 22/31] Add complete before-and-after TUI gallery and reproducible verification evidence --- docs/design/settings-clarity/README.md | 157 ++++++++++++++++++ .../after/01-general-120c.png | Bin 0 -> 47357 bytes .../after/01-general-120c.txt | 48 ++++++ .../settings-clarity/after/01-general-80c.png | Bin 0 -> 39930 bytes .../settings-clarity/after/01-general-80c.txt | 48 ++++++ .../settings-clarity/after/02-models-120c.png | Bin 0 -> 54473 bytes .../settings-clarity/after/02-models-120c.txt | 48 ++++++ .../settings-clarity/after/02-models-80c.png | Bin 0 -> 48388 bytes .../settings-clarity/after/02-models-80c.txt | 48 ++++++ .../after/02-models-advanced-120c.png | Bin 0 -> 88740 bytes .../after/02-models-advanced-120c.txt | 48 ++++++ .../after/02-models-advanced-80c.png | Bin 0 -> 80804 bytes .../after/02-models-advanced-80c.txt | 48 ++++++ .../after/02-models-advanced-end-120c.png | Bin 0 -> 100866 bytes .../after/02-models-advanced-end-120c.txt | 48 ++++++ .../after/02-models-advanced-end-80c.png | Bin 0 -> 93919 bytes .../after/02-models-advanced-end-80c.txt | 48 ++++++ .../settings-clarity/after/03-memory-120c.png | Bin 0 -> 32386 bytes .../settings-clarity/after/03-memory-120c.txt | 48 ++++++ .../settings-clarity/after/03-memory-80c.png | Bin 0 -> 24585 bytes .../settings-clarity/after/03-memory-80c.txt | 48 ++++++ .../settings-clarity/after/04-tasks-120c.png | Bin 0 -> 45482 bytes .../settings-clarity/after/04-tasks-120c.txt | 48 ++++++ .../settings-clarity/after/04-tasks-80c.png | Bin 0 -> 38389 bytes .../settings-clarity/after/04-tasks-80c.txt | 48 ++++++ .../after/04-tasks-advanced-120c.png | Bin 0 -> 50382 bytes .../after/04-tasks-advanced-120c.txt | 48 ++++++ .../after/04-tasks-advanced-80c.png | Bin 0 -> 42699 bytes .../after/04-tasks-advanced-80c.txt | 48 ++++++ .../after/04-tasks-advanced-end-120c.png | Bin 0 -> 51845 bytes .../after/04-tasks-advanced-end-120c.txt | 48 ++++++ .../after/04-tasks-advanced-end-80c.png | Bin 0 -> 44282 bytes .../after/04-tasks-advanced-end-80c.txt | 48 ++++++ .../after/05-ai-teams-120c.png | Bin 0 -> 42435 bytes .../after/05-ai-teams-120c.txt | 48 ++++++ .../after/05-ai-teams-80c.png | Bin 0 -> 34963 bytes .../after/05-ai-teams-80c.txt | 48 ++++++ .../after/05-ai-teams-advanced-120c.png | Bin 0 -> 40605 bytes .../after/05-ai-teams-advanced-120c.txt | 48 ++++++ .../after/05-ai-teams-advanced-80c.png | Bin 0 -> 33585 bytes .../after/05-ai-teams-advanced-80c.txt | 48 ++++++ .../after/05-ai-teams-advanced-end-120c.png | Bin 0 -> 46120 bytes .../after/05-ai-teams-advanced-end-120c.txt | 48 ++++++ .../after/05-ai-teams-advanced-end-80c.png | Bin 0 -> 39411 bytes .../after/05-ai-teams-advanced-end-80c.txt | 48 ++++++ .../after/06-permissions-120c.png | Bin 0 -> 50302 bytes .../after/06-permissions-120c.txt | 48 ++++++ .../after/06-permissions-80c.png | Bin 0 -> 44201 bytes .../after/06-permissions-80c.txt | 48 ++++++ .../after/06-permissions-advanced-120c.png | Bin 0 -> 59087 bytes .../after/06-permissions-advanced-120c.txt | 48 ++++++ .../after/06-permissions-advanced-80c.png | Bin 0 -> 51559 bytes .../after/06-permissions-advanced-80c.txt | 48 ++++++ .../06-permissions-advanced-end-120c.png | Bin 0 -> 65497 bytes .../06-permissions-advanced-end-120c.txt | 48 ++++++ .../after/06-permissions-advanced-end-80c.png | Bin 0 -> 57718 bytes .../after/06-permissions-advanced-end-80c.txt | 48 ++++++ .../after/07-spending-120c.png | Bin 0 -> 48938 bytes .../after/07-spending-120c.txt | 48 ++++++ .../after/07-spending-80c.png | Bin 0 -> 40569 bytes .../after/07-spending-80c.txt | 48 ++++++ .../after/08-connections-120c.png | Bin 0 -> 79500 bytes .../after/08-connections-120c.txt | 48 ++++++ .../after/08-connections-80c.png | Bin 0 -> 71340 bytes .../after/08-connections-80c.txt | 48 ++++++ .../after/08-connections-advanced-120c.png | Bin 0 -> 81712 bytes .../after/08-connections-advanced-120c.txt | 48 ++++++ .../after/08-connections-advanced-80c.png | Bin 0 -> 73682 bytes .../after/08-connections-advanced-80c.txt | 48 ++++++ .../08-connections-advanced-end-120c.png | Bin 0 -> 77296 bytes .../08-connections-advanced-end-120c.txt | 48 ++++++ .../after/08-connections-advanced-end-80c.png | Bin 0 -> 69742 bytes .../after/08-connections-advanced-end-80c.txt | 48 ++++++ .../after/09-privacy-120c.png | Bin 0 -> 43983 bytes .../after/09-privacy-120c.txt | 48 ++++++ .../settings-clarity/after/09-privacy-80c.png | Bin 0 -> 38229 bytes .../settings-clarity/after/09-privacy-80c.txt | 48 ++++++ .../after/09-privacy-advanced-120c.png | Bin 0 -> 40965 bytes .../after/09-privacy-advanced-120c.txt | 48 ++++++ .../after/09-privacy-advanced-80c.png | Bin 0 -> 33936 bytes .../after/09-privacy-advanced-80c.txt | 48 ++++++ .../after/09-privacy-advanced-end-120c.png | Bin 0 -> 44751 bytes .../after/09-privacy-advanced-end-120c.txt | 48 ++++++ .../after/09-privacy-advanced-end-80c.png | Bin 0 -> 38011 bytes .../after/09-privacy-advanced-end-80c.txt | 48 ++++++ .../after/edit-hints-after-120c.png | Bin 0 -> 61835 bytes .../after/edit-hints-after-120c.txt | 48 ++++++ .../after/edit-hints-after-80c.png | Bin 0 -> 53561 bytes .../after/edit-hints-after-80c.txt | 48 ++++++ .../after/edit-hints-before-120c.png | Bin 0 -> 62005 bytes .../after/edit-hints-before-120c.txt | 48 ++++++ .../after/edit-hints-before-80c.png | Bin 0 -> 53397 bytes .../after/edit-hints-before-80c.txt | 48 ++++++ .../after/edit-invalid-value-120c.png | Bin 0 -> 23324 bytes .../after/edit-invalid-value-120c.txt | 48 ++++++ .../after/edit-invalid-value-80c.png | Bin 0 -> 20072 bytes .../after/edit-invalid-value-80c.txt | 48 ++++++ .../after/edit-limit-cleared-120c.png | Bin 0 -> 42307 bytes .../after/edit-limit-cleared-120c.txt | 48 ++++++ .../after/edit-limit-cleared-80c.png | Bin 0 -> 34889 bytes .../after/edit-limit-cleared-80c.txt | 48 ++++++ .../after/edit-limit-reopened-120c.png | Bin 0 -> 21374 bytes .../after/edit-limit-reopened-120c.txt | 48 ++++++ .../after/edit-limit-reopened-80c.png | Bin 0 -> 18107 bytes .../after/edit-limit-reopened-80c.txt | 48 ++++++ .../after/edit-limit-saved-120c.png | Bin 0 -> 41681 bytes .../after/edit-limit-saved-120c.txt | 48 ++++++ .../after/edit-limit-saved-80c.png | Bin 0 -> 34600 bytes .../after/edit-limit-saved-80c.txt | 48 ++++++ .../after/edit-limit-unlimited-120c.png | Bin 0 -> 21961 bytes .../after/edit-limit-unlimited-120c.txt | 48 ++++++ .../after/edit-limit-unlimited-80c.png | Bin 0 -> 18659 bytes .../after/edit-limit-unlimited-80c.txt | 48 ++++++ .../after/hover-category-120c.png | Bin 0 -> 47483 bytes .../after/hover-category-120c.txt | 48 ++++++ .../after/navigation-activity-120c.png | Bin 0 -> 30193 bytes .../after/navigation-activity-120c.txt | 48 ++++++ .../after/navigation-activity-80c.png | Bin 0 -> 21743 bytes .../after/navigation-activity-80c.txt | 48 ++++++ .../after/navigation-ai-teams-120c.png | Bin 0 -> 36854 bytes .../after/navigation-ai-teams-120c.txt | 48 ++++++ .../after/navigation-ai-teams-80c.png | Bin 0 -> 33968 bytes .../after/navigation-ai-teams-80c.txt | 48 ++++++ .../after/search-old-label-120c.png | Bin 0 -> 66772 bytes .../after/search-old-label-120c.txt | 48 ++++++ .../after/search-old-label-80c.png | Bin 0 -> 57408 bytes .../after/search-old-label-80c.txt | 48 ++++++ .../after/search-privacy-120c.png | Bin 0 -> 52680 bytes .../after/search-privacy-120c.txt | 48 ++++++ .../after/search-privacy-80c.png | Bin 0 -> 46218 bytes .../after/search-privacy-80c.txt | 48 ++++++ .../after/search-service-120c.png | Bin 0 -> 51432 bytes .../after/search-service-120c.txt | 48 ++++++ .../after/search-service-80c.png | Bin 0 -> 42731 bytes .../after/search-service-80c.txt | 48 ++++++ .../before/01-session-120c.png | Bin 0 -> 33008 bytes .../before/01-session-120c.txt | 48 ++++++ .../before/01-session-80c.png | Bin 0 -> 31646 bytes .../before/01-session-80c.txt | 48 ++++++ .../before/02-context-120c.png | Bin 0 -> 42861 bytes .../before/02-context-120c.txt | 48 ++++++ .../before/02-context-80c.png | Bin 0 -> 39490 bytes .../before/02-context-80c.txt | 48 ++++++ .../before/03-workspace-120c.png | Bin 0 -> 53177 bytes .../before/03-workspace-120c.txt | 48 ++++++ .../before/03-workspace-80c.png | Bin 0 -> 49985 bytes .../before/03-workspace-80c.txt | 48 ++++++ .../before/04-display-120c.png | Bin 0 -> 40584 bytes .../before/04-display-120c.txt | 48 ++++++ .../before/04-display-80c.png | Bin 0 -> 38373 bytes .../before/04-display-80c.txt | 48 ++++++ .../before/05-spending-120c.png | Bin 0 -> 48400 bytes .../before/05-spending-120c.txt | 48 ++++++ .../before/05-spending-80c.png | Bin 0 -> 43444 bytes .../before/05-spending-80c.txt | 48 ++++++ .../before/06-safety-120c.png | Bin 0 -> 62031 bytes .../before/06-safety-120c.txt | 48 ++++++ .../settings-clarity/before/06-safety-80c.png | Bin 0 -> 58860 bytes .../settings-clarity/before/06-safety-80c.txt | 48 ++++++ .../settings-clarity/before/07-tasks-120c.png | Bin 0 -> 46136 bytes .../settings-clarity/before/07-tasks-120c.txt | 48 ++++++ .../settings-clarity/before/07-tasks-80c.png | Bin 0 -> 43624 bytes .../settings-clarity/before/07-tasks-80c.txt | 48 ++++++ .../settings-clarity/before/08-teams-120c.png | Bin 0 -> 39799 bytes .../settings-clarity/before/08-teams-120c.txt | 48 ++++++ .../settings-clarity/before/08-teams-80c.png | Bin 0 -> 36712 bytes .../settings-clarity/before/08-teams-80c.txt | 48 ++++++ .../before/09-providers-120c.png | Bin 0 -> 95577 bytes .../before/09-providers-120c.txt | 48 ++++++ .../before/09-providers-80c.png | Bin 0 -> 89644 bytes .../before/09-providers-80c.txt | 48 ++++++ .../before/10-connections-120c.png | Bin 0 -> 75713 bytes .../before/10-connections-120c.txt | 48 ++++++ .../before/10-connections-80c.png | Bin 0 -> 69674 bytes .../before/10-connections-80c.txt | 48 ++++++ .../before/navigation-activity-120c.png | Bin 0 -> 29269 bytes .../before/navigation-activity-120c.txt | 48 ++++++ .../before/navigation-activity-80c.png | Bin 0 -> 22044 bytes .../before/navigation-activity-80c.txt | 48 ++++++ .../before/navigation-ai-teams-120c.png | Bin 0 -> 38210 bytes .../before/navigation-ai-teams-120c.txt | 48 ++++++ .../before/navigation-ai-teams-80c.png | Bin 0 -> 37036 bytes .../before/navigation-ai-teams-80c.txt | 48 ++++++ .../before/search-old-label-120c.png | Bin 0 -> 25068 bytes .../before/search-old-label-120c.txt | 48 ++++++ .../before/search-old-label-80c.png | Bin 0 -> 22140 bytes .../before/search-old-label-80c.txt | 48 ++++++ .../before/search-privacy-120c.png | Bin 0 -> 24843 bytes .../before/search-privacy-120c.txt | 48 ++++++ .../before/search-privacy-80c.png | Bin 0 -> 22002 bytes .../before/search-privacy-80c.txt | 48 ++++++ .../before/search-service-120c.png | Bin 0 -> 24618 bytes .../before/search-service-120c.txt | 48 ++++++ .../before/search-service-80c.png | Bin 0 -> 21827 bytes .../before/search-service-80c.txt | 48 ++++++ docs/design/settings-clarity/capture.py | 62 +++++++ docs/design/settings-clarity/render.py | 8 + docs/design/settings-clarity/verification.md | 43 +++++ 198 files changed, 4926 insertions(+) create mode 100644 docs/design/settings-clarity/README.md create mode 100644 docs/design/settings-clarity/after/01-general-120c.png create mode 100644 docs/design/settings-clarity/after/01-general-120c.txt create mode 100644 docs/design/settings-clarity/after/01-general-80c.png create mode 100644 docs/design/settings-clarity/after/01-general-80c.txt create mode 100644 docs/design/settings-clarity/after/02-models-120c.png create mode 100644 docs/design/settings-clarity/after/02-models-120c.txt create mode 100644 docs/design/settings-clarity/after/02-models-80c.png create mode 100644 docs/design/settings-clarity/after/02-models-80c.txt create mode 100644 docs/design/settings-clarity/after/02-models-advanced-120c.png create mode 100644 docs/design/settings-clarity/after/02-models-advanced-120c.txt create mode 100644 docs/design/settings-clarity/after/02-models-advanced-80c.png create mode 100644 docs/design/settings-clarity/after/02-models-advanced-80c.txt create mode 100644 docs/design/settings-clarity/after/02-models-advanced-end-120c.png create mode 100644 docs/design/settings-clarity/after/02-models-advanced-end-120c.txt create mode 100644 docs/design/settings-clarity/after/02-models-advanced-end-80c.png create mode 100644 docs/design/settings-clarity/after/02-models-advanced-end-80c.txt create mode 100644 docs/design/settings-clarity/after/03-memory-120c.png create mode 100644 docs/design/settings-clarity/after/03-memory-120c.txt create mode 100644 docs/design/settings-clarity/after/03-memory-80c.png create mode 100644 docs/design/settings-clarity/after/03-memory-80c.txt create mode 100644 docs/design/settings-clarity/after/04-tasks-120c.png create mode 100644 docs/design/settings-clarity/after/04-tasks-120c.txt create mode 100644 docs/design/settings-clarity/after/04-tasks-80c.png create mode 100644 docs/design/settings-clarity/after/04-tasks-80c.txt create mode 100644 docs/design/settings-clarity/after/04-tasks-advanced-120c.png create mode 100644 docs/design/settings-clarity/after/04-tasks-advanced-120c.txt create mode 100644 docs/design/settings-clarity/after/04-tasks-advanced-80c.png create mode 100644 docs/design/settings-clarity/after/04-tasks-advanced-80c.txt create mode 100644 docs/design/settings-clarity/after/04-tasks-advanced-end-120c.png create mode 100644 docs/design/settings-clarity/after/04-tasks-advanced-end-120c.txt create mode 100644 docs/design/settings-clarity/after/04-tasks-advanced-end-80c.png create mode 100644 docs/design/settings-clarity/after/04-tasks-advanced-end-80c.txt create mode 100644 docs/design/settings-clarity/after/05-ai-teams-120c.png create mode 100644 docs/design/settings-clarity/after/05-ai-teams-120c.txt create mode 100644 docs/design/settings-clarity/after/05-ai-teams-80c.png create mode 100644 docs/design/settings-clarity/after/05-ai-teams-80c.txt create mode 100644 docs/design/settings-clarity/after/05-ai-teams-advanced-120c.png create mode 100644 docs/design/settings-clarity/after/05-ai-teams-advanced-120c.txt create mode 100644 docs/design/settings-clarity/after/05-ai-teams-advanced-80c.png create mode 100644 docs/design/settings-clarity/after/05-ai-teams-advanced-80c.txt create mode 100644 docs/design/settings-clarity/after/05-ai-teams-advanced-end-120c.png create mode 100644 docs/design/settings-clarity/after/05-ai-teams-advanced-end-120c.txt create mode 100644 docs/design/settings-clarity/after/05-ai-teams-advanced-end-80c.png create mode 100644 docs/design/settings-clarity/after/05-ai-teams-advanced-end-80c.txt create mode 100644 docs/design/settings-clarity/after/06-permissions-120c.png create mode 100644 docs/design/settings-clarity/after/06-permissions-120c.txt create mode 100644 docs/design/settings-clarity/after/06-permissions-80c.png create mode 100644 docs/design/settings-clarity/after/06-permissions-80c.txt create mode 100644 docs/design/settings-clarity/after/06-permissions-advanced-120c.png create mode 100644 docs/design/settings-clarity/after/06-permissions-advanced-120c.txt create mode 100644 docs/design/settings-clarity/after/06-permissions-advanced-80c.png create mode 100644 docs/design/settings-clarity/after/06-permissions-advanced-80c.txt create mode 100644 docs/design/settings-clarity/after/06-permissions-advanced-end-120c.png create mode 100644 docs/design/settings-clarity/after/06-permissions-advanced-end-120c.txt create mode 100644 docs/design/settings-clarity/after/06-permissions-advanced-end-80c.png create mode 100644 docs/design/settings-clarity/after/06-permissions-advanced-end-80c.txt create mode 100644 docs/design/settings-clarity/after/07-spending-120c.png create mode 100644 docs/design/settings-clarity/after/07-spending-120c.txt create mode 100644 docs/design/settings-clarity/after/07-spending-80c.png create mode 100644 docs/design/settings-clarity/after/07-spending-80c.txt create mode 100644 docs/design/settings-clarity/after/08-connections-120c.png create mode 100644 docs/design/settings-clarity/after/08-connections-120c.txt create mode 100644 docs/design/settings-clarity/after/08-connections-80c.png create mode 100644 docs/design/settings-clarity/after/08-connections-80c.txt create mode 100644 docs/design/settings-clarity/after/08-connections-advanced-120c.png create mode 100644 docs/design/settings-clarity/after/08-connections-advanced-120c.txt create mode 100644 docs/design/settings-clarity/after/08-connections-advanced-80c.png create mode 100644 docs/design/settings-clarity/after/08-connections-advanced-80c.txt create mode 100644 docs/design/settings-clarity/after/08-connections-advanced-end-120c.png create mode 100644 docs/design/settings-clarity/after/08-connections-advanced-end-120c.txt create mode 100644 docs/design/settings-clarity/after/08-connections-advanced-end-80c.png create mode 100644 docs/design/settings-clarity/after/08-connections-advanced-end-80c.txt create mode 100644 docs/design/settings-clarity/after/09-privacy-120c.png create mode 100644 docs/design/settings-clarity/after/09-privacy-120c.txt create mode 100644 docs/design/settings-clarity/after/09-privacy-80c.png create mode 100644 docs/design/settings-clarity/after/09-privacy-80c.txt create mode 100644 docs/design/settings-clarity/after/09-privacy-advanced-120c.png create mode 100644 docs/design/settings-clarity/after/09-privacy-advanced-120c.txt create mode 100644 docs/design/settings-clarity/after/09-privacy-advanced-80c.png create mode 100644 docs/design/settings-clarity/after/09-privacy-advanced-80c.txt create mode 100644 docs/design/settings-clarity/after/09-privacy-advanced-end-120c.png create mode 100644 docs/design/settings-clarity/after/09-privacy-advanced-end-120c.txt create mode 100644 docs/design/settings-clarity/after/09-privacy-advanced-end-80c.png create mode 100644 docs/design/settings-clarity/after/09-privacy-advanced-end-80c.txt create mode 100644 docs/design/settings-clarity/after/edit-hints-after-120c.png create mode 100644 docs/design/settings-clarity/after/edit-hints-after-120c.txt create mode 100644 docs/design/settings-clarity/after/edit-hints-after-80c.png create mode 100644 docs/design/settings-clarity/after/edit-hints-after-80c.txt create mode 100644 docs/design/settings-clarity/after/edit-hints-before-120c.png create mode 100644 docs/design/settings-clarity/after/edit-hints-before-120c.txt create mode 100644 docs/design/settings-clarity/after/edit-hints-before-80c.png create mode 100644 docs/design/settings-clarity/after/edit-hints-before-80c.txt create mode 100644 docs/design/settings-clarity/after/edit-invalid-value-120c.png create mode 100644 docs/design/settings-clarity/after/edit-invalid-value-120c.txt create mode 100644 docs/design/settings-clarity/after/edit-invalid-value-80c.png create mode 100644 docs/design/settings-clarity/after/edit-invalid-value-80c.txt create mode 100644 docs/design/settings-clarity/after/edit-limit-cleared-120c.png create mode 100644 docs/design/settings-clarity/after/edit-limit-cleared-120c.txt create mode 100644 docs/design/settings-clarity/after/edit-limit-cleared-80c.png create mode 100644 docs/design/settings-clarity/after/edit-limit-cleared-80c.txt create mode 100644 docs/design/settings-clarity/after/edit-limit-reopened-120c.png create mode 100644 docs/design/settings-clarity/after/edit-limit-reopened-120c.txt create mode 100644 docs/design/settings-clarity/after/edit-limit-reopened-80c.png create mode 100644 docs/design/settings-clarity/after/edit-limit-reopened-80c.txt create mode 100644 docs/design/settings-clarity/after/edit-limit-saved-120c.png create mode 100644 docs/design/settings-clarity/after/edit-limit-saved-120c.txt create mode 100644 docs/design/settings-clarity/after/edit-limit-saved-80c.png create mode 100644 docs/design/settings-clarity/after/edit-limit-saved-80c.txt create mode 100644 docs/design/settings-clarity/after/edit-limit-unlimited-120c.png create mode 100644 docs/design/settings-clarity/after/edit-limit-unlimited-120c.txt create mode 100644 docs/design/settings-clarity/after/edit-limit-unlimited-80c.png create mode 100644 docs/design/settings-clarity/after/edit-limit-unlimited-80c.txt create mode 100644 docs/design/settings-clarity/after/hover-category-120c.png create mode 100644 docs/design/settings-clarity/after/hover-category-120c.txt create mode 100644 docs/design/settings-clarity/after/navigation-activity-120c.png create mode 100644 docs/design/settings-clarity/after/navigation-activity-120c.txt create mode 100644 docs/design/settings-clarity/after/navigation-activity-80c.png create mode 100644 docs/design/settings-clarity/after/navigation-activity-80c.txt create mode 100644 docs/design/settings-clarity/after/navigation-ai-teams-120c.png create mode 100644 docs/design/settings-clarity/after/navigation-ai-teams-120c.txt create mode 100644 docs/design/settings-clarity/after/navigation-ai-teams-80c.png create mode 100644 docs/design/settings-clarity/after/navigation-ai-teams-80c.txt create mode 100644 docs/design/settings-clarity/after/search-old-label-120c.png create mode 100644 docs/design/settings-clarity/after/search-old-label-120c.txt create mode 100644 docs/design/settings-clarity/after/search-old-label-80c.png create mode 100644 docs/design/settings-clarity/after/search-old-label-80c.txt create mode 100644 docs/design/settings-clarity/after/search-privacy-120c.png create mode 100644 docs/design/settings-clarity/after/search-privacy-120c.txt create mode 100644 docs/design/settings-clarity/after/search-privacy-80c.png create mode 100644 docs/design/settings-clarity/after/search-privacy-80c.txt create mode 100644 docs/design/settings-clarity/after/search-service-120c.png create mode 100644 docs/design/settings-clarity/after/search-service-120c.txt create mode 100644 docs/design/settings-clarity/after/search-service-80c.png create mode 100644 docs/design/settings-clarity/after/search-service-80c.txt create mode 100644 docs/design/settings-clarity/before/01-session-120c.png create mode 100644 docs/design/settings-clarity/before/01-session-120c.txt create mode 100644 docs/design/settings-clarity/before/01-session-80c.png create mode 100644 docs/design/settings-clarity/before/01-session-80c.txt create mode 100644 docs/design/settings-clarity/before/02-context-120c.png create mode 100644 docs/design/settings-clarity/before/02-context-120c.txt create mode 100644 docs/design/settings-clarity/before/02-context-80c.png create mode 100644 docs/design/settings-clarity/before/02-context-80c.txt create mode 100644 docs/design/settings-clarity/before/03-workspace-120c.png create mode 100644 docs/design/settings-clarity/before/03-workspace-120c.txt create mode 100644 docs/design/settings-clarity/before/03-workspace-80c.png create mode 100644 docs/design/settings-clarity/before/03-workspace-80c.txt create mode 100644 docs/design/settings-clarity/before/04-display-120c.png create mode 100644 docs/design/settings-clarity/before/04-display-120c.txt create mode 100644 docs/design/settings-clarity/before/04-display-80c.png create mode 100644 docs/design/settings-clarity/before/04-display-80c.txt create mode 100644 docs/design/settings-clarity/before/05-spending-120c.png create mode 100644 docs/design/settings-clarity/before/05-spending-120c.txt create mode 100644 docs/design/settings-clarity/before/05-spending-80c.png create mode 100644 docs/design/settings-clarity/before/05-spending-80c.txt create mode 100644 docs/design/settings-clarity/before/06-safety-120c.png create mode 100644 docs/design/settings-clarity/before/06-safety-120c.txt create mode 100644 docs/design/settings-clarity/before/06-safety-80c.png create mode 100644 docs/design/settings-clarity/before/06-safety-80c.txt create mode 100644 docs/design/settings-clarity/before/07-tasks-120c.png create mode 100644 docs/design/settings-clarity/before/07-tasks-120c.txt create mode 100644 docs/design/settings-clarity/before/07-tasks-80c.png create mode 100644 docs/design/settings-clarity/before/07-tasks-80c.txt create mode 100644 docs/design/settings-clarity/before/08-teams-120c.png create mode 100644 docs/design/settings-clarity/before/08-teams-120c.txt create mode 100644 docs/design/settings-clarity/before/08-teams-80c.png create mode 100644 docs/design/settings-clarity/before/08-teams-80c.txt create mode 100644 docs/design/settings-clarity/before/09-providers-120c.png create mode 100644 docs/design/settings-clarity/before/09-providers-120c.txt create mode 100644 docs/design/settings-clarity/before/09-providers-80c.png create mode 100644 docs/design/settings-clarity/before/09-providers-80c.txt create mode 100644 docs/design/settings-clarity/before/10-connections-120c.png create mode 100644 docs/design/settings-clarity/before/10-connections-120c.txt create mode 100644 docs/design/settings-clarity/before/10-connections-80c.png create mode 100644 docs/design/settings-clarity/before/10-connections-80c.txt create mode 100644 docs/design/settings-clarity/before/navigation-activity-120c.png create mode 100644 docs/design/settings-clarity/before/navigation-activity-120c.txt create mode 100644 docs/design/settings-clarity/before/navigation-activity-80c.png create mode 100644 docs/design/settings-clarity/before/navigation-activity-80c.txt create mode 100644 docs/design/settings-clarity/before/navigation-ai-teams-120c.png create mode 100644 docs/design/settings-clarity/before/navigation-ai-teams-120c.txt create mode 100644 docs/design/settings-clarity/before/navigation-ai-teams-80c.png create mode 100644 docs/design/settings-clarity/before/navigation-ai-teams-80c.txt create mode 100644 docs/design/settings-clarity/before/search-old-label-120c.png create mode 100644 docs/design/settings-clarity/before/search-old-label-120c.txt create mode 100644 docs/design/settings-clarity/before/search-old-label-80c.png create mode 100644 docs/design/settings-clarity/before/search-old-label-80c.txt create mode 100644 docs/design/settings-clarity/before/search-privacy-120c.png create mode 100644 docs/design/settings-clarity/before/search-privacy-120c.txt create mode 100644 docs/design/settings-clarity/before/search-privacy-80c.png create mode 100644 docs/design/settings-clarity/before/search-privacy-80c.txt create mode 100644 docs/design/settings-clarity/before/search-service-120c.png create mode 100644 docs/design/settings-clarity/before/search-service-120c.txt create mode 100644 docs/design/settings-clarity/before/search-service-80c.png create mode 100644 docs/design/settings-clarity/before/search-service-80c.txt create mode 100644 docs/design/settings-clarity/capture.py create mode 100644 docs/design/settings-clarity/render.py create mode 100644 docs/design/settings-clarity/verification.md diff --git a/docs/design/settings-clarity/README.md b/docs/design/settings-clarity/README.md new file mode 100644 index 0000000000..6c641374ef --- /dev/null +++ b/docs/design/settings-clarity/README.md @@ -0,0 +1,157 @@ +# Settings clarity + +A settings page should answer four questions together: what this changes, what it is set to, where it applies, and when the change takes effect. + +## Design decisions + +- Nine categories separate everyday preferences, models, memory, task behavior, AI team defaults, permissions, spending, connections, and privacy. AI teams explicitly names coordinated AI work; it does not suggest a multi-user organization. +- Wide terminals use a category sidebar; narrower terminals use a scrolling category bar. The top bar uses text in sentence case. No new font-dependent icons are required. +- Search sits above the list and covers every category, including Advanced controls and the connection catalog. Existing names and config keys still work. Model selection has a separate **Filter models** box. +- General contains **show hints**, with a positive on/off reading. Turning tips off leaves keyboard instructions and setting explanations available. Selected and hovered controls share the same help area, whose fixed height prevents rows moving under the pointer. +- Less common controls remain under Advanced. Search can find them while collapsed. Memory has a direct route to inspecting saved memories. +- Names and values describe behavior: task scope assessment, assess while working, collapsed tool details, ask by default, and use-only model recommendations. Scope and restart requirements remain explicit. +- Invalid edits retain the draft and error. Escape cancels the editor; from search it clears the query first. Hosted team edits wait for acknowledgement. + +## Compatibility and architecture + +`Setting.ChatPresentation()` supplies chat-only categories, labels, descriptions, aliases, scope and activation timing. The existing registry remains the source of validators, writers and saved values. The UI value adapter changes readings without migrating configuration. Existing profiles retain their keys, values, unknown fields and defaults; opening settings writes nothing. + +The shared settings surface combines registry controls with live connection and model-account catalogs. It uses the existing editors, provider picker, memory view and crew panel. Moving a setting does not create another settings store. Document-reader selection now reaches the v3 session configuration, closing a consumer wiring gap found during the audit. + +Resident-only practice controls and unwritable legacy role slots are excluded from live chat settings. Their stored values remain intact. Canonical role and tier controls remain under Advanced. + +## Capture method + +These are real terminal frames, not mockups. The capture script starts the built CLI inside a private tmux server, with an isolated fixture profile, no host, automatic updates disabled, and a loopback model endpoint. No user account or model request is needed. `agg` and `ffmpeg` render the ANSI capture to PNG. Plain-text captures accompany each image for inspection. + +Before: `4fd9cfaa0fb63b1c78db5aa6ebe9479e512d1640` (dev base). After revision is recorded in [verification](verification.md). Both use 80- and 120-column terminals, 48 rows, and the same fixture. + +From a built checkout on Spark: + +```sh +python3 docs/design/settings-clarity/capture.py --binary "$(pwd)/bin/codeaf" --out /tmp/settings-proof --version after --columns 120 +python3 docs/design/settings-clarity/render.py /tmp/settings-proof +``` + +The fixture's key is dummy data. Live third-party OAuth, billing, and provider availability are not established by these screenshots. + +## Category comparisons + +Each pair below shows the primary former location and the new category. The complete baseline index follows because several old categories were combined or split. + +### General + +| Before · 120 columns | After · 120 columns | +| --- | --- | +| ![Before General](before/04-display-120c.png) | ![After General](after/01-general-120c.png) | + +[Before · 80 columns](before/04-display-80c.png) · [After · 80 columns](after/01-general-80c.png) + +### Models + +| Before · 120 columns | After · 120 columns | +| --- | --- | +| ![Before Models](before/09-providers-120c.png) | ![After Models](after/02-models-120c.png) | + +[Before · 80 columns](before/09-providers-80c.png) · [After · 80 columns](after/02-models-80c.png) + +### Memory + +| Before · 120 columns | After · 120 columns | +| --- | --- | +| ![Before Memory](before/01-session-120c.png) | ![After Memory](after/03-memory-120c.png) | + +[Before · 80 columns](before/01-session-80c.png) · [After · 80 columns](after/03-memory-80c.png) + +### Tasks + +| Before · 120 columns | After · 120 columns | +| --- | --- | +| ![Before Tasks](before/07-tasks-120c.png) | ![After Tasks](after/04-tasks-120c.png) | + +[Before · 80 columns](before/07-tasks-80c.png) · [After · 80 columns](after/04-tasks-80c.png) + +### AI teams + +| Before · 120 columns | After · 120 columns | +| --- | --- | +| ![Before AI teams](before/08-teams-120c.png) | ![After AI teams](after/05-ai-teams-120c.png) | + +[Before · 80 columns](before/08-teams-80c.png) · [After · 80 columns](after/05-ai-teams-80c.png) + +### Permissions + +| Before · 120 columns | After · 120 columns | +| --- | --- | +| ![Before Permissions](before/06-safety-120c.png) | ![After Permissions](after/06-permissions-120c.png) | + +[Before · 80 columns](before/06-safety-80c.png) · [After · 80 columns](after/06-permissions-80c.png) + +### Spending + +| Before · 120 columns | After · 120 columns | +| --- | --- | +| ![Before Spending](before/05-spending-120c.png) | ![After Spending](after/07-spending-120c.png) | + +[Before · 80 columns](before/05-spending-80c.png) · [After · 80 columns](after/07-spending-80c.png) + +### Connections + +| Before · 120 columns | After · 120 columns | +| --- | --- | +| ![Before Connections](before/10-connections-120c.png) | ![After Connections](after/08-connections-120c.png) | + +[Before · 80 columns](before/10-connections-80c.png) · [After · 80 columns](after/08-connections-80c.png) + +### Privacy + +| Before · 120 columns | After · 120 columns | +| --- | --- | +| ![Before Privacy](before/04-display-120c.png) | ![After Privacy](after/09-privacy-120c.png) | + +[Before · 80 columns](before/04-display-80c.png) · [After · 80 columns](after/09-privacy-80c.png) + +## All former categories + +- Session: [120 columns](before/01-session-120c.png) · [80 columns](before/01-session-80c.png) +- Context: [120 columns](before/02-context-120c.png) · [80 columns](before/02-context-80c.png) +- Workspace: [120 columns](before/03-workspace-120c.png) · [80 columns](before/03-workspace-80c.png) +- Display: [120 columns](before/04-display-120c.png) · [80 columns](before/04-display-80c.png) +- Spending: [120 columns](before/05-spending-120c.png) · [80 columns](before/05-spending-80c.png) +- Safety: [120 columns](before/06-safety-120c.png) · [80 columns](before/06-safety-80c.png) +- Tasks: [120 columns](before/07-tasks-120c.png) · [80 columns](before/07-tasks-80c.png) +- Teams: [120 columns](before/08-teams-120c.png) · [80 columns](before/08-teams-80c.png) +- Providers: [120 columns](before/09-providers-120c.png) · [80 columns](before/09-providers-80c.png) +- Connections: [120 columns](before/10-connections-120c.png) · [80 columns](before/10-connections-80c.png) + +## Advanced controls and interactions + +- 02 models advanced: [120 columns](after/02-models-advanced-120c.png) · [80 columns](after/02-models-advanced-80c.png) +- 02 models advanced end: [120 columns](after/02-models-advanced-end-120c.png) · [80 columns](after/02-models-advanced-end-80c.png) +- 04 tasks advanced: [120 columns](after/04-tasks-advanced-120c.png) · [80 columns](after/04-tasks-advanced-80c.png) +- 04 tasks advanced end: [120 columns](after/04-tasks-advanced-end-120c.png) · [80 columns](after/04-tasks-advanced-end-80c.png) +- 05 ai teams advanced: [120 columns](after/05-ai-teams-advanced-120c.png) · [80 columns](after/05-ai-teams-advanced-80c.png) +- 05 ai teams advanced end: [120 columns](after/05-ai-teams-advanced-end-120c.png) · [80 columns](after/05-ai-teams-advanced-end-80c.png) +- 06 permissions advanced: [120 columns](after/06-permissions-advanced-120c.png) · [80 columns](after/06-permissions-advanced-80c.png) +- 06 permissions advanced end: [120 columns](after/06-permissions-advanced-end-120c.png) · [80 columns](after/06-permissions-advanced-end-80c.png) +- 08 connections advanced: [120 columns](after/08-connections-advanced-120c.png) · [80 columns](after/08-connections-advanced-80c.png) +- 08 connections advanced end: [120 columns](after/08-connections-advanced-end-120c.png) · [80 columns](after/08-connections-advanced-end-80c.png) +- 09 privacy advanced: [120 columns](after/09-privacy-advanced-120c.png) · [80 columns](after/09-privacy-advanced-80c.png) +- 09 privacy advanced end: [120 columns](after/09-privacy-advanced-end-120c.png) · [80 columns](after/09-privacy-advanced-end-80c.png) +- edit hints after: [120 columns](after/edit-hints-after-120c.png) · [80 columns](after/edit-hints-after-80c.png) +- edit hints before: [120 columns](after/edit-hints-before-120c.png) · [80 columns](after/edit-hints-before-80c.png) +- edit invalid value: [120 columns](after/edit-invalid-value-120c.png) · [80 columns](after/edit-invalid-value-80c.png) +- edit limit cleared: [120 columns](after/edit-limit-cleared-120c.png) · [80 columns](after/edit-limit-cleared-80c.png) +- edit limit reopened: [120 columns](after/edit-limit-reopened-120c.png) · [80 columns](after/edit-limit-reopened-80c.png) +- edit limit saved: [120 columns](after/edit-limit-saved-120c.png) · [80 columns](after/edit-limit-saved-80c.png) +- edit limit unlimited: [120 columns](after/edit-limit-unlimited-120c.png) · [80 columns](after/edit-limit-unlimited-80c.png) +- hover category: [120 columns](after/hover-category-120c.png) +- navigation activity: [120 columns](after/navigation-activity-120c.png) · [80 columns](after/navigation-activity-80c.png) +- navigation ai teams: [120 columns](after/navigation-ai-teams-120c.png) · [80 columns](after/navigation-ai-teams-80c.png) +- search old label: [120 columns](after/search-old-label-120c.png) · [80 columns](after/search-old-label-80c.png) +- search privacy: [120 columns](after/search-privacy-120c.png) · [80 columns](after/search-privacy-80c.png) +- search service: [120 columns](after/search-service-120c.png) · [80 columns](after/search-service-80c.png) + +## Behavior verification + +See [verification.md](verification.md) for tested revisions, Spark job IDs, results, and limitations. diff --git a/docs/design/settings-clarity/after/01-general-120c.png b/docs/design/settings-clarity/after/01-general-120c.png new file mode 100644 index 0000000000000000000000000000000000000000..827891ff5c16ec7ed31c4863daf85d3da1ad9b1b GIT binary patch literal 47357 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 G&Hn=UbzNxy literal 0 HcmV?d00001 diff --git a/docs/design/settings-clarity/after/01-general-120c.txt b/docs/design/settings-clarity/after/01-general-120c.txt new file mode 100644 index 0000000000..1507d5acb1 --- /dev/null +++ b/docs/design/settings-clarity/after/01-general-120c.txt @@ -0,0 +1,48 @@ + >● codeaf Home Chats AI teams Activity Memory Spend Settings wed 3:03pm + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + General General + Models + Memory › Search all settings + Tasks mouse interaction on + AI teams timestamps footers + Permissions completed tool details collapsed + Spending tool icons auto + Connections automatic updates on + Privacy background reminders off + task sidebar on + switch chats immediately on + show hints on + include model in commit attribution on + + + + + + + + + + + + + + + + + + + + + + + + on gives hover and click; off gives the terminal's own text selection back. + + + + + +─ This profile ───────────────────────────────────────────────────────────────────────────────────────────────────────── + ↑↓ settings · ←→ categories · enter change · type to search all · tab next place · esc close diff --git a/docs/design/settings-clarity/after/01-general-80c.png b/docs/design/settings-clarity/after/01-general-80c.png new file mode 100644 index 0000000000000000000000000000000000000000..d9ae801f9f48756110b93de5bafa47a8052eb17e GIT binary patch literal 39930 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 literal 0 HcmV?d00001 diff --git a/docs/design/settings-clarity/after/01-general-80c.txt b/docs/design/settings-clarity/after/01-general-80c.txt new file mode 100644 index 0000000000..697c135d03 --- /dev/null +++ b/docs/design/settings-clarity/after/01-general-80c.txt @@ -0,0 +1,48 @@ + >● codeaf Home Chats AI teams Activity Memory Spend Settings + +──────────────────────────────────────────────────────────────────────────────── + + General Models Memory Tasks AI teams Permissions Spending … + + › Search all settings + mouse interaction on + timestamps footers + completed tool details collapsed + tool icons auto + automatic updates on + background reminders off + task sidebar on + switch chats immediately on + show hints on + include model in commit attribution on + + + + + + + + + + + + + + + + + + + + + + + + on gives hover and click; off gives the terminal's own text selection back. + + + + + +─ This profile ───────────────────────────────────────────────────────────────── + ↑↓ settings · ←→ categories · enter change · tab next place · esc close diff --git a/docs/design/settings-clarity/after/02-models-120c.png b/docs/design/settings-clarity/after/02-models-120c.png new file mode 100644 index 0000000000000000000000000000000000000000..43a87b9d647173324ed5c5de8d4f4ca4d4a72c80 GIT binary patch literal 54473 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 z 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