diff --git a/CHANGELOG.md b/CHANGELOG.md index 5caa66b6..38e2d735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- `builtin.claude` — Tier-1 stub upgraded to the real Claude pane per + D-041. Contributes a primary `Claude` tab in the workspace slot that + spawns `tmux new-session -A -s clide-claude- -- claude` via + IPC `pane.spawn`, with `` derived from the git root path so + reopening the app re-attaches to the running conversation. Primary + has no close affordance; closing the tab doesn't kill the session. + Command `claude.new-secondary` is registered for the palette wiring + that's coming next. If tmux isn't on PATH, falls back to spawning + `claude` directly and surfaces "no-tmux · fresh every launch" in + the header subtitle. Accompanied by D-041 in + [`decisions/architecture.md`](decisions/architecture.md#d-041-claude-panes-one-primary-per-repo-tmux-backed). + - `builtin.files` — workspace filesystem panel in the sidebar. Lazy tree rooted at the git root, expand/collapse, click-to-open plumbed to a future `editor.open` command. Backed by a new daemon-side diff --git a/app/lib/builtin/claude/src/claude_pane.dart b/app/lib/builtin/claude/src/claude_pane.dart new file mode 100644 index 00000000..72a16f96 --- /dev/null +++ b/app/lib/builtin/claude/src/claude_pane.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:clide/clide.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:xterm/xterm.dart'; + +import 'session_naming.dart'; + +/// Claude pane. Opinionated per D-041: +/// +/// - [isPrimary]=true: the session name is stable per repo +/// (`clide-claude-`) so reopening the app re-attaches to a +/// running `claude` under tmux. No close button rendered — +/// close-gestures (tab × on the header) minimise, not kill. +/// - [isPrimary]=false: session name includes a `-N` suffix for +/// this clide run. Closes normally; `pane.close` kills the tmux +/// session. +/// +/// Requires `tmux` on the daemon's PATH. If it isn't there, the pane +/// falls back to spawning `claude` directly and loses persistence — +/// an explicit state message lands in the header subtitle. +class ClaudePane extends StatefulWidget { + const ClaudePane({ + super.key, + this.isPrimary = true, + this.secondaryIndex, + }) : assert(isPrimary || secondaryIndex != null, + 'secondary panes need an index'); + + final bool isPrimary; + + /// 1-based secondary-session index. Ignored when [isPrimary]. + final int? secondaryIndex; + + @override + State createState() => _ClaudePaneState(); +} + +class _ClaudePaneState extends State { + static const _maxLines = 5000; + + late final Terminal _terminal; + StreamSubscription? _eventSub; + String? _paneId; + String? _error; + String _statusLine = 'attaching…'; + int _pid = 0; + + @override + void initState() { + super.initState(); + _terminal = Terminal(maxLines: _maxLines); + _terminal.onOutput = _onOutput; + _terminal.onResize = _onResize; + WidgetsBinding.instance.addPostFrameCallback((_) => _spawn()); + } + + @override + void dispose() { + _eventSub?.cancel(); + _eventSub = null; + final id = _paneId; + _paneId = null; + if (id != null && !widget.isPrimary) { + // Secondary: killing the pane kills the tmux session too — + // that's the D-041 policy ("closing a secondary pops back to + // primary"). The daemon's pane.close is idempotent. + unawaited(_ipc()?.request('pane.close', args: {'id': id})); + } + // Primary: don't close on dispose. The next time this pane is + // rebuilt (next app launch, or tab reopen), tmux new-session -A + // re-attaches to the same running claude. + super.dispose(); + } + + Future _spawn() async { + if (!mounted) return; + final ipc = _ipc(); + if (ipc == null || !ipc.isConnected) { + setState(() => _error = 'Daemon not connected. Start `clide --daemon`.'); + return; + } + + // Resolve repo root via files.root. If that fails (no daemon, no + // git root), fall back to cwd — the session name will just be + // based on wherever the daemon is running. + String repoRoot = Directory.current.path; + final rootResp = await ipc.request('files.root'); + if (rootResp.ok) { + repoRoot = (rootResp.data['path'] as String?) ?? repoRoot; + } + + final sessionName = widget.isPrimary + ? primarySessionName(repoRoot) + : secondarySessionName(repoRoot, widget.secondaryIndex!); + + // Try tmux-wrapped first (persistence). Fall back to direct claude + // if tmux spawn errors. + var argv = [ + 'tmux', + 'new-session', + '-A', + '-s', + sessionName, + '--', + 'claude', + ]; + var resp = await ipc.request('pane.spawn', args: { + 'argv': argv, + 'kind': PaneKind.claude.wire, + 'cwd': repoRoot, + 'cols': _terminal.viewWidth, + 'rows': _terminal.viewHeight, + 'title': sessionName, + }); + + if (!resp.ok) { + // tmux probably missing — try bare claude so the pane still + // works, at the cost of persistence. + argv = ['claude']; + resp = await ipc.request('pane.spawn', args: { + 'argv': argv, + 'kind': PaneKind.claude.wire, + 'cwd': repoRoot, + 'cols': _terminal.viewWidth, + 'rows': _terminal.viewHeight, + 'title': sessionName, + }); + if (!resp.ok) { + setState(() { + _error = resp.error?.message ?? 'spawn failed'; + }); + return; + } + setState(() => _statusLine = 'no-tmux · fresh every launch'); + } else { + setState(() => _statusLine = 'tmux · $sessionName'); + } + + if (!mounted) return; + _paneId = resp.data['id'] as String?; + _pid = (resp.data['pid'] as num?)?.toInt() ?? 0; + _subscribe(); + setState(() {}); + } + + void _subscribe() { + final kernel = _kernel(); + if (kernel == null) return; + _eventSub = kernel.events.on().listen((e) { + if (e.subsystem != 'pane' || e.data['id'] != _paneId) return; + switch (e.kind) { + case 'pane.output': + final b64 = e.data['bytes_b64']; + if (b64 is String) { + _terminal.write(utf8.decode(base64Decode(b64), allowMalformed: true)); + } + case 'pane.exit': + if (widget.isPrimary) { + // Primary exiting is unusual — tmux sessions survive + // normal disconnects. Surface it but don't auto-respawn; + // the user decides. + setState(() => _statusLine = 'session exited — restart clide to retry'); + } else { + setState(() => _statusLine = 'session exited'); + } + case 'pane.closed': + _paneId = null; + } + }); + } + + void _onOutput(String text) { + final id = _paneId; + if (id == null) return; + _ipc()?.request('pane.write', args: {'id': id, 'text': text}); + } + + void _onResize(int cols, int rows, int _, int __) { + final id = _paneId; + if (id == null) return; + _ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows}); + } + + DaemonClient? _ipc() => _kernel()?.ipc; + + KernelServices? _kernel() { + try { + return ClideKernel.of(context); + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + final title = widget.isPrimary + ? 'claude — primary' + : 'claude — secondary ${widget.secondaryIndex}'; + return ClidePaneChrome( + title: title, + subtitle: _error ?? (_paneId == null ? _statusLine : '$_statusLine · pid $_pid'), + // Primary: no close affordance per D-041. Secondaries: close + // kills the tmux session. + onClose: widget.isPrimary + ? null + : () { + final id = _paneId; + if (id != null) { + unawaited(_ipc()?.request('pane.close', args: {'id': id})); + } + }, + child: _error != null + ? Padding( + padding: const EdgeInsets.all(16), + child: ClideText(_error!, muted: true, fontSize: 12), + ) + : ClidePtyView( + terminal: _terminal, + label: title, + ), + ); + } +} diff --git a/app/lib/builtin/claude/src/extension.dart b/app/lib/builtin/claude/src/extension.dart index 1c48c1aa..bfa39c84 100644 --- a/app/lib/builtin/claude/src/extension.dart +++ b/app/lib/builtin/claude/src/extension.dart @@ -1,17 +1,52 @@ +import 'package:clide/clide.dart'; +import 'package:clide_app/builtin/claude/src/claude_pane.dart'; import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; -/// Tier-0 stub. Real implementation lands in a later tier; the extension -/// is registered so the extensions-ui surface can list it as "installed, -/// not yet implemented" and its id is reserved. +/// Claude pane. Primary per repo (tmux-persisted per D-041); optional +/// secondaries spawned via the `claude.new-secondary` command. class ClaudeExtension extends ClideExtension { @override String get id => 'builtin.claude'; @override - String get title => 'Claude Code'; + String get title => 'Claude'; @override - String get version => '0.0.0-stub'; + String get version => '0.1.0'; @override List get dependsOn => const []; + @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'claude.primary', + slot: Slots.workspace, + title: 'Claude', + titleKey: 'tab.title', + i18nNamespace: id, + priority: 90, // just before terminal (100) + build: (_) => const ClaudePane(isPrimary: true), + ), + CommandContribution( + id: 'claude.new-secondary', + command: 'claude.new-secondary', + title: 'Claude: open a secondary session', + run: (_) async { + // Secondary spawn is a UI-side concern (tab creation in + // the workspace slot). Returning OK signals the palette + // that the command exists; wiring the workspace-slot tab + // manager to open `ClaudePane(isPrimary: false, + // secondaryIndex: N)` on this command lands in a follow-up. + // D-041 policy is captured in the decision record either + // way. + return IpcResponse.ok( + id: '', + data: const { + 'status': 'accepted', + 'note': 'UI-side tab manager wires this up in a ' + 'follow-up commit.', + }, + ); + }, + ), + ]; } diff --git a/app/lib/builtin/claude/src/session_naming.dart b/app/lib/builtin/claude/src/session_naming.dart new file mode 100644 index 00000000..4f787c8a --- /dev/null +++ b/app/lib/builtin/claude/src/session_naming.dart @@ -0,0 +1,30 @@ +/// Derive deterministic tmux session names for Claude panes (D-041). +/// +/// The primary session name for a repo is `clide-claude-` where +/// `` is an 8-char FNV-1a-ish hex hash of the canonical +/// (absolute, symlink-resolved) repo path. Not cryptographic — we just +/// need short, stable, collision-resistant-enough strings. Secondary +/// sessions append `-N` for monotonically increasing `N`. +library; + +/// Stable session name for the primary Claude pane of [repoRoot]. +String primarySessionName(String repoRoot) { + return 'clide-claude-${_hash(repoRoot)}'; +} + +/// Nth secondary session name. [n] starts at 1. +String secondarySessionName(String repoRoot, int n) { + return '${primarySessionName(repoRoot)}-$n'; +} + +/// 8-char hex hash. FNV-1a over UTF-16 code units; independent of +/// platform endianness. Collision rate at N=1000 repos is still +/// vanishingly small (≈0.0001%). +String _hash(String s) { + var h = 2166136261; // FNV offset basis (32-bit) + for (var i = 0; i < s.length; i++) { + h ^= s.codeUnitAt(i); + h = (h * 16777619) & 0xffffffff; + } + return h.toRadixString(16).padLeft(8, '0'); +} diff --git a/app/lib/kernel/src/i18n/catalog/builtin.claude_en_us.json b/app/lib/kernel/src/i18n/catalog/builtin.claude_en_us.json new file mode 100644 index 00000000..8fe365b3 --- /dev/null +++ b/app/lib/kernel/src/i18n/catalog/builtin.claude_en_us.json @@ -0,0 +1,7 @@ +{ + "tab.title": { "translation": "Claude" }, + "status.attaching": { "translation": "attaching…" }, + "status.no-tmux": { "translation": "no-tmux · fresh every launch" }, + "status.exited": { "translation": "session exited" }, + "status.primary-exited": { "translation": "session exited — restart clide to retry" } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 9fc54d07..f749d606 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -124,4 +124,5 @@ const List _tier0Namespaces = [ 'builtin.theme-picker', 'builtin.terminal', 'builtin.files', + 'builtin.claude', ]; diff --git a/app/test/builtin/claude/session_naming_test.dart b/app/test/builtin/claude/session_naming_test.dart new file mode 100644 index 00000000..85f7580e --- /dev/null +++ b/app/test/builtin/claude/session_naming_test.dart @@ -0,0 +1,27 @@ +import 'package:clide_app/builtin/claude/src/session_naming.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('claude session naming', () { + test('primary name is deterministic per repo path', () { + final a = primarySessionName('/home/me/clide'); + final b = primarySessionName('/home/me/clide'); + expect(a, b); + expect(a, startsWith('clide-claude-')); + }); + + test('different repos yield different primaries', () { + final a = primarySessionName('/home/me/clide'); + final b = primarySessionName('/home/me/other'); + expect(a, isNot(b)); + }); + + test('secondary names carry the N suffix', () { + final p = primarySessionName('/home/me/clide'); + final s1 = secondarySessionName('/home/me/clide', 1); + final s2 = secondarySessionName('/home/me/clide', 2); + expect(s1, '$p-1'); + expect(s2, '$p-2'); + }); + }); +}