feat(claude): account login pane — host claude login in a modal terminal (T-485)

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=<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) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 00:52:41 +02:00
co-authored by Claude Opus 4.8
parent 222d4ae5e3
commit 996b10d3ec
8 changed files with 199 additions and 8 deletions
@@ -0,0 +1,94 @@
/// Modal that hosts `CLAUDE_CONFIG_DIR=<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 `<dir>` 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),
),
),
],
),
),
);
}
}
+13 -3
View File
@@ -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<String> 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=<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=<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<Object>((c, dismiss) => ClaudeLoginDialog(name: name, dir: dir, cwd: _projectRoot, onClose: dismiss));
}
}
}