From acf502ea399e3ded8bd3fc9c3ee358fbf36f3bac Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 10:34:48 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(settings):=20a=20Devices=20tab=20?= =?UTF-8?q?=E2=80=94=20add,=20rename=20and=20remove=20computers=20from=20/?= =?UTF-8?q?settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device management lived only behind /devices, /pair and the command line, so someone looking for it on the settings page found nothing. /settings now has a Devices tab between Connections and Privacy. It hosts the same deviceCard /devices opens — the same rows, order, dots, key words and door calls — plus an "Add another device" row that leaves settings and starts what bare /pair starts. r removes the computer under the cursor, n renames this one, and esc while typing a name cancels the name without closing settings. Where pairing cannot work, the tab says the same one sentence /devices says. The device messages now land on whichever view holds the card, the presence feed stays joined while the tab is open, and the add-device words are one shared constant with the home card. The online dot comes through the palette's glyph door instead of a literal mark. The manual gains a section headed in the asker's words, the settings tab lists in the corpus name Devices, and four probes reach the new section. Co-Authored-By: Claude Opus 5.5 --- .../manual/chat/add-or-remove-a-computer.md | 14 + internal/manual/chat/commands.md | 16 +- internal/manual/chat/keys.md | 4 +- internal/manual/chat/models-and-cost.md | 2 +- internal/manual/chat/naming-a-device.md | 6 +- .../chat/reaching-this-machine-without-ssh.md | 2 +- internal/manual/chat/settings.md | 10 +- .../chat/use-this-on-another-computer.md | 6 +- internal/manual/chat_test.go | 8 + internal/manual/chatdisplaytruth_test.go | 4 +- internal/tui3/connectcaps_test.go | 4 +- internal/tui3/devices.go | 63 ++- internal/tui3/homeband_addmachine.go | 5 +- internal/tui3/machinewatch.go | 6 +- internal/tui3/place_settings.go | 28 +- internal/tui3/settings.go | 140 ++++- internal/tui3/settings_devices.go | 389 ++++++++++++++ internal/tui3/settings_devices_test.go | 491 ++++++++++++++++++ internal/tui3/settings_organization_test.go | 2 +- internal/tui3/settingspend_test.go | 2 +- 20 files changed, 1142 insertions(+), 60 deletions(-) create mode 100644 internal/tui3/settings_devices.go create mode 100644 internal/tui3/settings_devices_test.go diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index 6ae166a588..ec8f372901 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -164,6 +164,20 @@ desk was removed — it can no longer reach your chats. The list is in a fixed order: this device first, then devices that are online, then the rest, each by name. Two devices with the same name show a short tail, such as `tower #a1b2`, so you can tell them apart. Your own computer has no `r`: it cannot remove itself, and if you press `r` there the list says `this device cannot remove itself — choose another row.` A computer that is already removed does nothing when you press `r`. A removed computer can only come back as a new request that you approve. If you remove a computer from another of your devices while this list is open, its row changes to `removed` here too, without closing the list. Removing a computer does not undo what it already holds: for a lost computer read the page *Locking out a lost computer for good*. +## Devices in /settings — where your computers are on the settings page, add another device, rename this one, or remove (revoke) one from settings + +Open `/settings` (or `ctrl+,`) and walk with `←` and `→` to `Devices`, between `Connections` and `Privacy` — or type `devices`, `remove`, `revoke`, `pair` or `add device` in settings search. + +While loading, the tab says `looking up your devices…`. Loaded, it lists your paired computers in the same order and words as `/devices` — `●` online, `○` away (`seen 3h ago`, or `offline`), `this device`, and `removed` — followed by `Add another device`. The open tab refreshes on its own when a computer joins, renames itself or is removed. + +The key line shrinks to what works on the row (`↑↓ choose` when there is more than one row, `r remove` only on a removable computer, `←→ categories`, `esc close`): + +- `enter add` on `Add another device` leaves `/settings` and starts `/pair`. +- `n rename` on `this device` opens `name this device: ` under the row with `enter save · ctrl+u clear · esc cancel`. While typing, `esc` cancels the edit without closing `/settings`; a second `esc` leaves `/settings`. On another row `n` says `a device renames itself — choose this device's row, or open /devices on the one you mean.` +- `r remove` (called `codeaf devices revoke ` on the CLI) removes the computer under the cursor at once, marks its row `removed`, and says `desk was removed — it can no longer reach your chats.` On your own row `r` says `this device cannot remove itself — choose another row.` On `Add another device` or a `removed` row, `r` does nothing. + +When devices cannot work here, the tab shows one sentence and no list or `Add another device` row: `pairing is not available here` without a pairing door (such as `--host` or `--at`), `moving chats needs a newer furrow engine than this build carries` without a compatible engine, or the read error if loading fails. + ## A device was removed but refreshing the list failed After a successful removal, its confirmation stays visible even if refreshing the device list fails. The card shows the refresh error too, then clears that error when a later refresh succeeds. A failed refresh does not restore the removed computer's access. If the removal itself fails, the card shows that error and does not confirm a removal. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index db03b64542..5befa40ba7 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -341,7 +341,7 @@ command). | `/pair` | `/sync`, `/link`, `/laptop` | `` | on the other computer: uses a code from `/pair` there, and shows three words to compare while it waits | | `/pair` | `/sync`, `/link`, `/laptop` | ` --replace` | on the other computer: like ``, but a computer with chats of its own is asked first, and an explicit yes replaces them with the shared ones — chats sealed under the old identity cannot be read afterwards | | `/pair` | `/sync`, `/link`, `/laptop` | `` | on a device that is already paired: opens a new device's request from the link it shows, with its name, system, time and a check number to compare, then `a` approves and `d` declines | -| `/devices` | — | — | lists your devices with `●` online and `○` away; `n` renames this device, `r` removes the one under the cursor at once, and a removed device can only return as a new request you approve | +| `/devices` | — | — | lists your devices with `●` online and `○` away; `n` renames this device, `r` removes the one under the cursor at once, and a removed device can only return as a new request you approve (`/settings` → `Devices` holds the same list plus `Add another device`) | The words to type are `/pair`, `/pair `, `/sync`, `/link` and `/laptop`. All five open the same door. In a terminal `codeaf pair --code` shows a code and `codeaf pair ` uses one; bare `codeaf pair` requests approval by link instead. The page *Pairing your chats with a second computer* has the whole story: the three words, one attempt for each code, and what revoking cannot take back. @@ -1817,7 +1817,7 @@ this split when it opens; configure the remote profile on that machine. ## /settings, /set, /config — opening and navigating settings `/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 +codeaf settings, plus tabs for connected accounts and paired devices. It was the first of the three fullscreen 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. @@ -1844,7 +1844,7 @@ and saves. Text and number editors offer adjacent Save/Cancel controls. Invalid input keeps the draft and shows its error beside it. Escape cancels an open editor or choice list without saving. -esc backs out one layer at a time: the search first, then anything the `Connections` tab +esc backs out one layer at a time: the search first, then anything the `Connections` or `Devices` tab has open, then the panel. The head line says `esc close` on the right. The bottom legend normally reads `↑↓ move · ←→ tabs · enter change · type to search · esc close`. @@ -1918,7 +1918,7 @@ Refusals inside the panel, exactly as written: they say `that model is chosen where its session is opened`. - Any refusal from the settings registry is shown on the foot line in the registry's own words. Nothing is swallowed. -- A search that matches nothing says `nothing matches`. The `Connections` tab has its own +- A search that matches nothing says `nothing matches`. The `Connections` and `Devices` tabs have their own sentences. ## config.json keys are not read — why codeaf says a setting I wrote is ignored @@ -1943,7 +1943,7 @@ value to the key a settings row names (`settings` lists every one), or delete th Settings now groups controls by what you want to change: -`General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Privacy` +`General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Devices · Privacy` General holds appearance, input and updates. Models holds model selection and advanced routing/context controls. Memory holds `notice things to remember` — @@ -1951,6 +1951,8 @@ whether codeaf may keep a line on its own after a turn you typed — and a link the Memories tab. 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. +Devices lists your paired computers, renames this one, removes another, and starts +`/pair` from `Add another device`. Privacy holds history, drafts and data-sharing choices. Old saved keys and values are unchanged. Search accepts old labels and keys, @@ -1965,7 +1967,7 @@ 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. -Type to search across settings and connection services. Escape clears the +Type to search across settings, connection services and paired devices. Escape clears the search before leaving settings. Advanced controls remain searchable while collapsed. Escape from an editor cancels its unfinished edit. @@ -2014,7 +2016,7 @@ have no provider row behind them, so nothing unfolds there and the legend does n 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. +Connections tab, the device rename box on the Devices tab — the foot drops `tab next place`, because the layer has taken that key. ## Task model seats and their limits in Settings diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index f4ef4e4914..cabfabcec4 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1966,10 +1966,10 @@ into… picker, typing filters, `↑` `↓` walk, `enter` moves, `esc` cancels. the *teams page* page. **Settings panel** (`ctrl+,`): `esc` backs out one layer at a time — search, then an -open account, then the panel · `left`/`shift+tab` and `right`/`tab` change tab · +open account or device rename, then the panel · `left`/`shift+tab` and `right`/`tab` change tab · `up`/`ctrl+p`, `down`/`ctrl+n`, `pgup`, `pgdown`, `home`, `end` walk · `enter` activates, and `space` activates too — except while a search is on, when it goes into the box, so -a phrase like `shell command` can be written · `backspace`, `ctrl+u`, `ctrl+w` edit the +a phrase like `shell command` can be written · on the **Devices** tab without a search, `n` renames this device and `r` removes the selected computer · `backspace`, `ctrl+u`, `ctrl+w` edit the search · anything else types into it. **Sessions place** (`ctrl+.`, or `/history`, or the one dim door line at the bottom of the task diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 465b99a659..e14de647d3 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -3089,7 +3089,7 @@ Open **Spending** for monetary limits. There is no practice limit: codeaf no lon The settings panel's tab bar reads, in order: ``` -General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Privacy +General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Devices · Privacy ``` Money is on **Spending** and nowhere else. The rows that used to share it are on the two diff --git a/internal/manual/chat/naming-a-device.md b/internal/manual/chat/naming-a-device.md index 6de3fef020..e9f4d4b836 100644 --- a/internal/manual/chat/naming-a-device.md +++ b/internal/manual/chat/naming-a-device.md @@ -6,7 +6,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -Every screen that names a device uses one name for it: the home rows (`running on tower`, `tower offline`), `Continue tower's latest chat here?`, the approve screen, `tower is now paired. Your chats can continue there.`, `/devices`, and the line after a move, `Moved from tower in 4s`. A device is called by its host name, the word your operating system already uses for it, until you give it a name. A name is only a label. It is not a key and it signs nothing, and renaming a device changes nothing about what it can reach. +Every screen that names a device uses one name for it: the home rows (`running on tower`, `tower offline`), `Continue tower's latest chat here?`, the approve screen, `tower is now paired. Your chats can continue there.`, `/devices`, `/settings` → `Devices`, and the line after a move, `Moved from tower in 4s`. A device is called by its host name, the word your operating system already uses for it, until you give it a name. A name is only a label. It is not a key and it signs nothing, and renaming a device changes nothing about what it can reach. Because you never named this device, it kept the default. Name it and every other device shows the new name. @@ -28,7 +28,7 @@ Type this on the device you mean: codeaf devices rename "atlas" ``` -Or open `/devices`, stay on the row marked `this device`, and press `n`. The name is filled in under the list: +Or open `/devices` (or `/settings` → `Devices`), stay on the row marked `this device`, and press `n`. The name is filled in under the list: ``` name this device: atlas▏ @@ -59,4 +59,4 @@ One to 32 characters you can see. Spaces at the start and end are dropped. A nam - `a device name is at most 32 characters` - `a device name cannot hold control characters or line breaks` -Two devices may have the same name. `/devices` then adds a short tail, such as `tower #a1b2`, so you can tell them apart. +Two devices may have the same name. `/devices` and `/settings` → `Devices` then add a short tail, such as `tower #a1b2`, so you can tell them apart. diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index 7ca5f9f885..8e80489cc6 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -9,7 +9,7 @@ You learn one thing: **a code shown on a screen you own, typed on the other comp | What the other computer gets | a key to this machine's tools: a hand on that one machine | your identity: your chats, your saved keys and your sync, on every computer you own | | The code | six digits, shown `715 302`, typed with the name of the machine | a number, then six digits, shown `42-715-302`; the number is the place, so the code carries it | | This machine must | stay on and stay connected | be there only while you pair | -| Who can undo it | this machine alone, with `codeaf devices revoke `, at once | you, with `codeaf devices revoke `, but it cuts the relay only; see the page *Pairing your chats with a second computer* | +| Who can undo it | this machine alone, with `codeaf devices revoke `, at once | you, with `r` in `/devices` or `/settings` → `Devices`, or `codeaf devices revoke `, but it cuts the relay only; see the page *Pairing your chats with a second computer* | **One code never gives both.** A code for your chats does not give a shell on any machine. A code for a machine does not give your chats. diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 08d7c7ef02..8c2e044485 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -3,13 +3,13 @@ ## 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 +Memory, Tasks, AI teams, Permissions, Spending, Connections, Devices 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 +Type to search across categories, including advanced controls, connection +services and paired devices. Old setting names and configuration keys remain searchable. Clear the 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. @@ -138,6 +138,10 @@ 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. +**Devices** lists your paired computers in the same words and order as `/devices`, +renames this device with `n`, removes another with `r`, and starts `/pair` from +`Add another device`. + **Privacy** separates input history, unfinished drafts, anonymous usage reporting and **shared model recommendations** (formerly Model Pool). Recommendations can **use and contribute**, **use only**, or remain **off**. Disabling one privacy diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index f00b892eaf..b3a57fa2f0 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -16,7 +16,7 @@ This page is the code way. A new computer can also ask to join and be approved f There are two doors. Pick one. -1. On the computer that has your chats, type `/pair` in the chat. Or run `codeaf pair --code` in a terminal. It shows a code and how to use it: +1. On the computer that has your chats, type `/pair` in the chat (or choose `Add another device` in `/settings` → `Devices`). Or run `codeaf pair --code` in a terminal. It shows a code and how to use it: ``` this shares your chats with the device you pair @@ -215,7 +215,7 @@ There is no phone app for continuing chats. A phone browser can open the `/p/` p Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. -When moving chats is available, `codeaf devices` lists every device linked to this computer, in up to two groups. A group with nothing in it does not show. With nothing of either kind it says: +When moving chats is available, `codeaf devices` lists every device linked to this computer, in up to two groups (in the chat, `/devices` and `/settings` → `Devices` list your paired computers). A group with nothing in it does not show. With nothing of either kind it says: ``` nothing is paired yet — run `codeaf pair` to add another device, or `codeaf serve` to let a device use this machine. @@ -242,7 +242,7 @@ devices that can use this machine ## Remove a paired computer — revoke and the chats it already downloaded -To remove a computer, run `codeaf devices revoke ` with the name from the list. It works on both groups. When you remove a device that holds your chats, it says: +To remove a computer from a terminal, run `codeaf devices revoke ` with the name from the list (in the chat, press `r` on its row in `/devices` or `/settings` → `Devices`). In the terminal, `revoke` works on both groups. When you remove a device that holds your chats, it says: ``` desktop was removed — it can no longer sync your chats. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 96d611c5f0..593915fb89 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -3219,6 +3219,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"what does leave it there do", "continuing-a-chat-on-another-computer"}, {"when was a device last seen", "add-or-remove-a-computer"}, {"what does the removed computer see after I remove it", "add-or-remove-a-computer"}, + {"can I remove a device from settings", "add-or-remove-a-computer"}, + {"where are my computers in the settings page", "add-or-remove-a-computer"}, + {"how do I revoke a computer in settings", "add-or-remove-a-computer"}, + {"add another device from settings", "add-or-remove-a-computer"}, {"what happens to node_modules when I move a chat", "home"}, {"are my job logs moved with the chat", "home"}, {"does the task history come with the chat to another computer", "home"}, @@ -4408,6 +4412,10 @@ func TestTheDeviceQuestionsReachTheirPages(t *testing.T) { {"what is the check number on the new device", add, "Check number 4821. Approve only if"}, {"what does codeaf pair --code do", add, "codeaf pair --code"}, {"how do I remove a computer", add, "r remove"}, + {"can I remove a device from settings", add, "between `Connections` and `Privacy`"}, + {"where are my computers in the settings page", add, "between `Connections` and `Privacy`"}, + {"how do I revoke a computer in settings", add, "between `Connections` and `Privacy`"}, + {"add another device from settings", add, "`enter add` on `Add another device` leaves `/settings`"}, {"what does the dot next to a computer mean", add, "○ desk (offline)"}, {"I was told this computer was removed", add, "this device was removed by another of your devices"}, {"how do I rename this computer", naming, "codeaf devices rename \"atlas\""}, diff --git a/internal/manual/chatdisplaytruth_test.go b/internal/manual/chatdisplaytruth_test.go index e781211276..5b8967fc35 100644 --- a/internal/manual/chatdisplaytruth_test.go +++ b/internal/manual/chatdisplaytruth_test.go @@ -83,6 +83,8 @@ func TestNoSectionRepeatsStaleHelpSettingsGreetingOrManagerClaims(t *testing.T) "The nine tabs need", "walk the nine one at a time", "Tasks · Providers · Connections", + "Connections · Privacy", + "Spending, Connections and Privacy", "a plain launch opens straight on home", "deterministic `@manager-...` fallback", } @@ -150,7 +152,7 @@ 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", "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"}}, + {"commands", "Settings categories — where did Session, Context, Workspace, Display and Providers go", []string{"General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Devices · 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/tui3/connectcaps_test.go b/internal/tui3/connectcaps_test.go index b061ab5227..e369fc944c 100644 --- a/internal/tui3/connectcaps_test.go +++ b/internal/tui3/connectcaps_test.go @@ -105,8 +105,8 @@ func TestTheConnectionsTabIsOnTheBarAndOwnsNoRegistryRow(t *testing.T) { if !found { t.Fatalf("the tab bar has no %s tab: %v", tabConnections, settingTabs) } - if at != len(settingTabs)-2 { - t.Fatalf("%s sits at %d of %d, want the end of the bar", tabConnections, at, len(settingTabs)) + if at != len(settingTabs)-3 { + t.Fatalf("%s sits at %d of %d, want before Devices and Privacy", tabConnections, at, len(settingTabs)) } registry := config.NewSettings(config.SettingsOptions{ProfileDir: t.TempDir()}) for _, row := range registry.Rows() { diff --git a/internal/tui3/devices.go b/internal/tui3/devices.go index bb6f813fc2..67d1a32075 100644 --- a/internal/tui3/devices.go +++ b/internal/tui3/devices.go @@ -85,16 +85,41 @@ type revokedMsg struct { err error } +// openDevicesCard is the device list currently on screen, whether opened as +// /devices or hosted on the /settings sheet. +func (a *app) openDevicesCard() *deviceCard { + if a.pair.open { + if card, ok := a.pair.card.(*deviceCard); ok { + return card + } + } + if a.at(pageSettings) && a.sheet.dev != nil { + return a.sheet.dev + } + return nil +} + +// ownsDeviceCard reports whether c is still held by /devices or by the open +// /settings sheet, so a late answer from a closed view is dropped. +func (a *app) ownsDeviceCard(c *deviceCard) bool { + return c != nil && (a.pair.card == panelCard(c) || (a.at(pageSettings) && a.sheet.dev == c)) +} + func (m devicesMsg) land(a *app) tea.Cmd { - if a.pair.card != panelCard(m.card) { + if !a.ownsDeviceCard(m.card) { return nil } + wasAdd := a.at(pageSettings) && a.sheet.dev == m.card && a.sheet.onAddDeviceRow() + if a.at(pageSettings) && a.sheet.dev == m.card { + a.sheet.syncDeviceCursor() + } if m.err != nil { if !m.card.loaded { m.card.line = m.err.Error() } else { m.card.readErr = m.err } + a.syncSheetDeviceCard(m.card, wasAdd) return nil } m.card.readErr = nil @@ -105,6 +130,7 @@ func (m devicesMsg) land(a *app) tea.Cmd { picked, had := m.card.pick() m.card.rowsOf, m.card.loaded = orderDevices(m.rows), true m.card.keepCursorOn(picked.ID, had) + a.syncSheetDeviceCard(m.card, wasAdd) return nil } @@ -128,27 +154,33 @@ func (c *deviceCard) livePresence() (map[string]bool, bool) { } func (m renamedMsg) land(a *app) tea.Cmd { - if a.pair.card != panelCard(m.card) { + if !a.ownsDeviceCard(m.card) { return nil } + wasAdd := a.at(pageSettings) && a.sheet.dev == m.card && a.sheet.onAddDeviceRow() if m.err != nil { m.card.line = m.err.Error() + a.syncSheetDeviceCard(m.card, wasAdd) return nil } m.card.naming, m.card.line = nil, m.said + a.syncSheetDeviceCard(m.card, wasAdd) return a.reloadDevices(m.card) } func (m revokedMsg) land(a *app) tea.Cmd { - if a.pair.card != panelCard(m.card) { + if !a.ownsDeviceCard(m.card) { return nil } + wasAdd := a.at(pageSettings) && a.sheet.dev == m.card && a.sheet.onAddDeviceRow() if m.err != nil { m.card.line = m.err.Error() + a.syncSheetDeviceCard(m.card, wasAdd) return nil } m.card.mark(m.row.ID) m.card.line = fmt.Sprintf(devicesRevoked, m.row.Name) + a.syncSheetDeviceCard(m.card, wasAdd) return a.forgetDevice(m.row.ID) } @@ -292,8 +324,8 @@ func (a *app) reloadDevices(c *deviceCard) tea.Cmd { // refreshOnChange reads the list again when the feed has announced a directory version the open // card has not read, so what another computer did to a device shows here without closing the card. func (a *app) refreshOnChange() tea.Cmd { - card, open := a.pair.card.(*deviceCard) - if !open || !a.pair.open || a.dirFeed == nil || a.approvals == nil { + card := a.openDevicesCard() + if card == nil || a.dirFeed == nil || a.approvals == nil { return nil } version := a.dirFeed.State().Version @@ -348,9 +380,11 @@ func (c *deviceCard) waiting() string { return devicesLoading } -// row is one device: its dot, name, system and, when it is off, when it was -// last seen. The cursor row is ink; the others are dim. -func (c *deviceCard) row(i int, d DeviceRow, width int, now time.Time, pal palette) string { +// rowText is one device's words without the card's own cursor gutter: its dot, +// name, system and, when it is off, when it was last seen. The card frames it +// with `› `, and the /settings Devices tab hands the same string to the sheet's +// row painter so neither list can drift from the other. +func (c *deviceCard) rowText(d DeviceRow, now time.Time, pal palette) string { online, _ := c.livePresence() d.Online = online[d.ID] && !d.Revoked nameW, wordW := c.columns() @@ -358,6 +392,13 @@ func (c *deviceCard) row(i int, d DeviceRow, width int, now time.Time, pal palet for _, tail := range d.tails(now) { text += " " + tail } + return text +} + +// row is one device: its dot, name, system and, when it is off, when it was +// last seen. The cursor row is ink; the others are dim. +func (c *deviceCard) row(i int, d DeviceRow, width int, now time.Time, pal palette) string { + text := c.rowText(d, now, pal) if i == c.cursor { return pal.ink(fit("› "+text, width)) } @@ -374,10 +415,12 @@ func (c *deviceCard) columns() (nameW, wordW int) { return nameW, wordW } -// dot is `●` for a device online now, `○` otherwise. +// dot is the filled circle for a device online now, and the resting circle +// otherwise, both resolved through the palette's glyph door so ASCII and Nerd +// Font terminals render their own repertoire. func (d DeviceRow) dot(pal palette) string { if !d.Revoked && (d.Online || d.Self) { - return "●" + return pal.glyph(tokens.GStepDone) } return pal.glyph(tokens.GQueued) } diff --git a/internal/tui3/homeband_addmachine.go b/internal/tui3/homeband_addmachine.go index 0355be6568..860ca1346a 100644 --- a/internal/tui3/homeband_addmachine.go +++ b/internal/tui3/homeband_addmachine.go @@ -46,7 +46,10 @@ const ( // fleetAskTimeout bounds one ask, so a silent directory is an unknown fleet. fleetAskTimeout = 5 * time.Second - addMachineHeading = "+ Add another device" + // addDeviceWord is the one person-facing name for adding a computer, shared + // between the home card and the /settings Devices tab so the two never drift. + addDeviceWord = "Add another device" + addMachineHeading = "+ " + addDeviceWord addMachinePitch = "Pick up your work anywhere, exactly where you left it." addMachineStepRun = "1. On the new device, install and run: codeaf pair" addMachineStepPut = "2. It shows a link. Paste it here:" diff --git a/internal/tui3/machinewatch.go b/internal/tui3/machinewatch.go index f97dacd2f2..1ebafaa8e4 100644 --- a/internal/tui3/machinewatch.go +++ b/internal/tui3/machinewatch.go @@ -133,10 +133,10 @@ func (a *app) probeWatch() { } } -// devicesCardOpen says /devices is on screen, which wants the feed on any page. +// devicesCardOpen says the device list is on screen — as /devices or on the +// /settings sheet — which wants the feed on any page. func (a *app) devicesCardOpen() bool { - _, open := a.pair.card.(*deviceCard) - return a.pair.open && open + return a.openDevicesCard() != nil } // letGoOffHome leaves the feed once the card that joined it away from home has diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 939b2df80a..6ad2a187e6 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -43,7 +43,7 @@ func (placeSettings) open(a *app) tea.Cmd { // // OVER --host THE TEAMS TAB READS THE FAR MACHINE, once, off the loop. A // local launch asks for nothing. - return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults()) + return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults(), a.ensureSheetDevices()) } // tick re-reads nothing and keeps the beat: see the note over [placeSettings.open]. @@ -135,7 +135,7 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { if s.edit != nil { return settingsEditorBody(a, width, room, rows) } - if s.sel == nil && s.edit == nil && s.conn.entry == nil { + if s.sel == nil && s.edit == nil && s.conn.entry == nil && !s.devTyping() { filter, _, _ := draftBlock(&s.query, pal, width-2, 1, "Search all settings", "") for _, line := range filter { rows = append(rows, placeRow{text: " " + line}) @@ -370,6 +370,9 @@ func (placeSettings) note(a *app, width int) []string { return []string{" " + pal.dim(noteFit(a.sheet.sel.label, width-2))} } + if lines, ok := a.sheet.devicesNote(pal, width); ok { + return lines + } item, ok := settingsFocused(a) scope := a.sheet.footNote() if ok && (item.memoryDoor || item.advancedDoor) { @@ -397,6 +400,15 @@ func settingsFocused(a *app) (sheetItem, bool) { } func settingsDetails(a *app, width int) []string { + if a.sheet.onDevices() && !a.sheet.searching() { + frameW, _ := a.size() + lines := a.sheet.devicesDetails(frameW, width) + if len(lines) > 5 { + lines = lines[:5] + lines[4] = fit(lines[4]+glyphMore, max(1, width-4)) + } + return lines + } item, ok := settingsFocused(a) if !ok { return nil @@ -451,6 +463,8 @@ func (placeSettings) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { a.sheetSelectKey(msg) case s.conn.entry != nil: return a.connEntryKey(msg), true + case s.devTyping(): + cmd = s.dev.typed(a, msg) default: return nil, false } @@ -485,6 +499,16 @@ 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.devTyping() { + line, column := a.sheet.devNamingLine(width, a.pal) + want := ansi.Strip(line) + for j, row := range rows { + if content(row.text) == want { + return j, column + offset, true + } + } + return -1, 0, true + } if a.sheet.edit != nil { block, column, at := a.sheet.editBlock(width, a.pal) if at >= 0 && at < len(block) { diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index d7d23d552d..1d85cdc774 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" @@ -87,7 +88,7 @@ const ( ) var settingTabs = []string{tabGeneral, tabProviders, tabMemory, tabTasks, tabTeams, - tabSafety, tabSpending, tabConnections, tabPrivacy} + tabSafety, tabSpending, tabConnections, tabDevices, tabPrivacy} var settingTabCategory = map[string]string{ tabSpending: config.CategorySpending, tabSafety: config.CategorySafety, tabTasks: config.CategoryTasks, tabTeams: config.CategoryTeams, @@ -924,6 +925,10 @@ type sheetItem struct { // reason: an item is an item, and only what draws it and what the cursor // does with it ask which kind this one is. read *railReading + // dev is set on the rows of the Devices tab, which reuse [deviceCard] and + // add one door to `/pair` rather than reading the settings registry + // (settings_devices.go). + dev *deviceSheetRow // hitAt and hitLen say where the search landed on this row's own label — // an offset and a length into the sheet's hit span, the bytes the query // matched, which the row draws carried in bold ([overlayLinesHit]). Both @@ -991,7 +996,14 @@ type sheet struct { // conn is what that tab remembers between builds (connectcaps.go). conn connTab - rows []config.Setting + // cellsUnavailable, approvals, dev and now serve the Devices tab, which + // hosts a [deviceCard] over the surface's own [Approvals] door and clock + // (settings_devices.go). + cellsUnavailable string + approvals Approvals + dev *deviceCard + now func() time.Time + rows []config.Setting // matchFields is the field list the search hands the fuzzy matcher one row // at a time, reused rather than rebuilt: a rebuild runs per keystroke over // every row, and a fresh slice per row is the one allocation it does not @@ -1296,17 +1308,20 @@ func (a *app) raiseSettings() { // conversation-wide reading taken on the way up and refreshed after every // write ([app.readAutonomy]), not a door asked here. Opening a page is on // the update loop, where a surface may not wait on a network. - autonomy: a.autonomyRules, - autonomyDoor: a.hasAutonomyDoor(), - conns: a.conns, - modelRows: a.modelConnectionRows, - liveModel: a.conversationModel, - sources: a.sources, - force: a.laneForceNow, - defaults: settingDefaults(), - sessionModel: a.model, - routing: a.routing, - today: a.todayReading(), + autonomy: a.autonomyRules, + autonomyDoor: a.hasAutonomyDoor(), + conns: a.conns, + cellsUnavailable: a.cellsUnavailable, + approvals: a.approvals, + now: a.now, + modelRows: a.modelConnectionRows, + liveModel: a.conversationModel, + sources: a.sources, + force: a.laneForceNow, + defaults: settingDefaults(), + sessionModel: a.model, + routing: a.routing, + today: a.todayReading(), } a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() @@ -1346,6 +1361,7 @@ func (a *app) closeSettings() { // dropSettings is [placeSettings]'s `close`. Nothing but the router calls it. func (a *app) dropSettings() { a.sheet = sheet{} + a.letGoOffHome() a.touch() } @@ -1472,6 +1488,10 @@ func (s *sheet) build() { return } if query == "" { + if s.onDevices() { + s.buildDevices() + return + } // THE SPENDING TAB LAYS ITSELF OUT, because three of its rows are not // registry rows at all and the order of the rest is a reading of how // often a person worries about each one rather than of registry order @@ -1595,6 +1615,9 @@ func (s *sheet) build() { if title == tabConnections { s.buildConnections() } + if title == tabDevices { + best = max(best, s.appendDevicesSearch(terms)) + } if len(s.items) > start { groups = append(groups, resultGroup{start, len(s.items), tab, best}) } @@ -1815,6 +1838,7 @@ func (s *sheet) move(delta int) { at = next } s.cursor = at + s.syncDeviceCursor() } func abs(n int) int { @@ -2249,6 +2273,9 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { if bind := a.takeSpendRailBindCmd(); bind != nil { cmd = tea.Batch(cmd, bind) } + if devCmd := a.ensureSheetDevices(); devCmd != nil { + cmd = tea.Batch(cmd, devCmd) + } }() if !a.at(pageSettings) { return nil, false @@ -2329,11 +2356,37 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { s.move(sheetRows) case "home": s.cursor = s.clampCursor(0) + s.syncDeviceCursor() case "end": s.cursor = s.clampCursor(len(s.items) - 1) + s.syncDeviceCursor() case "enter": return a.activate(), true + case "r": + if s.onDevices() && !s.searching() && s.devicesUnavailable() == "" { + if s.dev != nil && s.dev.loaded { + s.syncDeviceCursor() + if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.unavailable { + return a.revoke(s.dev), true + } + } + return nil, true + } + s.query.insert("r") + s.build() + case "n": + if s.onDevices() && !s.searching() && s.devicesUnavailable() == "" { + if s.dev != nil && s.dev.loaded { + s.syncDeviceCursor() + if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.unavailable { + s.dev.startNaming() + } + } + return nil, true + } + s.query.insert("n") + s.build() case " ", "space": // A SPACE WHILE A SEARCH IS TYPED IS A WORD IN THE QUERY and not a // press on whatever row the cursor happens to share the screen with. @@ -2407,6 +2460,9 @@ func (s *sheet) tabBy(delta int) { // SERVICE, because coming back to a tab you left half-read and finding it // collapsed is the tab forgetting where you were. s.conn.armed, s.conn.entry = false, nil + if s.dev != nil { + s.dev.naming = nil + } s.build() } @@ -2431,6 +2487,30 @@ func (a *app) activate() tea.Cmd { s.build() return nil } + if item.dev != nil { + if item.dev.add { + a.closeSettings() + return a.runPair("") + } + if s.searching() { + wantID := item.dev.id + s.query.reset() + for at, title := range settingTabs { + if title == tabDevices { + s.tab = at + } + } + s.build() + for at, candidate := range s.items { + if candidate.dev != nil && !candidate.dev.add && candidate.dev.id == wantID { + s.cursor = at + break + } + } + s.syncDeviceCursor() + } + 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. @@ -3132,8 +3212,11 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { if bind := a.takeSpendRailBindCmd(); bind != nil { cmd = tea.Batch(cmd, bind) } + if devCmd := a.ensureSheetDevices(); devCmd != nil { + cmd = tea.Batch(cmd, devCmd) + } }() - if a.sheet.conn.entry != nil { + if a.sheet.conn.entry != nil || a.sheet.devTyping() { // 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). @@ -3192,6 +3275,9 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { a.sheet.tab = tab a.sheet.cursor, a.sheet.top, a.sheet.msg = 0, 0, "" a.sheet.conn.armed, a.sheet.conn.entry = false, nil + if a.sheet.dev != nil { + a.sheet.dev.naming = nil + } a.sheet.build() a.saySettingsHost() a.touch() @@ -3204,6 +3290,7 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { // that was passing through. if a.sheet.cursor != hit.index { a.sheet.cursor = hit.index + a.sheet.syncDeviceCursor() a.touch() return nil } @@ -3514,6 +3601,8 @@ func (s *sheet) listLines(width, room int, pal palette, hover int) ([]string, [] word := "nothing matches" if s.onConnections() { word = s.connEmptyWord() + } else if s.onDevices() && !s.searching() { + word = s.devEmptyWord() } put(placeLead+pal.dim(word), -1) return lines, owner @@ -3593,6 +3682,7 @@ func (s *sheet) cursorLastLine(owner []int, at, width int) int { entryOpen := item.conn != nil && item.conn.entry != nil entryOpen = entryOpen || (item.service != nil && s.conn.entry != nil && s.conn.entry.id == modelConnectionID(item.service.id)) + entryOpen = entryOpen || (item.dev != nil && s.devTyping()) if entryOpen { last := at for last+1 < len(owner) && owner[last+1] == s.cursor { @@ -3639,6 +3729,9 @@ func (s *sheet) rowLinesWithin(item sheetItem, selected, hovered bool, width, bo if item.conn != nil { return s.connRowLines(item.conn, selected, hovered, width, boxRows, pal) } + if item.dev != nil { + return s.devRowLines(item.dev, s.itemHit(item), selected, hovered, width, pal) + } if item.service != nil { head := overlayLines(item.service.name, item.service.value, selected, false, hovered, width, pal) entry := s.conn.entry @@ -3862,6 +3955,9 @@ func (s *sheet) footNote() string { if s.onConnections() { return s.connFootNote() } + if s.onDevices() { + return "" + } // THE TEAMS TAB IS DEFAULTS, and says where the exceptions live: a team's // own overrides are on its card on the teams page (teamsheet.go). Over // --host it says whose defaults the teams there really read. @@ -3920,6 +4016,8 @@ func (s *sheet) keysLine() string { return s.connKeysLine() case s.onConnections(): return s.connKeysLine() + case s.onDevices() && !s.searching(): + return s.devicesKeysLine() default: // A PINNED ROLE HAS A KEY THE REST OF THE SHEET DOES NOT, so the legend // says so on the row it works on and nowhere else — a line that offered @@ -3936,6 +4034,10 @@ func (s *sheet) keysLine() string { switch { case item.memoryDoor, item.crewDoor: action = "open" + case item.dev != nil && item.dev.add: + action = "add" + case item.dev != nil: + action = "open" case item.advancedDoor: action = "expand" if s.advanced[settingTabs[s.tab]] { @@ -3952,16 +4054,16 @@ func (s *sheet) keysLine() string { } } -// sheetLayerOwnsKeys is whether one of the three layers inside this panel has -// taken the WHOLE keyboard ([placeSettings.owns]). The foot asks, because a -// hint line that named the router's `tab` while a layer had it would be naming -// a key nothing on screen answers (pages.go's [app.placeHintSaid]). +// sheetLayerOwnsKeys is whether one of the layers inside this panel has taken +// the WHOLE keyboard ([placeSettings.owns]). The foot asks, because a hint line +// that named the router's `tab` while a layer had it would be naming a key +// nothing on screen answers (pages.go's [app.placeHintSaid]). func (a *app) sheetLayerOwnsKeys() bool { if !a.at(pageSettings) { return false } s := &a.sheet - return s.edit != nil || s.sel != nil || s.choice != nil || s.conn.entry != nil + return s.edit != nil || s.sel != nil || s.choice != nil || s.conn.entry != nil || s.devTyping() } // hoveredSheetRow is the item the pointer is over, or -1. diff --git a/internal/tui3/settings_devices.go b/internal/tui3/settings_devices.go new file mode 100644 index 0000000000..aaac23dd3f --- /dev/null +++ b/internal/tui3/settings_devices.go @@ -0,0 +1,389 @@ +package tui3 + +import ( + "sort" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/fuzzy" +) + +// ── THE DEVICES TAB ON THE SETTINGS SHEET ─────────────────────────────────── +// +// `/devices` is the fleet asked in a hurry; this is the same list at rest on the +// settings sheet, right after Connections, plus one door that starts `/pair`. +// +// ONE SOURCE OF TRUTH. The tab holds a [deviceCard] (devices.go) rather than a +// second device slice or a second set of strings: the same [Approvals] door, the +// same [orderDevices] sort, the same row formatting ([deviceCard.rowText]), the +// same `n` rename editor ([deviceCard.startNaming], [deviceCard.typed], +// [app.rename]), the same `r` removal ([app.revoke]), the same directory-version +// refresh ([app.refreshOnChange]), and the same unavailability sentences +// ([app.openDevices]). A sentence spelled in two places drifts. + +const ( + // tabDevices is the category title on the settings bar, placed between + // Connections and Privacy. + tabDevices = "Devices" + // devicesAddKey is the key legend clause for the row that starts pairing + // another computer. + devicesAddKey = "enter add" +) + +// deviceSheetRow hangs one Devices-tab entry off [sheetItem] so the sheet's own +// cursor walk, scroll window, hover and pointer hit-testing work unchanged. +type deviceSheetRow struct { + index int + id string + add bool + unavailable bool +} + +// onDevices reports whether the Devices category is the active tab. +func (s *sheet) onDevices() bool { + return s.tab >= 0 && s.tab < len(settingTabs) && settingTabs[s.tab] == tabDevices +} + +// devTyping reports whether the inline rename field on this device's row currently +// owns the keyboard. +func (s *sheet) devTyping() bool { + return s.onDevices() && s.dev != nil && s.dev.typing() +} + +// onAddDeviceRow reports whether the sheet cursor is resting on the add-device +// door row. +func (s *sheet) onAddDeviceRow() bool { + item, ok := s.current() + return ok && item.dev != nil && item.dev.add +} + +// devicesUnavailable answers why devices cannot work in this session, in the +// exact order and wording a typed `/devices` uses ([app.openDevices]), or "" +// when the door is ready to ask. +func (s *sheet) devicesUnavailable() string { + if s.cellsUnavailable != "" { + return s.cellsUnavailable + } + if s.approvals == nil { + return pairUnavailableWord + } + return "" +} + +// devNow reads the surface clock handed to the sheet so relative `seen … ago` +// timestamps stay deterministic under test clocks. +func (s *sheet) devNow() time.Time { + if s.now != nil { + return s.now() + } + return timeNow() +} + +// buildDevices populates the Devices tab when no search query is active. When +// devices cannot work here, or while the initial directory read is still in +// flight or failed, no rows are emitted — [sheet.listLines] draws the single +// explanatory sentence instead, never an empty list and never a working-looking +// add row. +func (s *sheet) buildDevices() { + if s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + s.cursor = 0 + return + } + for i, d := range s.dev.rowsOf { + s.items = append(s.items, sheetItem{ + dev: &deviceSheetRow{index: i, id: d.ID}, + }) + } + s.items = append(s.items, sheetItem{ + dev: &deviceSheetRow{add: true}, + }) + s.cursor = s.clampCursor(s.cursor) + s.syncDeviceCursor() +} + +// devEmptyWord is the single sentence drawn when the Devices tab has no rows to +// offer: the unavailability reason, the loading line, or the card's own read +// failure ([deviceCard.waiting]). +func (s *sheet) devEmptyWord() string { + if why := s.devicesUnavailable(); why != "" { + return why + } + if s.dev == nil { + return devicesLoading + } + return s.dev.waiting() +} + +// syncDeviceCursor keeps the hosted [deviceCard]'s cursor aligned with the +// sheet's cursor so `r`, `n` and re-reads act on the row the person selected. +// Standing on the add row parks the card's cursor past its slice so +// [deviceCard.pick] reports no device row under it. +func (s *sheet) syncDeviceCursor() { + if s.dev == nil { + return + } + item, ok := s.current() + if !ok || item.dev == nil || item.dev.add || item.dev.unavailable { + s.dev.cursor = len(s.dev.rowsOf) + return + } + s.dev.cursor = item.dev.index +} + +// ensureSheetDevices starts the initial directory read and joins the change +// watch the first time a visit lands on the Devices tab. Other categories never +// pay a network ask they did not look at. +func (a *app) ensureSheetDevices() tea.Cmd { + if !a.at(pageSettings) || !a.sheet.onDevices() { + return nil + } + if a.sheet.devicesUnavailable() != "" || a.sheet.dev != nil { + return nil + } + card := &deviceCard{presence: a.presence} + a.sheet.dev = card + if !a.sheet.searching() { + a.sheet.build() + } + return tea.Batch(a.joinWatchIfAbsent(), a.reloadDevices(card)) +} + +// syncSheetDeviceCard rebuilds the sheet after an asynchronous device read, +// rename or removal lands on its hosted [deviceCard], keeping the cursor on the +// same computer (or on the add row) across re-sorts. +func (a *app) syncSheetDeviceCard(c *deviceCard, wasAdd bool) { + if !a.at(pageSettings) || a.sheet.dev != c { + return + } + s := &a.sheet + if s.searching() { + s.build() + a.touch() + return + } + picked, had := c.pick() + s.build() + switch { + case wasAdd: + for i, item := range s.items { + if item.dev != nil && item.dev.add { + s.cursor = i + break + } + } + case had: + for i, item := range s.items { + if item.dev != nil && !item.dev.add && item.dev.id == picked.ID { + s.cursor = i + break + } + } + } + s.syncDeviceCursor() + a.touch() +} + +// addDeviceSearchWords and unavailableDevicesSearchWords let global `/settings` +// search find the Devices tab by any of the words people ask with — `devices`, +// `remove`, `revoke`, `pair`, `add device` — even before the tab has been opened +// on this visit. +var addDeviceSearchWords = []string{ + addDeviceWord, + tabDevices, + "devices", + "remove", + "revoke", + "pair", + "rename", + "computer", +} + +var unavailableDevicesSearchWords = []string{ + tabDevices, + "devices", + "remove", + "revoke", + "pair", + addDeviceWord, + "rename", + "computer", +} + +// appendDevicesSearch scores the Devices tab against a standing search query and +// appends its heading and matching rows. When pairing cannot work here, a match +// surfaces the unavailability sentence rather than a working-looking add row. +func (s *sheet) appendDevicesSearch(terms []fuzzy.Term) int { + if len(terms) == 0 { + return 0 + } + if s.devicesUnavailable() != "" { + score, ok := fuzzy.ScoreFields(unavailableDevicesSearchWords, terms) + if !ok { + return 0 + } + s.items = append(s.items, + sheetItem{head: tabDevices}, + sheetItem{dev: &deviceSheetRow{unavailable: true}}, + ) + return score + } + type devHit struct { + item sheetItem + score int + } + var matched []devHit + best := 0 + if s.dev != nil && s.dev.loaded { + for i, d := range s.dev.rowsOf { + fields := []string{ + s.dev.shownName(d), + platformOf(d.Platform).word, + tabDevices, + "devices", + "remove", + "revoke", + "rename", + "computer", + } + score, ok := fuzzy.ScoreFields(fields, terms) + if !ok { + continue + } + best = max(best, score) + matched = append(matched, devHit{ + item: sheetItem{dev: &deviceSheetRow{index: i, id: d.ID}}, + score: score, + }) + } + } + if score, ok, hitAt, hitLen := s.matchHits(addDeviceSearchWords, terms); ok { + best = max(best, score) + matched = append(matched, devHit{ + item: sheetItem{dev: &deviceSheetRow{add: true}, hitAt: hitAt, hitLen: hitLen}, + score: score, + }) + } + if len(matched) == 0 { + return 0 + } + sort.SliceStable(matched, func(i, j int) bool { return matched[i].score > matched[j].score }) + s.items = append(s.items, sheetItem{head: tabDevices}) + for _, one := range matched { + s.items = append(s.items, one.item) + } + return best +} + +// devRowLines renders one row of the Devices tab through the sheet's standard +// [overlayLines] grammar while delegating every word and mark of a device row +// to [deviceCard.rowText]. When this device is being renamed, the card's own +// prompt hangs directly beneath its row. +func (s *sheet) devRowLines(row *deviceSheetRow, hit []int, selected, hovered bool, width int, pal palette) []string { + if row.unavailable { + return overlayLines(s.devicesUnavailable(), "", selected, false, hovered, width, pal) + } + if row.add { + return overlayLinesHit(addDeviceWord, "", hit, selected, false, hovered, width, pal) + } + if s.dev == nil || row.index < 0 || row.index >= len(s.dev.rowsOf) { + return nil + } + d := s.dev.rowsOf[row.index] + head := overlayLines(s.dev.rowText(d, s.devNow(), pal), "", selected, false, hovered, width, pal) + if d.Self && s.devTyping() { + line, _ := s.devNamingLine(width, pal) + return append(head, line) + } + return head +} + +// devNamingLine paints the inline rename prompt under this device's row and +// reports the terminal column immediately after the typed name so +// [placeSettings.caretRow] can park the hardware cursor there. +func (s *sheet) devNamingLine(width int, pal palette) (string, int) { + if s.dev == nil || s.dev.naming == nil { + return "", 0 + } + room := max(1, width-overlayIndent) + shown := fit(devicesNameAsk+*s.dev.naming, room) + return strings.Repeat(" ", overlayIndent) + pal.ink(shown), overlayIndent + ansi.StringWidth(shown) +} + +// devicesNote supplies the foot-rule caption for the Devices tab. The tab +// writes to the directory rather than `.codeaf/config.json`, so at rest it says +// nothing instead of claiming profile writes; after a rename, removal or refusal +// that fits the rule, it shows the card's own sentence on the foot line. A +// sentence wider than the rule wraps whole in [settingsDetails] above it rather +// than being cut in half or repeated twice on one frame. +func (s *sheet) devicesNote(pal palette, width int) ([]string, bool) { + if !s.onDevices() || s.searching() { + return nil, false + } + if s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + return nil, true + } + room := max(1, width-2) + if s.dev.line != "" && ansi.StringWidth(s.dev.line) <= room { + return []string{" " + pal.dim(noteFit(s.dev.line, room))}, true + } + if s.dev.line == "" && s.dev.readErr != nil && ansi.StringWidth(s.dev.readErr.Error()) <= room { + return []string{" " + pal.bad(noteFit(s.dev.readErr.Error(), room))}, true + } + return nil, true +} + +// devicesDetails returns any card message that did not fit on the single-line +// foot rule (or a concurrent refresh error standing alongside a completed +// removal, so neither fact erases the other). +func (s *sheet) devicesDetails(frameWidth, bodyWidth int) []string { + if !s.onDevices() || s.searching() || s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + return nil + } + ruleRoom := max(1, frameWidth-placeNoteRuleFrame) + wrapRoom := max(1, bodyWidth-4) + var lines []string + if s.dev.line != "" && ansi.StringWidth(s.dev.line) > ruleRoom { + lines = append(lines, wrap(s.dev.line, wrapRoom)...) + } + if s.dev.readErr != nil && (s.dev.line != "" || ansi.StringWidth(s.dev.readErr.Error()) > ruleRoom) { + lines = append(lines, wrap(s.dev.readErr.Error(), wrapRoom)...) + } + return lines +} + +// devicesKeysLine builds the key legend for the Devices tab from [deviceCard]'s +// own key constants, shrinking dynamically to the gestures that work on the row +// under the cursor: `r remove` only on a removable computer, `↑↓ choose` only +// when more than one row can be walked, and `enter add` on the add row. +func (s *sheet) devicesKeysLine() string { + if s.devTyping() { + return s.dev.hint() + } + var out []string + restful := 0 + for _, item := range s.items { + if item.restful() { + restful++ + } + } + if restful > 1 { + out = append(out, devicesMoveKey) + } + if item, ok := s.current(); ok && item.dev != nil && !item.dev.unavailable { + if item.dev.add { + out = append(out, devicesAddKey) + } else { + out = append(out, devicesNameKey) + if s.dev != nil && item.dev.index >= 0 && item.dev.index < len(s.dev.rowsOf) { + if row := s.dev.rowsOf[item.dev.index]; !row.Self && !row.Revoked { + out = append(out, devicesGoneKey) + } + } + } + } + out = append(out, "←→ categories", devicesCloseKey) + return strings.Join(out, " · ") +} diff --git a/internal/tui3/settings_devices_test.go b/internal/tui3/settings_devices_test.go new file mode 100644 index 0000000000..53ed4be1a2 --- /dev/null +++ b/internal/tui3/settings_devices_test.go @@ -0,0 +1,491 @@ +package tui3 + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/furrowbin" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" + "github.com/charmbracelet/x/ansi" +) + +func openSettingsDevices(t *testing.T, door Approvals, now time.Time) (*app, *pairRig) { + t.Helper() + a, r := approveApp(t, door) + a.clock = func() time.Time { return now } + a.openSettings() + walkToDevicesTab(t, a, r) + if door != nil && a.cellsUnavailable == "" { + r.until("the settings device list", func() bool { + return a.sheet.dev != nil && (a.sheet.dev.loaded || a.sheet.dev.line != "" || a.sheet.dev.readErr != nil) + }) + } + return a, r +} + +func walkToDevicesTab(t *testing.T, a *app, r *pairRig) { + t.Helper() + for guard := 0; guard < len(settingTabs) && !a.sheet.onDevices(); guard++ { + r.press("right") + } + if !a.sheet.onDevices() { + t.Fatalf("right arrows never reached %s; stopped on %s", tabDevices, settingTabs[a.sheet.tab]) + } +} + +// Validation contract item 1: `/settings` shows a `Devices` tab between +// `Connections` and `Privacy`, and moving across the bar with the existing tab +// keys (`←` and `→`) reaches it. +func TestSettingsDevicesTabSitsBetweenConnectionsAndPrivacyAndIsReachedByTabKeys(t *testing.T) { + at := -1 + for i, name := range settingTabs { + if name == tabDevices { + at = i + break + } + } + if at <= 0 || at+1 >= len(settingTabs) { + t.Fatalf("%s at index %d in %v, want between %s and %s", tabDevices, at, settingTabs, tabConnections, tabPrivacy) + } + if settingTabs[at-1] != tabConnections || settingTabs[at+1] != tabPrivacy { + t.Fatalf("neighbors around %s are %q and %q, want %q and %q", + tabDevices, settingTabs[at-1], settingTabs[at+1], tabConnections, tabPrivacy) + } + + a, r := approveApp(t, devicesFake()) + a.openSettings() + if settingTabs[a.sheet.tab] != tabGeneral { + t.Fatalf("settings opened on %s, want %s", settingTabs[a.sheet.tab], tabGeneral) + } + walkToDevicesTab(t, a, r) + if !strings.Contains(plain(frame(a)), tabDevices) { + t.Fatalf("frame does not draw %s on the bar:\n%s", tabDevices, plain(frame(a))) + } + r.press("right") + if settingTabs[a.sheet.tab] != tabPrivacy { + t.Fatalf("one step right of %s landed on %s, want %s", tabDevices, settingTabs[a.sheet.tab], tabPrivacy) + } + r.press("left") + if !a.sheet.onDevices() { + t.Fatalf("one step left of %s landed on %s, want %s", tabPrivacy, settingTabs[a.sheet.tab], tabDevices) + } +} + +type gatedDevicesApprovals struct { + *fakeApprovals + release chan struct{} +} + +func (g *gatedDevicesApprovals) Devices(ctx context.Context) ([]DeviceRow, error) { + select { + case <-g.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return g.fakeApprovals.Devices(ctx) +} + +// Validation contract item 2: the tab lists devices through `a.approvals.Devices` +// in `orderDevices` order, using the card's row words (`this device`, +// `seen … ago`, `removed`, online and offline dots from the palette's glyph +// door) and `devicesLoading` while loading, and re-reads when the directory +// version advances (`refreshOnChange`). +func TestSettingsDevicesListsDevicesInCardOrderAndRefreshesOnDirectoryChange(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + door := &fakeApprovals{devices: []DeviceRow{ + {ID: "dev_C", Name: "desk", Platform: "darwin", LastSeen: now.Add(-3 * time.Hour)}, + {ID: "dev_B", Name: "tower", Platform: "linux"}, + {ID: "dev_A", Name: "this mac", Platform: "darwin", Self: true}, + }} + gated := &gatedDevicesApprovals{fakeApprovals: door, release: make(chan struct{})} + + a, r := approveApp(t, gated) + a.clock = func() time.Time { return now } + a.dirFeed = &stillFeed{online: []string{"dev_B"}, version: 1} + a.openSettings() + walkToDevicesTab(t, a, r) + + loading := plain(frame(a)) + if !strings.Contains(loading, devicesLoading) { + t.Fatalf("while loading, tab lacked %q:\n%s", devicesLoading, loading) + } + if strings.Contains(loading, addDeviceWord) { + t.Fatalf("while loading, tab already drew %q:\n%s", addDeviceWord, loading) + } + + close(gated.release) + r.until("loaded rows", func() bool { return a.sheet.dev != nil && a.sheet.dev.loaded }) + + got := plain(frame(a)) + selfAt := strings.Index(got, "● this mac") + towerAt := strings.Index(got, "● tower") + deskAt := strings.Index(got, "○ desk") + addAt := strings.Index(got, addDeviceWord) + if selfAt < 0 || towerAt < 0 || deskAt < 0 || addAt < 0 { + t.Fatalf("missing expected rows:\n%s", got) + } + if !(selfAt < towerAt && towerAt < deskAt && deskAt < addAt) { + t.Fatalf("rows out of orderDevices order (self=%d tower=%d desk=%d add=%d):\n%s", + selfAt, towerAt, deskAt, addAt, got) + } + for _, want := range []string{devicesSelf, "seen 3h ago"} { + if !strings.Contains(got, want) { + t.Fatalf("screen lacked %q:\n%s", want, got) + } + } + + // Dots resolve through the palette's glyph door rather than literal marks. + asciiPal := newPalette(tokens.ANSI256, true) + asciiSelf := plain(strings.Join(a.sheet.devRowLines(a.sheet.items[0].dev, nil, false, false, 80, asciiPal), "\n")) + asciiAway := plain(strings.Join(a.sheet.devRowLines(a.sheet.items[2].dev, nil, false, false, 80, asciiPal), "\n")) + if !strings.Contains(asciiSelf, "# this mac") || !strings.Contains(asciiAway, "o desk") { + t.Fatalf("ASCII glyph repertoire not honored: self=%q away=%q", asciiSelf, asciiAway) + } + + // Directory change triggers refreshOnChange and updates the open tab. + door.mu.Lock() + door.devices[1].Revoked = true + door.mu.Unlock() + a.dirFeed = &stillFeed{online: []string{"dev_B"}, version: 2} + r.feed(dirWatchMsg{from: a.dirFeed}) + r.until("directory refresh", func() bool { + shown := plain(frame(a)) + return strings.Contains(shown, "○ tower") && strings.Contains(shown, "removed") + }) + if strings.Contains(plain(frame(a)), "● tower") { + t.Fatalf("revoked device kept online dot after refresh:\n%s", plain(frame(a))) + } +} + +// Validation contract item 3: the tab has an `Add another device` row sharing +// the single person-facing constant, and pressing `enter` on it leaves +// `/settings` and starts bare `/pair` (`runPair("")`). +func TestSettingsDevicesAddRowLeavesSettingsAndStartsBarePair(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + door := devicesFake() + var shown *pair.Code + pairing := &fakePairing{ + offer: func(ctx context.Context, ui pair.OfferUI) (string, error) { + code, err := pair.NewCode(now) + if err != nil { + return "", err + } + code = code.WithPlate("42") + shown = code + ui.Show(code, pair.ChatLines(code, "")) + <-ctx.Done() + return "", ctx.Err() + }, + } + + a, r := approveApp(t, door) + a.pairing = pairing + a.clock = func() time.Time { return now } + a.openSettings() + walkToDevicesTab(t, a, r) + r.until("loaded rows", func() bool { return a.sheet.dev != nil && a.sheet.dev.loaded }) + + for guard := 0; guard < len(a.sheet.items) && !a.sheet.onAddDeviceRow(); guard++ { + r.press("down") + } + if !a.sheet.onAddDeviceRow() { + t.Fatal("down arrows did not reach Add another device") + } + + r.press("enter") + if a.at(pageSettings) { + t.Fatal("enter on Add another device did not close /settings") + } + r.until("the pairing code", func() bool { return a.pair.open && a.pair.code != nil }) + if shown == nil || !strings.Contains(plain(frame(a)), "codeaf pair "+shown.Shown()) { + t.Fatalf("bare /pair screen did not open:\n%s", plain(frame(a))) + } +} + +// Validation contract item 4: `r` calls `Revoke(ctx, id)`, marks the row +// `removed`, and puts `devicesRevoked` with the name on the foot line; on this +// device's own row `r` shows `devicesOwnRow` and calls nothing; on the add row +// or an already-removed row `r` does nothing. +func TestSettingsDevicesRKeyRemovesAnotherRowRefusesOwnRowAndIgnoresAddAndRemovedRows(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + door := &fakeApprovals{devices: []DeviceRow{ + {ID: "dev_A", Name: "this mac", Platform: "darwin", Self: true}, + {ID: "dev_B", Name: "tower", Platform: "linux"}, + }} + a, r := openSettingsDevices(t, door, now) + + // Own row: shows devicesOwnRow on the foot line and calls nothing. + r.press("r") + if door.did() != "" { + t.Fatalf("r on own row called door: %s", door.did()) + } + foot := plain(strings.Join((placeSettings{}).note(a, a.width), " ")) + if !strings.Contains(foot, devicesOwnRow) || !strings.Contains(plain(frame(a)), devicesOwnRow) { + t.Fatalf("r on own row did not show %q on foot line (foot=%q):\n%s", devicesOwnRow, foot, plain(frame(a))) + } + + // Add row: r does nothing. + r.press("down", "down") + if !a.sheet.onAddDeviceRow() { + t.Fatal("expected cursor on add row") + } + a.sheet.dev.line = "" + r.press("r") + if door.did() != "" || a.sheet.dev.line != "" { + t.Fatalf("r on add row acted: door=%q line=%q", door.did(), a.sheet.dev.line) + } + + // Removable row: revokes, marks removed, and puts devicesRevoked on the foot line. + r.press("up", "r") + wantRevoked := fmt.Sprintf(devicesRevoked, "tower") + r.until("revoked outcome", func() bool { + return strings.Contains(plain(frame(a)), wantRevoked) + }) + if door.did() != "revoke:dev_B" { + t.Fatalf("door calls = %q, want revoke:dev_B", door.did()) + } + foot = plain(strings.Join((placeSettings{}).note(a, a.width), " ")) + if !strings.Contains(foot, wantRevoked) { + t.Fatalf("foot line = %q, want %q", foot, wantRevoked) + } + shown := plain(frame(a)) + if !strings.Contains(shown, "○ tower") || !strings.Contains(shown, "removed") { + t.Fatalf("row not marked removed:\n%s", shown) + } + + // Already-removed row: a second r does nothing. + r.press("r") + if door.did() != "revoke:dev_B" { + t.Fatalf("r on removed row called door again: %s", door.did()) + } +} + +// Validation contract item 5: `n` renames this device through `startNaming`, +// `typed` and `rename`; while typing, `esc` cancels typing and does NOT close +// `/settings`, and only a second `esc` with no typing in progress leaves +// `/settings`. +func TestSettingsDevicesNKeyRenamesThisDeviceAndEscCancelsTypingWithoutClosingSettings(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + door := devicesFake() + a, r := openSettingsDevices(t, door, now) + + r.press("n") + if !a.sheet.devTyping() { + t.Fatal("n on own row did not start naming") + } + lines, _, caretX, caretY, up := a.placeFrameNow(a.width, a.height) + if !up { + t.Fatal("settings place not up") + } + promptRow := -1 + for i, line := range lines { + if strings.Contains(plain(line), devicesNameAsk+"this mac") { + promptRow = i + break + } + } + if promptRow < 0 { + t.Fatalf("prefilled rename field missing:\n%s", plain(strings.Join(lines, "\n"))) + } + if caretY != promptRow || !strings.HasSuffix(ansi.Truncate(plain(lines[caretY]), caretX, ""), "this mac") { + t.Fatalf("caret at (%d,%d), want end of prompt row %d", caretX, caretY, promptRow) + } + + // Letters that are otherwise commands (`r`, `n`) type into the name, and esc + // cancels only the typing without closing /settings. + r.press("ctrl+u") + r.typeText("rnd") + if door.did() != "" { + t.Fatalf("typing into rename field called door: %s", door.did()) + } + r.press("esc") + if a.sheet.devTyping() { + t.Fatal("first esc did not cancel naming") + } + if !a.at(pageSettings) || !a.sheet.onDevices() { + t.Fatal("first esc closed /settings instead of only cancelling the rename") + } + if door.did() != "" { + t.Fatalf("cancelling rename wrote to door: %s", door.did()) + } + + // Saving a new name goes through Rename and refreshes the row. + r.press("n", "ctrl+u") + r.typeText("studio mac") + r.press("enter") + r.until("renamed row", func() bool { + return !a.sheet.devTyping() && strings.Contains(plain(frame(a)), "● studio mac") + }) + if door.did() != "rename:studio mac" { + t.Fatalf("door = %q, want rename:studio mac", door.did()) + } + + // With no typing in progress, esc leaves /settings. + r.press("esc") + if a.at(pageSettings) { + t.Fatal("esc at rest on Devices did not close /settings") + } +} + +// Validation contract item 6: the keys line uses `devicesNameKey`, +// `devicesGoneKey`, `devicesMoveKey` and `devicesNameKeys`, shrinking so +// `r remove` appears only on a removable row, `↑↓ choose` only when more than +// one row can be walked, and `enter add` on the add row. +func TestSettingsDevicesKeysLineShrinksToWhatWorksOnTheCurrentRow(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + + // Single selectable row (empty directory + Add another device): no ↑↓ choose, + // enter add present, no r remove. + single, _ := openSettingsDevices(t, &fakeApprovals{}, now) + keys := single.sheet.keysLine() + if strings.Contains(keys, devicesMoveKey) { + t.Fatalf("single-row Devices tab offered %q: %q", devicesMoveKey, keys) + } + if !strings.Contains(keys, devicesAddKey) || strings.Contains(keys, devicesGoneKey) { + t.Fatalf("single add row keys wrong: %q", keys) + } + + // Multi-row tab: self, removable, and already-removed rows (orderDevices + // sorts offline computers alphabetically, so "old" precedes "tower"). + door := &fakeApprovals{devices: []DeviceRow{ + {ID: "dev_A", Name: "this mac", Platform: "darwin", Self: true}, + {ID: "dev_B", Name: "tower", Platform: "linux"}, + {ID: "dev_C", Name: "old", Platform: "linux", Revoked: true}, + }} + a, r := openSettingsDevices(t, door, now) + + // Row 0 (Self): ↑↓ choose + n rename, no r remove. + keys = a.sheet.keysLine() + if !strings.Contains(keys, devicesMoveKey) || !strings.Contains(keys, devicesNameKey) || strings.Contains(keys, devicesGoneKey) { + t.Fatalf("own row keys = %q", keys) + } + + // Typing a name: devicesNameKeys. + r.press("n") + if got := a.sheet.keysLine(); got != devicesNameKeys { + t.Fatalf("typing keys = %q, want %q", got, devicesNameKeys) + } + r.press("esc") + + // Row 1 ("old", already removed): ↑↓ choose + n rename, no r remove. + r.press("down") + keys = a.sheet.keysLine() + if !strings.Contains(keys, devicesMoveKey) || !strings.Contains(keys, devicesNameKey) || strings.Contains(keys, devicesGoneKey) { + t.Fatalf("removed row keys = %q", keys) + } + + // Row 2 ("tower", removable): ↑↓ choose + n rename + r remove. + r.press("down") + keys = a.sheet.keysLine() + if !strings.Contains(keys, devicesMoveKey) || !strings.Contains(keys, devicesNameKey) || !strings.Contains(keys, devicesGoneKey) { + t.Fatalf("removable row keys = %q", keys) + } + + // Row 3 (Add another device): ↑↓ choose + enter add, no r remove. + r.press("down") + keys = a.sheet.keysLine() + if !strings.Contains(keys, devicesMoveKey) || !strings.Contains(keys, devicesAddKey) || strings.Contains(keys, devicesGoneKey) { + t.Fatalf("add row keys = %q", keys) + } +} + +// Validation contract item 7: when devices cannot work here, the tab shows the +// same single sentence `/devices` would say — `cellsUnavailable` (including on +// platforms where `commandOnPlatform` hides `/devices`), `pairUnavailableWord` +// with a nil approvals door, or the card's own error wording on a failed list +// read — and never shows an empty list, `0 devices`, or a working-looking add +// row. +func TestSettingsDevicesUnavailableAndFailedReadSayWhyAndNothingElse(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + + t.Run("nil approvals door", func(t *testing.T) { + a, _ := openSettingsDevices(t, nil, now) + got := plain(frame(a)) + if !strings.Contains(got, pairUnavailableWord) { + t.Fatalf("nil door lacked %q:\n%s", pairUnavailableWord, got) + } + if strings.Contains(got, addDeviceWord) || strings.Contains(got, "0 devices") { + t.Fatalf("nil door drew forbidden list or add row:\n%s", got) + } + }) + + t.Run("cells unavailable reason", func(t *testing.T) { + reason := furrowbin.CellsUnavailable + a, r := approveApp(t, devicesFake()) + a.cellsUnavailable = reason + a.clock = func() time.Time { return now } + // Confirm a typed /devices says this exact sentence. + a.slash("/devices") + if !strings.Contains(updateNotes(a), reason) { + t.Fatalf("typed /devices did not say %q: %q", reason, updateNotes(a)) + } + a.openSettings() + walkToDevicesTab(t, a, r) + got := plain(frame(a)) + if !strings.Contains(got, reason) { + t.Fatalf("tab lacked cells-unavailable sentence %q:\n%s", reason, got) + } + if strings.Contains(got, addDeviceWord) || strings.Contains(got, "this mac") { + t.Fatalf("unavailable tab drew rows or add door:\n%s", got) + } + }) + + t.Run("failed list read", func(t *testing.T) { + readErr := errors.New("the device directory did not answer") + a, _ := openSettingsDevices(t, &fakeApprovals{err: readErr}, now) + got := plain(frame(a)) + if !strings.Contains(got, readErr.Error()) { + t.Fatalf("failed read lacked card error %q:\n%s", readErr.Error(), got) + } + if strings.Contains(got, addDeviceWord) { + t.Fatalf("failed read still offered %q:\n%s", addDeviceWord, got) + } + }) +} + +// Validation contract item 8: typing `devices`, `remove`, `revoke`, `pair` or +// `add device` into the `/settings` search finds the `Devices` tab. +func TestSettingsDevicesSearchFindsTheTabAcrossQueries(t *testing.T) { + queries := []struct { + query string + top bool + }{ + {"devices", true}, + {"remove", false}, + {"revoke", true}, + {"pair", true}, + {"add device", true}, + } + + for _, tc := range queries { + t.Run(tc.query, func(t *testing.T) { + a, r := approveApp(t, devicesFake()) + a.openSettings() + r.press("ctrl+u") + r.typeText(tc.query) + if tc.top && !a.sheet.onDevices() { + t.Fatalf("query %q landed on %s, want %s", tc.query, settingTabs[a.sheet.tab], tabDevices) + } + var hasHeading, hasRow bool + for _, item := range a.sheet.items { + if item.head == tabDevices { + hasHeading = true + } + if item.dev != nil { + hasRow = true + } + } + if !hasHeading || !hasRow { + t.Fatalf("query %q missing %s results (heading=%t row=%t)", tc.query, tabDevices, hasHeading, hasRow) + } + if shown := plain(frame(a)); !strings.Contains(shown, addDeviceWord) { + t.Fatalf("query %q did not draw %q on screen:\n%s", tc.query, addDeviceWord, shown) + } + }) + } +} diff --git a/internal/tui3/settings_organization_test.go b/internal/tui3/settings_organization_test.go index ae75a937f7..89d52e0e0c 100644 --- a/internal/tui3/settings_organization_test.go +++ b/internal/tui3/settings_organization_test.go @@ -13,7 +13,7 @@ func TestSettingsOrganizationCategoriesAndAdvancedSearch(t *testing.T) { if settingTabs[a.sheet.tab] != tabGeneral { t.Fatal("settings must open on General") } - want := "General,Models,Memory,Tasks,AI teams,Permissions,Spending,Connections,Privacy" + want := "General,Models,Memory,Tasks,AI teams,Permissions,Spending,Connections,Devices,Privacy" if strings.Join(settingTabs, ",") != want { t.Fatalf("categories: %v", settingTabs) } diff --git a/internal/tui3/settingspend_test.go b/internal/tui3/settingspend_test.go index 6ea6ca3725..6558a48c53 100644 --- a/internal/tui3/settingspend_test.go +++ b/internal/tui3/settingspend_test.go @@ -116,7 +116,7 @@ func TestSafetyAndTasksHoldWhatSpendingLetGoAndWorkspaceNamesNoMoney(t *testing. } } // AND THE BAR READS IN THE DESIGN'S ORDER. - want2 := []string{tabGeneral, tabProviders, tabMemory, tabTasks, tabTeams, tabSafety, tabSpending, tabConnections, tabPrivacy} + want2 := []string{tabGeneral, tabProviders, tabMemory, tabTasks, tabTeams, tabSafety, tabSpending, tabConnections, tabDevices, tabPrivacy} if strings.Join(settingTabs, " ") != strings.Join(want2, " ") { t.Fatalf("the tab bar reads %v", settingTabs) } From 00c90590cc4d545fcb2f5ab8b6142da68e7c1dd4 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 10:35:15 -0400 Subject: [PATCH 2/3] docs(changes): entry for #2074 Co-Authored-By: Claude Opus 5.5 --- .../unreleased/2074-settings-devices-tab.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/changes/unreleased/2074-settings-devices-tab.md diff --git a/docs/changes/unreleased/2074-settings-devices-tab.md b/docs/changes/unreleased/2074-settings-devices-tab.md new file mode 100644 index 0000000000..e4862cac6e --- /dev/null +++ b/docs/changes/unreleased/2074-settings-devices-tab.md @@ -0,0 +1,20 @@ +--- +kind: added +title: /settings has a Devices tab to add, rename and remove your computers +pr: 2074 +surface: [chat] +invalidates: + - >- + Adding and removing computers in the chat was reachable only through + /devices, /pair and the home card, and /settings had nine tabs ending + Connections, Privacy. /settings now has a Devices tab between Connections + and Privacy that lists the same devices as /devices, with r to remove, n to + rename this device, and an "Add another device" row that starts /pair. + - >- + The online dot on /devices was a literal ● in every terminal. It now comes + through the palette's glyph door, so an ASCII terminal draws # beside o. +--- + +The tab hosts the same device list /devices opens rather than a second one, so +the two cannot disagree. Where pairing cannot work here, it says the same one +sentence /devices says instead of showing an empty list. From a44efc89ddcb9b3026a1ebea05354e158e7128cd Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 11:08:07 -0400 Subject: [PATCH 3/3] docs(manual): say what the Devices tab does with sync off Hand-testing on a home with CODEAF_SYNC_URL=off showed the tab lists no computers and still offers Add another device, whose enter answers the same sync-off refusal /pair gives. The section claimed only "one sentence and no rows" for unusable states; it now says exactly what sync off does, and the unusable-state sentence is tightened to keep the section standalone-short. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/add-or-remove-a-computer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index ec8f372901..12b8e75511 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -176,7 +176,7 @@ The key line shrinks to what works on the row (`↑↓ choose` when there is mor - `n rename` on `this device` opens `name this device: ` under the row with `enter save · ctrl+u clear · esc cancel`. While typing, `esc` cancels the edit without closing `/settings`; a second `esc` leaves `/settings`. On another row `n` says `a device renames itself — choose this device's row, or open /devices on the one you mean.` - `r remove` (called `codeaf devices revoke ` on the CLI) removes the computer under the cursor at once, marks its row `removed`, and says `desk was removed — it can no longer reach your chats.` On your own row `r` says `this device cannot remove itself — choose another row.` On `Add another device` or a `removed` row, `r` does nothing. -When devices cannot work here, the tab shows one sentence and no list or `Add another device` row: `pairing is not available here` without a pairing door (such as `--host` or `--at`), `moving chats needs a newer furrow engine than this build carries` without a compatible engine, or the read error if loading fails. +Where devices cannot work, the tab shows one sentence and no rows: `pairing is not available here` (a chat over `--host` or `--at`), `moving chats needs a newer furrow engine than this build carries`, or the read error. With `CODEAF_SYNC_URL=off` it lists no computers, and `enter add` answers `sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address`, as `/pair` does. ## A device was removed but refreshing the list failed