From 996b10d3eceb2f0f41536942ce0675fe77507976 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 00:52:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(claude):=20account=20login=20pane=20?= =?UTF-8?q?=E2=80=94=20host=20`claude=20login`=20in=20a=20modal=20terminal?= =?UTF-8?q?=20(T-485)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the `login` verb + the UI add/re-login affordances. The accountActionChannel 'login' action opens ClaudeLoginDialog — a modal hosting a TerminalPane that runs `CLAUDE_CONFIG_DIR= claude login`, so the CLI drives the OAuth browser flow and credentials land in that account's config dir (D-64: one CLI-initiated browser flow, on explicit action). TerminalPane gains optional argv/env/cwdOverride (default stays the login shell), and its pane.spawn carries env when set. The login pane reuses the TerminalPane *widget* — a code import, not a runtime dependency, since it spawns through the always-present pane.spawn IPC rather than the terminal extension. Tests cover the parameterized spawn args and the dialog's host wiring (title, CLAUDE_CONFIG_DIR, close). Closes T-485. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 2 + CHANGELOG.md | 4 + .../claude/src/account_login_dialog.dart | 94 +++++++++++++++++++ lib/builtin/claude/src/extension.dart | 16 +++- lib/builtin/terminal/src/terminal_pane.dart | 27 +++++- .../claude/account_login_dialog_test.dart | 43 +++++++++ test/builtin/terminal/terminal_pane_test.dart | 19 ++++ 8 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 lib/builtin/claude/src/account_login_dialog.dart create mode 100644 test/builtin/claude/account_login_dialog_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 39ebe9f1..68aca4e1 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -7197,3 +7197,5 @@ All actions go through T-480''s CLI verbs; this widget never writes the registry Done: the Claude pane account badge (ClaudeAccountBadge) in the pane chrome trailing slot — shows the bound account (or ''default''), colour-tinted per account via accountAccent (theme tokens, hash-indexed), tap opens the account picker; hidden when no accounts registered. The welcome-view accounts section is split to T-486 (welcome is builtin-decoupled; needs a contribution point to avoid welcome->claude peer coupling).', NULL, '2026-06-27 22:43:31', '2026-06-27 22:43:31.723', '2026-06-27 22:43:31.723', NULL, 'd3146ee6d4abe36714b1b160e94d3d3a', 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', 'in_progress', 'done', NULL, '2026-06-27 22:43:31', '2026-06-27 22:43:31.769', '2026-06-27 22:43:31.769', NULL, '34a8c26c8c71d27fdcb1e60e3ffa1ea0', 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 ('06FGN13H42TP2MP3T27CTSCDRW', 'description', NULL, 'Done: ClaudeLoginDialog hosts a parameterized TerminalPane running ''claude login'' with CLAUDE_CONFIG_DIR= in a modal (ctx.dialog.show), wired from the accountActionChannel ''login'' action. TerminalPane gained argv/env/cwdOverride params (default still the login shell). The CLI owns the OAuth browser flow (D-64). No runtime dep on builtin.terminal — the login pane reuses the TerminalPane widget, which spawns via the always-present pane.spawn IPC. Tests: parameterized spawn args + dialog host wiring.', NULL, '2026-06-27 22:52:31', '2026-06-27 22:52:31.121', '2026-06-27 22:52:31.121', NULL, '1e158c1dcb4bf9eafecb8c26cc4936d2', 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 ('06FGN13H42TP2MP3T27CTSCDRW', 'status', 'backlog', 'done', NULL, '2026-06-27 22:52:31', '2026-06-27 22:52:31.156', '2026-06-27 22:52:31.156', NULL, '620a851447b74c2553407fac431b1e18', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index f31869fc..66a18fbb 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -9157,3 +9157,5 @@ All actions go through T-480''s CLI verbs; this widget never writes the registry Done: the Claude pane account badge (ClaudeAccountBadge) in the pane chrome trailing slot — shows the bound account (or ''default''), colour-tinted per account via accountAccent (theme tokens, hash-indexed), tap opens the account picker; hidden when no accounts registered. The welcome-view accounts section is split to T-486 (welcome is builtin-decoupled; needs a contribution point to avoid welcome->claude peer coupling).', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 22:43:31.769', NULL, '065f5914ac6b963342be92e0ea65ab82', 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 ('06FGN13H42TP2MP3T27CTSCDRW', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'claude account login: spawn a CLAUDE_CONFIG_DIR= claude login terminal pane', 'Done: ClaudeLoginDialog hosts a parameterized TerminalPane running ''claude login'' with CLAUDE_CONFIG_DIR= in a modal (ctx.dialog.show), wired from the accountActionChannel ''login'' action. TerminalPane gained argv/env/cwdOverride params (default still the login shell). The CLI owns the OAuth browser flow (D-64). No runtime dep on builtin.terminal — the login pane reuses the TerminalPane widget, which spawns via the always-present pane.spawn IPC. Tests: parameterized spawn args + dialog host wiring.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-27 19:17:59.712', '2026-06-27 22:52:31.121', NULL, 'd52f43050d3e2b14eecd47aff90ea836', 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 ('06FGN13H42TP2MP3T27CTSCDRW', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'claude account login: spawn a CLAUDE_CONFIG_DIR= claude login terminal pane', 'Done: ClaudeLoginDialog hosts a parameterized TerminalPane running ''claude login'' with CLAUDE_CONFIG_DIR= in a modal (ctx.dialog.show), wired from the accountActionChannel ''login'' action. TerminalPane gained argv/env/cwdOverride params (default still the login shell). The CLI owns the OAuth browser flow (D-64). No runtime dep on builtin.terminal — the login pane reuses the TerminalPane widget, which spawns via the always-present pane.spawn IPC. Tests: parameterized spawn args + dialog host wiring.', 'done', 'medium', NULL, NULL, NULL, '2026-06-27 19:17:59.712', '2026-06-27 22:52:31.156', NULL, 'c0e79a87fa88a49a38e8b469b194a96a', 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 8ed52360..896d3e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Claude account login pane.** `account login` (and the UI add/re-login + affordances) open a modal terminal running `CLAUDE_CONFIG_DIR= claude + login`; the CLI owns the OAuth flow, credentials land in that account's dir. + (T-485, epic T-476) - **Claude pane account badge.** The pane header shows which account this repo is bound to (colour-tinted per account); tap to switch. Hidden when no accounts are registered. (T-481, epic T-476) diff --git a/lib/builtin/claude/src/account_login_dialog.dart b/lib/builtin/claude/src/account_login_dialog.dart new file mode 100644 index 00000000..ecfda5cc --- /dev/null +++ b/lib/builtin/claude/src/account_login_dialog.dart @@ -0,0 +1,94 @@ +/// Modal that hosts `CLAUDE_CONFIG_DIR= claude login` in a terminal pane +/// (T-485, epic T-476). clide writes no auth code: the Claude CLI owns the OAuth +/// browser flow, and clide just provides the TTY + the per-account config dir, +/// so the resulting credentials land in `` rather than the global +/// `~/.claude` (D-64 — one CLI-initiated browser flow, on explicit action, +/// nothing in the background). No-Material (D-7); shown via the DialogRouter. +library; + +import 'package:clide/builtin/terminal/src/terminal_pane.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class ClaudeLoginDialog extends StatelessWidget { + const ClaudeLoginDialog({super.key, required this.name, required this.dir, required this.onClose, this.cwd}); + + /// Account display name (for the title). + final String name; + + /// The account's `CLAUDE_CONFIG_DIR` — where `claude login` writes credentials. + final String dir; + + /// Working directory for the spawned `claude login` (defaults to the + /// workspace); irrelevant to auth, but keeps the pane oriented. + final String? cwd; + + final VoidCallback onClose; + + KeyEventResult _onKey(FocusNode node, KeyEvent e) { + if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) { + onClose(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final theme = ClideSettings.theme.of(context).surface; + return Focus( + autofocus: true, + onKeyEvent: _onKey, + child: Container( + width: 760, + decoration: BoxDecoration( + color: theme.panelBackground, + border: Border.all(color: theme.globalBorder), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 4), + child: Row( + children: [ + Expanded( + child: ClideText('Sign in: $name', fontSize: clideFontBody, color: theme.globalForeground), + ), + Semantics( + button: true, + label: 'Close', + excludeSemantics: true, + child: ClideTappable( + key: const Key('account-login-close'), + cursor: SystemMouseCursors.click, + onTap: onClose, + builder: (ctx, hovered, _) => + ClideIcon(PhosphorIcons.byName('x'), size: 14, color: hovered ? theme.globalForeground : theme.globalTextMuted), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: ClideText('Running `claude login` against $dir — finish the browser sign-in, then close.', muted: true, fontSize: clideFontSmall), + ), + // Fixed height — TerminalPane needs a bounded box; the dialog itself + // sizes to its content (mainAxisSize.min). + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: SizedBox( + height: 380, + child: TerminalPane(argv: const ['claude', 'login'], env: {'CLAUDE_CONFIG_DIR': dir}, cwdOverride: cwd), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 0768c3ae..7acf5541 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:clide/clide.dart'; import 'package:clide/builtin/claude/src/account_registry.dart'; +import 'package:clide/builtin/claude/src/account_login_dialog.dart'; import 'package:clide/builtin/claude/src/account_settings_control.dart'; import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel; import 'package:clide/builtin/claude/src/claude_config.dart'; @@ -47,6 +48,9 @@ class ClaudeExtension extends ClideExtension { @override String get version => '0.2.0'; @override + // No runtime dependency on builtin.terminal: the login pane (T-485) reuses the + // TerminalPane *widget* (a code import), which spawns via the always-present + // pane.spawn IPC — it doesn't need the terminal extension activated. List get dependsOn => const []; ClideExtensionContext? _ctx; @@ -542,9 +546,15 @@ class ClaudeExtension extends ClideExtension { case 'purge': final dir = m.data['dir'] as String?; if (dir != null) unawaited(_purgeAccountDir(dir)); - // 'login' would spawn a `CLAUDE_CONFIG_DIR= claude login` terminal - // pane; that needs argv+env pane support in the terminal builtin and is - // wired separately. The action is published for that consumer. + case 'login': + final name = m.data['name'] as String?; + final dir = m.data['dir'] as String?; + final ctx = _ctx; + // Host `CLAUDE_CONFIG_DIR= claude login` in a modal terminal pane + // (T-485); the CLI owns the OAuth browser flow. + if (name != null && dir != null && ctx != null) { + ctx.dialog.show((c, dismiss) => ClaudeLoginDialog(name: name, dir: dir, cwd: _projectRoot, onClose: dismiss)); + } } } diff --git a/lib/builtin/terminal/src/terminal_pane.dart b/lib/builtin/terminal/src/terminal_pane.dart index 8742c223..00d86818 100644 --- a/lib/builtin/terminal/src/terminal_pane.dart +++ b/lib/builtin/terminal/src/terminal_pane.dart @@ -18,7 +18,17 @@ import 'package:clide/src/terminal/terminal.dart'; /// job. The shared widget layer (ClidePtyView, ClidePaneChrome) keeps /// the two extensions visually consistent without coupling them. class TerminalPane extends StatefulWidget { - const TerminalPane({super.key}); + const TerminalPane({super.key, this.argv, this.env, this.cwdOverride}); + + /// Command to run instead of the login `$SHELL` — e.g. `['claude', 'login']` + /// for the per-repo account sign-in pane (T-485). Null spawns the shell. + final List? argv; + + /// Extra environment for the spawned process (e.g. `CLAUDE_CONFIG_DIR`). + final Map? env; + + /// Working directory override; defaults to the open workspace. + final String? cwdOverride; @override State createState() => _TerminalPaneState(); @@ -79,16 +89,23 @@ class _TerminalPaneState extends State { // Windows has no $SHELL convention and no login-shell flag — // PowerShell 7 first, classic PowerShell as the always-there - // fallback. + // fallback. A caller-supplied argv (e.g. `claude login`, T-485) wins. final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash'); - final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo']; + final argv = widget.argv ?? (shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo']); // The open workspace, not Directory.current — a desktop launch starts // in $HOME and a project switch doesn't move the process CWD (T-381). - final cwd = _kernel?.project.current?.path ?? Directory.current.path; + final cwd = widget.cwdOverride ?? _kernel?.project.current?.path ?? Directory.current.path; final response = await ipc.request( 'pane.spawn', - args: {'argv': argv, 'kind': PaneKind.terminal.wire, 'cwd': cwd, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight}, + args: { + 'argv': argv, + 'kind': PaneKind.terminal.wire, + 'cwd': cwd, + 'cols': _terminal.viewWidth, + 'rows': _terminal.viewHeight, + if (widget.env != null) 'env': widget.env, + }, ); if (!mounted) return; if (!response.ok) { diff --git a/test/builtin/claude/account_login_dialog_test.dart b/test/builtin/claude/account_login_dialog_test.dart new file mode 100644 index 00000000..b8ad77a3 --- /dev/null +++ b/test/builtin/claude/account_login_dialog_test.dart @@ -0,0 +1,43 @@ +/// T-485: the account login dialog hosts `claude login` in a terminal pane with +/// the account's CLAUDE_CONFIG_DIR, and its close affordance dismisses. The CLI +/// owns the OAuth flow; this verifies the host wiring (title, env, close). +library; + +import 'package:clide/builtin/claude/src/account_login_dialog.dart'; +import 'package:clide/builtin/terminal/src/terminal_pane.dart'; +import 'package:clide/clide.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture fixture; + setUp(() async => fixture = await KernelFixture.create()); + tearDown(() async => fixture.dispose()); + + testWidgets('renders the account title, spawns claude login with the config dir, and closes', (tester) async { + var closed = false; + Map? spawnArgs; + fixture.ipc.setConnected(true); + fixture.ipc.stub('pane.spawn', (args) async { + spawnArgs = args; + return IpcResponse.ok(id: 'r1', data: {'id': 'p1', 'pid': 1}); + }); + fixture.ipc.stub('pane.close', (args) async => IpcResponse.ok(id: 'r2')); + + await tester.pumpWidget(harness(fixture, ClaudeLoginDialog(name: 'work', dir: '/home/u/.claude-work', onClose: () => closed = true))); + await pumpAsync(tester); + + expect(find.text('Sign in: work'), findsOneWidget); + final pane = tester.widget(find.byType(TerminalPane)); + expect(pane.argv, ['claude', 'login']); + expect(pane.env, {'CLAUDE_CONFIG_DIR': '/home/u/.claude-work'}); + expect(spawnArgs?['env'], {'CLAUDE_CONFIG_DIR': '/home/u/.claude-work'}); + + await tester.tap(find.byKey(const Key('account-login-close'))); + await tester.pump(); + expect(closed, isTrue); + }); +} diff --git a/test/builtin/terminal/terminal_pane_test.dart b/test/builtin/terminal/terminal_pane_test.dart index caf9e484..2cda5cfc 100644 --- a/test/builtin/terminal/terminal_pane_test.dart +++ b/test/builtin/terminal/terminal_pane_test.dart @@ -75,6 +75,25 @@ void main() { expect(spawnedCwd, isNot(Directory.current.path)); }); + testWidgets('a caller-supplied argv + env + cwd reach pane.spawn (T-485)', (tester) async { + Map? spawnArgs; + fixture.ipc.setConnected(true); + fixture.ipc.stub('pane.spawn', (args) async { + spawnArgs = args; + return IpcResponse.ok(id: 'r1', data: {'id': 'pane-1', 'pid': 1}); + }); + fixture.ipc.stub('pane.close', (args) async => IpcResponse.ok(id: 'r2')); + + await tester.pumpWidget( + harness(fixture, const TerminalPane(argv: ['claude', 'login'], env: {'CLAUDE_CONFIG_DIR': '/home/u/.claude-work'}, cwdOverride: '/repo')), + ); + await pumpAsync(tester); + + expect(spawnArgs?['argv'], ['claude', 'login']); + expect(spawnArgs?['env'], {'CLAUDE_CONFIG_DIR': '/home/u/.claude-work'}); + expect(spawnArgs?['cwd'], '/repo'); + }); + testWidgets('disposing before spawn completes sends no close', (tester) async { final closed = []; fixture.ipc.setConnected(false); // spawn bails out: no pane id