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:
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>? argv;
|
||||
|
||||
/// Extra environment for the spawned process (e.g. `CLAUDE_CONFIG_DIR`).
|
||||
final Map<String, String>? env;
|
||||
|
||||
/// Working directory override; defaults to the open workspace.
|
||||
final String? cwdOverride;
|
||||
|
||||
@override
|
||||
State<TerminalPane> createState() => _TerminalPaneState();
|
||||
@@ -79,16 +89,23 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user