diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql
index d72a3a58..0ce5e5cd 100644
--- a/.pql/changelog/ticket_history/2026-06.sql
+++ b/.pql/changelog/ticket_history/2026-06.sql
@@ -7025,3 +7025,82 @@ Dropdown/select listing registered accounts + a "(default)" option. Selecting is
Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).', NULL, '2026-06-27 21:46:33', '2026-06-27 21:46:33.584', '2026-06-27 21:46:33.584', NULL, '4f4f8d74d775750d79f054ba5821ce5a', 2) ON CONFLICT(hash) DO NOTHING;
+INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 21:47:58', '2026-06-27 21:47:58.084', '2026-06-27 21:47:58.084', NULL, '36e3db47395a6678771e97e108c2b88b', 2) ON CONFLICT(hash) DO NOTHING;
+INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WEE5N4PESF5G1G5JRHR', 'description', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category.
+
+## Rows
+
+### Global: "Claude > Accounts"
+
+Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `
/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance.
+
+### Per-workspace: "Claude > Account for this workspace"
+
+Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default).
+
+## Where
+
+- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host.
+- All actions go through T-480''s CLI verbs.
+- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually.
+
+## Acceptance
+
+1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation).
+2. Per-workspace row renders the current binding and lets the user switch / clear it.
+3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload.
+4. All actions issue T-480 verbs — no direct settings writes from this UI.
+5. Widget tests for the row renderers in each state.
+
+## Depends on
+
+- T-480 (CLI verbs) for the action layer.
+- T-477 (storage) for the schema registration.
+- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating.
+
+## Out of scope
+
+- The Claude pane chrome badge + welcome view — T-481.
+
+
+Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category.
+
+## Rows
+
+### Global: "Claude > Accounts"
+
+Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance.
+
+### Per-workspace: "Claude > Account for this workspace"
+
+Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default).
+
+## Where
+
+- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host.
+- All actions go through T-480''s CLI verbs.
+- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually.
+
+## Acceptance
+
+1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation).
+2. Per-workspace row renders the current binding and lets the user switch / clear it.
+3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload.
+4. All actions issue T-480 verbs — no direct settings writes from this UI.
+5. Widget tests for the row renderers in each state.
+
+## Depends on
+
+- T-480 (CLI verbs) for the action layer.
+- T-477 (storage) for the schema registration.
+- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating.
+
+## Out of scope
+
+- The Claude pane chrome badge + welcome view — T-481.
+
+
+Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).
+
+Slice 2 done: the global Accounts registry list control (ClaudeAccountsListControl, customId ''claude.accounts'') in the Claude > Account section. Per-account sign-in dot (accountIsSignedIn read-only probe), name, config dir, re-login + remove affordances (remove guarded while a workspace is bound, matching the CLI), and an inline add-account field that registers ~/.claude- + publishes login. Live via the settings notifier. Note: in-UI remove is registry-only; --purge dir deletion stays on the CLI flag. All three acceptance criteria met (list+manage, per-workspace, live).', NULL, '2026-06-27 22:35:36', '2026-06-27 22:35:36.216', '2026-06-27 22:35:36.216', NULL, 'fb876cec752b1ac391234569789ad282', 2) ON CONFLICT(hash) DO NOTHING;
+INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WEE5N4PESF5G1G5JRHR', 'status', 'in_progress', 'done', NULL, '2026-06-27 22:35:36', '2026-06-27 22:35:36.250', '2026-06-27 22:35:36.250', NULL, 'd6bed97cc8a298c7cab9ae5fb5b1ede1', 2) ON CONFLICT(hash) DO NOTHING;
diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql
index 44735351..c1945907 100644
--- a/.pql/changelog/tickets/2026-06.sql
+++ b/.pql/changelog/tickets/2026-06.sql
@@ -8936,3 +8936,128 @@ Dropdown/select listing registered accounts + a "(default)" option. Selecting is
Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 21:46:33.584', NULL, '31cf98ecc2a8e496cd9cb324fa75a404', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'story', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: UI — Claude pane badge + welcome view accounts section', 'The conversational/chrome UI surfaces for the multi-account epic (T-476). Settings-UI rows live in T-482; this ticket owns the always-visible affordances.
+
+## Claude pane chrome badge
+
+Small badge in the pane chrome (next to the existing status/banner row — see `lib/builtin/claude/src/claude_banner.dart` / status-bar pattern) showing the active account name, or "default" when unbound.
+
+- Tap opens a popover/picker: list of registered accounts (radio-selectable) + "Add account…" entry.
+- Selecting an account dispatches `clide claude account set `; "Add account…" runs the add+login flow.
+- The badge live-updates from the `claude.account` MessageBus channel published by T-480.
+- Colour-coded subtly (one accent per account name? Hash-derived?) so the user can spot at a glance which window is which account.
+
+## Welcome view: Claude accounts section
+
+New section in the welcome view (peer of the existing project / toolchain rows in `lib/builtin/welcome/src/welcome_view.dart`):
+
+- **Registered accounts** — one row per registered account: name, configDir path, a per-row affordance to bind this workspace / unbind / re-login.
+- **Detected (not yet registered)** — rows for the T-477 bootstrap probe results (existing `~/.claude-*/` dirs not in the registry yet). Each has a "Register this" affordance that dispatches `clide claude account add --dir `.
+- **Add new account** — button that runs the full add+login flow against a default `~/.claude-/` path.
+
+All actions go through T-480''s CLI verbs; this widget never writes the registry directly.
+
+## Constraints
+
+- Use the existing UI-design vocabulary (theme tokens, control geometry — see the `ui-design` skill). No Material/Cupertino.
+- The badge is display-with-affordance, not an inline interaction surface in the D-78 sense — opening the picker is a navigation gesture, the selection happens in the picker modal.
+- The welcome view follows the existing one-screen welcome-layout decisions (don''t add a fold-out / accordion if it conflicts with the current layout).
+
+## Acceptance
+
+1. The Claude pane badge shows the active account name (or "default"), updates live across `set`/`unset`/`add` actions.
+2. Tapping the badge opens a picker; selecting a registered account dispatches `clide claude account set` and respawns the pane on the new account.
+3. The welcome view''s accounts section lists registered accounts + detected candidates + an add-new affordance.
+4. Every action available in the UI is reachable as the corresponding `clide claude account` verb (D-6 parity verified by inspection).
+5. Widget tests cover badge rendering for each state (default / bound / unknown) + the picker action dispatch (mock the CLI client).
+6. A11y: badge has a semantic label naming the account; picker is keyboard-reachable.
+
+## Depends on
+
+- T-480 (CLI verbs). Wires every action to the dispatcher.
+
+## Out of scope
+
+- Settings-UI rows — T-482.
+- Project-picker integration (each project shows its bound account) — desirable, file as a follow-up if it grows complex.
+', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 21:47:58.084', NULL, '2ef8c7fb006f7bf2eb18d981a0126e24', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WEE5N4PESF5G1G5JRHR', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: settings UI rows (accounts registry + per-workspace binding)', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category.
+
+## Rows
+
+### Global: "Claude > Accounts"
+
+Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance.
+
+### Per-workspace: "Claude > Account for this workspace"
+
+Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default).
+
+## Where
+
+- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host.
+- All actions go through T-480''s CLI verbs.
+- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually.
+
+## Acceptance
+
+1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation).
+2. Per-workspace row renders the current binding and lets the user switch / clear it.
+3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload.
+4. All actions issue T-480 verbs — no direct settings writes from this UI.
+5. Widget tests for the row renderers in each state.
+
+## Depends on
+
+- T-480 (CLI verbs) for the action layer.
+- T-477 (storage) for the schema registration.
+- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating.
+
+## Out of scope
+
+- The Claude pane chrome badge + welcome view — T-481.
+
+
+Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).
+
+Slice 2 done: the global Accounts registry list control (ClaudeAccountsListControl, customId ''claude.accounts'') in the Claude > Account section. Per-account sign-in dot (accountIsSignedIn read-only probe), name, config dir, re-login + remove affordances (remove guarded while a workspace is bound, matching the CLI), and an inline add-account field that registers ~/.claude- + publishes login. Live via the settings notifier. Note: in-UI remove is registry-only; --purge dir deletion stays on the CLI flag. All three acceptance criteria met (list+manage, per-workspace, live).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 22:35:36.216', NULL, '5543c116e734c8d891dad897a844a727', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WEE5N4PESF5G1G5JRHR', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: settings UI rows (accounts registry + per-workspace binding)', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category.
+
+## Rows
+
+### Global: "Claude > Accounts"
+
+Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance.
+
+### Per-workspace: "Claude > Account for this workspace"
+
+Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default).
+
+## Where
+
+- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host.
+- All actions go through T-480''s CLI verbs.
+- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually.
+
+## Acceptance
+
+1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation).
+2. Per-workspace row renders the current binding and lets the user switch / clear it.
+3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload.
+4. All actions issue T-480 verbs — no direct settings writes from this UI.
+5. Widget tests for the row renderers in each state.
+
+## Depends on
+
+- T-480 (CLI verbs) for the action layer.
+- T-477 (storage) for the schema registration.
+- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating.
+
+## Out of scope
+
+- The Claude pane chrome badge + welcome view — T-481.
+
+
+Slice 1 done: the per-workspace account picker (acceptance #2) — a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).
+
+Slice 2 done: the global Accounts registry list control (ClaudeAccountsListControl, customId ''claude.accounts'') in the Claude > Account section. Per-account sign-in dot (accountIsSignedIn read-only probe), name, config dir, re-login + remove affordances (remove guarded while a workspace is bound, matching the CLI), and an inline add-account field that registers ~/.claude- + publishes login. Live via the settings notifier. Note: in-UI remove is registry-only; --purge dir deletion stays on the CLI flag. All three acceptance criteria met (list+manage, per-workspace, live).', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 22:35:36.250', NULL, '9ed2e68e4ff8955cf57a3ce2ef27a8a9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed9a7342..ecb3b744 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,9 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
-- **Settings → Claude → Account picker.** A per-workspace dropdown binds this
- repo to a registered Claude account (or Default); switching respawns the pane
- onto it. (T-482, epic T-476)
+- **Settings → Claude → Accounts.** A registry list (sign-in status, dir,
+ re-login / remove, add) plus a per-workspace picker that binds this repo to an
+ account (or Default); switching respawns the pane onto it. (T-482, epic T-476)
- **Per-repo Claude accounts — `clide claude account` verbs.** Manage named
Claude config dirs and bind one per workspace: `add`/`list`/`set`/`unset`/
`remove [--purge]`. `set`/`unset` respawn the workspace's Claude pane(s) onto
diff --git a/assets/i18n/en_us/builtin.claude.json b/assets/i18n/en_us/builtin.claude.json
index f9d2a10d..a49929d4 100644
--- a/assets/i18n/en_us/builtin.claude.json
+++ b/assets/i18n/en_us/builtin.claude.json
@@ -239,6 +239,8 @@
"settings.claude.permissionMode.label": { "translation": "Permission mode" },
"settings.claude.permissionMode.help": { "translation": "Starting permission mode for new sessions." },
"settings.claude.account.label": { "translation": "Account" },
+ "settings.claude.account.registry.label": { "translation": "Accounts" },
+ "settings.claude.account.registry.help": { "translation": "Registered Claude accounts (each a separate config dir + login)." },
"settings.claude.account.workspace.label": { "translation": "Account for this workspace" },
"settings.claude.account.workspace.help": { "translation": "Which Claude account this repo runs under; Default uses the system login." }
}
diff --git a/assets/i18n/nl_nl/builtin.claude.json b/assets/i18n/nl_nl/builtin.claude.json
index 6f75cef6..1084fd9a 100644
--- a/assets/i18n/nl_nl/builtin.claude.json
+++ b/assets/i18n/nl_nl/builtin.claude.json
@@ -239,6 +239,8 @@
"settings.claude.permissionMode.label": { "translation": "Permissiemodus" },
"settings.claude.permissionMode.help": { "translation": "Begin-permissiemodus voor nieuwe sessies." },
"settings.claude.account.label": { "translation": "Account" },
+ "settings.claude.account.registry.label": { "translation": "Accounts" },
+ "settings.claude.account.registry.help": { "translation": "Geregistreerde Claude-accounts (elk een eigen configmap + login)." },
"settings.claude.account.workspace.label": { "translation": "Account voor deze werkmap" },
"settings.claude.account.workspace.help": { "translation": "Welk Claude-account deze repo gebruikt; Standaard gebruikt de systeemlogin." }
}
diff --git a/lib/builtin/claude/src/account_registry.dart b/lib/builtin/claude/src/account_registry.dart
index 1e5745db..9ecd50a0 100644
--- a/lib/builtin/claude/src/account_registry.dart
+++ b/lib/builtin/claude/src/account_registry.dart
@@ -153,6 +153,22 @@ class AccountRegistry {
}
}
+/// Best-effort, read-only check of whether an account config dir holds live
+/// credentials (T-482) — for the "signed in / not signed in" indicator. True
+/// when the dir has a `.credentials.json` (Linux/Windows) or its `.claude.json`
+/// carries an `oauthAccount` marker. Never mutates; under-reports on macOS,
+/// where Claude Code keeps credentials in the system keychain rather than a file.
+bool accountIsSignedIn(String dir) {
+ if (File('$dir/.credentials.json').existsSync()) return true;
+ final cfg = File('$dir/.claude.json');
+ if (!cfg.existsSync()) return false;
+ try {
+ return cfg.readAsStringSync().contains('"oauthAccount"');
+ } catch (_) {
+ return false;
+ }
+}
+
/// Whether [dir] is safe to `rm -rf` as a purged account config dir
/// (`remove --purge`, T-480): it must be a `~/.claude-*` directory that is a
/// DIRECT child of [home]. Anything else — an absolute path elsewhere, a nested
diff --git a/lib/builtin/claude/src/account_settings_control.dart b/lib/builtin/claude/src/account_settings_control.dart
index c6c1328d..dcc39716 100644
--- a/lib/builtin/claude/src/account_settings_control.dart
+++ b/lib/builtin/claude/src/account_settings_control.dart
@@ -1,3 +1,5 @@
+import 'dart:io' show Platform;
+
import 'package:clide/builtin/claude/src/account_registry.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel;
@@ -132,3 +134,178 @@ class _ClaudeWorkspaceAccountControlState extends State createState() => _ClaudeAccountsListControlState();
+}
+
+class _ClaudeAccountsListControlState extends State {
+ final TextEditingController _name = TextEditingController();
+ final FocusNode _focus = FocusNode(debugLabel: 'add-account');
+ SettingsStore? _settings;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ final settings = ClideKernel.maybeOf(context)?.settings;
+ if (identical(settings, _settings)) return;
+ _settings?.removeListener(_onChange);
+ _settings = settings;
+ _settings?.addListener(_onChange);
+ }
+
+ void _onChange() {
+ if (mounted) setState(() {});
+ }
+
+ @override
+ void dispose() {
+ _settings?.removeListener(_onChange);
+ _name.dispose();
+ _focus.dispose();
+ super.dispose();
+ }
+
+ Future _add(KernelServices services) async {
+ final name = _name.text.trim();
+ if (name.isEmpty) return;
+ final reg = AccountRegistry(services.settings);
+ if (reg.accountByName(name) != null) {
+ _name.clear();
+ return; // idempotent — already registered
+ }
+ final dir = '${Platform.environment['HOME'] ?? ''}/.claude-$name';
+ _name.clear();
+ final write = reg.registerAccount(name, dir);
+ // Kick off the login flow for the new account (T-485 consumer opens it).
+ services.messages.publish('ui', accountActionChannel, {'action': 'login', 'name': name, 'dir': dir});
+ await write;
+ }
+
+ void _relogin(KernelServices services, Account a) {
+ services.messages.publish('ui', accountActionChannel, {'action': 'login', 'name': a.name, 'dir': a.dir});
+ }
+
+ Future _remove(KernelServices services, String name) => AccountRegistry(services.settings).removeAccount(name);
+
+ @override
+ Widget build(BuildContext context) {
+ final tokens = ClideSettings.theme.of(context).surface;
+ final services = ClideKernel.maybeOf(context);
+ if (services == null) return const SizedBox.shrink();
+ final reg = AccountRegistry(services.settings);
+ final accounts = reg.accounts;
+ final bound = reg.boundAccountNames();
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (accounts.isEmpty)
+ ClideText('No accounts registered yet.', fontSize: clideFontCaption, color: tokens.globalTextMuted)
+ else
+ for (final a in accounts) _row(context, services, tokens, a, bound.contains(a.name)),
+ const SizedBox(height: 10),
+ _addRow(context, services, tokens),
+ ],
+ );
+ }
+
+ Widget _row(BuildContext context, KernelServices services, SurfaceTokens tokens, Account a, bool isBound) {
+ final signedIn = accountIsSignedIn(a.dir);
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: Row(
+ children: [
+ Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(shape: BoxShape.circle, color: signedIn ? tokens.statusSuccess : tokens.globalTextMuted),
+ ),
+ const SizedBox(width: 8),
+ ClideText(a.name, color: tokens.globalForeground),
+ const SizedBox(width: 10),
+ Expanded(
+ child: ClideText(a.dir, fontSize: clideFontCaption, muted: true, fontFamily: ClideSettings.fonts.monoOf(context), overflow: TextOverflow.ellipsis),
+ ),
+ const SizedBox(width: 8),
+ _iconButton(context, 'sign-in', signedIn ? 'Re-sign in to ${a.name}' : 'Sign in to ${a.name}', () => _relogin(services, a)),
+ const SizedBox(width: 6),
+ _iconButton(
+ context,
+ 'trash',
+ isBound ? '${a.name} is bound to a workspace — unset it first' : 'Remove ${a.name}',
+ isBound ? null : () => _remove(services, a.name),
+ color: isBound ? tokens.globalTextMuted : tokens.statusError,
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _iconButton(BuildContext context, String icon, String semantic, VoidCallback? onTap, {Color? color}) {
+ final tokens = ClideSettings.theme.of(context).surface;
+ return Semantics(
+ button: true,
+ enabled: onTap != null,
+ label: semantic,
+ excludeSemantics: true,
+ child: ClideTappable(
+ cursor: onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
+ onTap: onTap,
+ builder: (ctx, hovered, _) =>
+ ClideIcon(PhosphorIcons.byName(icon), size: 14, color: color ?? (hovered ? tokens.globalForeground : tokens.globalTextMuted)),
+ ),
+ );
+ }
+
+ Widget _addRow(BuildContext context, KernelServices services, SurfaceTokens tokens) {
+ return Row(
+ children: [
+ Expanded(
+ child: Container(
+ height: 26,
+ alignment: Alignment.centerLeft,
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ decoration: BoxDecoration(
+ color: tokens.panelBackground,
+ border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: EditableText(
+ controller: _name,
+ focusNode: _focus,
+ style: TextStyle(fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontMono, color: tokens.globalForeground),
+ cursorColor: tokens.globalFocus,
+ backgroundCursorColor: tokens.globalTextMuted,
+ maxLines: 1,
+ onSubmitted: (_) => _add(services),
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Semantics(
+ button: true,
+ label: 'Add account',
+ excludeSemantics: true,
+ child: ClideTappable(
+ cursor: SystemMouseCursors.click,
+ onTap: () => _add(services),
+ builder: (ctx, hovered, _) => Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
+ decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : tokens.buttonBackground, borderRadius: BorderRadius.circular(4)),
+ child: ClideText('Add account', color: tokens.buttonForeground, fontSize: clideFontCaption),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart
index 061c81a2..0768c3ae 100644
--- a/lib/builtin/claude/src/extension.dart
+++ b/lib/builtin/claude/src/extension.dart
@@ -207,9 +207,18 @@ class ClaudeExtension extends ClideExtension {
labelKey: 'settings.claude.account.label',
fields: [
SettingsField(
- // Placeholder key — a custom field is rendered by its control,
+ // Placeholder keys — custom fields are rendered by their control,
// never stored here; kept clear of the app.claude.account.
// binding namespace the registry scans (T-480).
+ key: 'app.claude.accountsRegistry',
+ kind: SettingsFieldKind.custom,
+ label: 'Accounts',
+ labelKey: 'settings.claude.account.registry.label',
+ help: 'Registered Claude accounts (each a separate config dir + login).',
+ helpKey: 'settings.claude.account.registry.help',
+ customId: 'claude.accounts',
+ ),
+ SettingsField(
key: 'app.claude.workspaceAccount',
kind: SettingsFieldKind.custom,
label: 'Account for this workspace',
@@ -223,6 +232,7 @@ class ClaudeExtension extends ClideExtension {
],
),
),
+ SettingsControlContribution(id: 'claude.accounts', customId: 'claude.accounts', builder: (_) => const ClaudeAccountsListControl()),
SettingsControlContribution(id: 'claude.workspace-account', customId: 'claude.workspace-account', builder: (_) => const ClaudeWorkspaceAccountControl()),
// T-171: agent roster controls (D-6 CLI/UI parity).
// Usage: clide claude.agent.show
diff --git a/test/builtin/claude/account_registry_test.dart b/test/builtin/claude/account_registry_test.dart
index a598a847..752ccbb5 100644
--- a/test/builtin/claude/account_registry_test.dart
+++ b/test/builtin/claude/account_registry_test.dart
@@ -138,6 +138,31 @@ void main() {
});
});
+ group('sign-in probe (T-482)', () {
+ test('true with a .credentials.json or an oauthAccount marker; false otherwise', () async {
+ final home = await Directory.systemTemp.createTemp('clide-signin-');
+ addTearDown(() => home.deleteSync(recursive: true));
+
+ // .credentials.json present → signed in.
+ final a = Directory('${home.path}/.claude-a')..createSync();
+ File('${a.path}/.credentials.json').writeAsStringSync('{}');
+ expect(accountIsSignedIn(a.path), isTrue);
+
+ // .claude.json with an oauthAccount marker → signed in.
+ final b = Directory('${home.path}/.claude-b')..createSync();
+ File('${b.path}/.claude.json').writeAsStringSync('{"oauthAccount": {"email": "x@y.z"}}');
+ expect(accountIsSignedIn(b.path), isTrue);
+
+ // .claude.json without the marker → not signed in.
+ final c = Directory('${home.path}/.claude-c')..createSync();
+ File('${c.path}/.claude.json').writeAsStringSync('{"theme": "dark"}');
+ expect(accountIsSignedIn(c.path), isFalse);
+
+ // No config dir at all → not signed in.
+ expect(accountIsSignedIn('${home.path}/.claude-missing'), isFalse);
+ });
+ });
+
group('purge guard (T-480)', () {
const home = '/home/u';
test('accepts ~/.claude-* direct children only, rejects everything else', () {
diff --git a/test/builtin/claude/account_settings_control_test.dart b/test/builtin/claude/account_settings_control_test.dart
index ebbeab75..b3653697 100644
--- a/test/builtin/claude/account_settings_control_test.dart
+++ b/test/builtin/claude/account_settings_control_test.dart
@@ -95,4 +95,64 @@ void main() {
expect(published.single['action'], 'unset');
expect(published.single['previous'], 'work');
});
+
+ // The global registry list control (T-482 part 2).
+ Future pumpList(WidgetTester tester) => tester.pumpWidget(
+ harness(
+ f,
+ const Align(
+ alignment: Alignment.center,
+ child: SizedBox(width: 460, child: ClaudeAccountsListControl()),
+ ),
+ ),
+ );
+
+ testWidgets('registry list: empty shows a hint + the add row', (tester) async {
+ await pumpList(tester);
+ await tester.pump();
+ expect(find.textContaining('No accounts registered'), findsOneWidget);
+ expect(find.text('Add account'), findsOneWidget);
+ });
+
+ testWidgets('registry list: renders each account name + dir', (tester) async {
+ final reg = AccountRegistry(f.services.settings);
+ await tester.runAsync(() => reg.registerAccount('work', '/home/u/.claude-work'));
+ await pumpList(tester);
+ await tester.pump();
+ expect(find.text('work'), findsOneWidget);
+ expect(find.text('/home/u/.claude-work'), findsOneWidget);
+ });
+
+ testWidgets('registry list: typing a name + Add registers it and publishes login', (tester) async {
+ final reg = AccountRegistry(f.services.settings);
+ final published =