dissolve app/ into repo root (D-056)
Single Flutter package at the repo root. All code, tests, assets, and platform directories moved from app/ to root. Package renamed from clide_app to clide — all imports rewritten. Merged pubspec combines core (ffi) and app (flutter, yaml, xterm) dependencies. Makefile simplified: no APP_PRESENT conditionals, no cd, no daemon lifecycle. 317 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/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-<hash>`) 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,
|
||||
this.showChrome = true,
|
||||
}) : assert(isPrimary || secondaryIndex != null,
|
||||
'secondary panes need an index');
|
||||
|
||||
final bool isPrimary;
|
||||
final bool showChrome;
|
||||
|
||||
/// 1-based secondary-session index. Ignored when [isPrimary].
|
||||
final int? secondaryIndex;
|
||||
|
||||
@override
|
||||
State<ClaudePane> createState() => _ClaudePaneState();
|
||||
}
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
static const _maxLines = 5000;
|
||||
|
||||
late final Terminal _terminal;
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
String? _paneId;
|
||||
String? _error;
|
||||
String _statusLine = 'attaching…';
|
||||
|
||||
@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<void> _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 = <String>[
|
||||
'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 available in resp.data['pid'] if needed for debugging.
|
||||
_subscribe();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
final kernel = _kernel();
|
||||
if (kernel == null) return;
|
||||
_eventSub = kernel.events.on<DaemonEvent>().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}';
|
||||
final body = _error != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClideText(_error!, muted: true),
|
||||
)
|
||||
: ClidePtyView(terminal: _terminal, label: title);
|
||||
|
||||
if (!widget.showChrome) return body;
|
||||
|
||||
return ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: _error ?? _statusLine,
|
||||
onClose: widget.isPrimary
|
||||
? null
|
||||
: () {
|
||||
final id = _paneId;
|
||||
if (id != null) {
|
||||
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
},
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'claude_pane.dart';
|
||||
|
||||
class ClaudeSessionHost extends StatefulWidget {
|
||||
const ClaudeSessionHost({super.key});
|
||||
|
||||
@override
|
||||
State<ClaudeSessionHost> createState() => ClaudeSessionHostState();
|
||||
}
|
||||
|
||||
class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
final List<_Session> _sessions = [];
|
||||
int _activeIndex = 0;
|
||||
int _nextSecondary = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sessions.add(_Session(isPrimary: true, label: 'primary'));
|
||||
}
|
||||
|
||||
void addSecondary() {
|
||||
final index = _nextSecondary++;
|
||||
setState(() {
|
||||
_sessions.add(_Session(isPrimary: false, secondaryIndex: index, label: 'session $index'));
|
||||
_activeIndex = _sessions.length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _close(int index) {
|
||||
if (index < 0 || index >= _sessions.length) return;
|
||||
if (_sessions[index].isPrimary) return;
|
||||
setState(() {
|
||||
_sessions.removeAt(index);
|
||||
if (_activeIndex >= _sessions.length) _activeIndex = _sessions.length - 1;
|
||||
if (_activeIndex < 0) _activeIndex = 0;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final showTabs = _sessions.length > 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (showTabs)
|
||||
_TabRow(
|
||||
sessions: _sessions,
|
||||
activeIndex: _activeIndex,
|
||||
tokens: tokens,
|
||||
onSelect: (i) => setState(() => _activeIndex = i),
|
||||
onClose: _close,
|
||||
onAdd: addSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _activeIndex,
|
||||
children: [
|
||||
for (final s in _sessions)
|
||||
ClaudePane(
|
||||
key: s.key,
|
||||
isPrimary: s.isPrimary,
|
||||
secondaryIndex: s.secondaryIndex,
|
||||
showChrome: !showTabs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Session {
|
||||
_Session({required this.isPrimary, this.secondaryIndex, required this.label}) : key = GlobalKey();
|
||||
final bool isPrimary;
|
||||
final int? secondaryIndex;
|
||||
final String label;
|
||||
final GlobalKey key;
|
||||
}
|
||||
|
||||
class _TabRow extends StatelessWidget {
|
||||
const _TabRow({
|
||||
required this.sessions,
|
||||
required this.activeIndex,
|
||||
required this.tokens,
|
||||
required this.onSelect,
|
||||
required this.onClose,
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
final List<_Session> sessions;
|
||||
final int activeIndex;
|
||||
final SurfaceTokens tokens;
|
||||
final ValueChanged<int> onSelect;
|
||||
final ValueChanged<int> onClose;
|
||||
final VoidCallback onAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onDoubleTap: onAdd,
|
||||
child: Container(
|
||||
height: 28,
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.dividerColor))),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < sessions.length; i++) _Tab(session: sessions[i], active: i == activeIndex, tokens: tokens, onTap: () => onSelect(i), onClose: sessions[i].isPrimary ? null : () => onClose(i)),
|
||||
const SizedBox(width: 4),
|
||||
_AddButton(tokens: tokens, onTap: onAdd),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatefulWidget {
|
||||
const _Tab({required this.session, required this.active, required this.tokens, required this.onTap, this.onClose});
|
||||
final _Session session;
|
||||
final bool active;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
State<_Tab> createState() => _TabState();
|
||||
}
|
||||
|
||||
class _TabState extends State<_Tab> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered && !widget.active ? widget.tokens.tabInactive : null,
|
||||
border: Border(bottom: BorderSide(color: widget.active ? widget.tokens.tabActiveBorder : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(
|
||||
widget.session.label,
|
||||
fontSize: 12,
|
||||
color: widget.active ? widget.tokens.tabActiveForeground : widget.tokens.tabInactiveForeground,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
if (widget.onClose != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: widget.onClose,
|
||||
child: ClideIcon(PhosphorIcons.xMark, size: 10, color: _hovered ? widget.tokens.globalForeground : widget.tokens.globalTextMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddButton extends StatefulWidget {
|
||||
const _AddButton({required this.tokens, required this.onTap});
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_AddButton> createState() => _AddButtonState();
|
||||
}
|
||||
|
||||
class _AddButtonState extends State<_AddButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: ClideText('+', fontSize: 14, color: _hovered ? widget.tokens.globalForeground : widget.tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClaudeExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.claude';
|
||||
@override
|
||||
String get title => 'Claude';
|
||||
@override
|
||||
String get version => '0.2.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
ClideExtensionContext? _ctx;
|
||||
final GlobalKey<ClaudeSessionHostState> _hostKey = GlobalKey();
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'claude.primary',
|
||||
slot: Slots.workspace,
|
||||
title: 'Claude',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 90,
|
||||
build: (_) => ClaudeSessionHost(key: _hostKey),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.new-secondary',
|
||||
command: 'claude.new-secondary',
|
||||
title: 'Claude: open a secondary session',
|
||||
run: (_) async {
|
||||
_hostKey.currentState?.addSecondary();
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.kill-all-sessions',
|
||||
command: 'claude.kill-all-sessions',
|
||||
title: 'Claude: kill all tmux sessions for this repo',
|
||||
run: _killAllSessions,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async {
|
||||
await _killAllSessions([]);
|
||||
}
|
||||
|
||||
Future<IpcResponse> _killAllSessions(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return IpcResponse.ok(id: '', data: const {});
|
||||
final resp = await ctx.ipc.request('pane.list');
|
||||
if (!resp.ok) return resp;
|
||||
final panes = resp.data['panes'];
|
||||
if (panes is List) {
|
||||
for (final p in panes) {
|
||||
if (p is Map && p['kind'] == 'claude') {
|
||||
final id = p['id'] as String?;
|
||||
if (id != null) {
|
||||
await ctx.ipc.request('pane.close', args: {'id': id});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'killed'});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/// Derive deterministic tmux session names for Claude panes (D-041).
|
||||
///
|
||||
/// The primary session name encodes the repo path in a human-readable
|
||||
/// form: `clide-claude-<path-slug>`. For example:
|
||||
/// ~/projects/clide → clide-claude-projects-clide
|
||||
/// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp
|
||||
///
|
||||
/// Secondary sessions append `-N`.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
/// Stable session name for the primary Claude pane of [repoRoot].
|
||||
String primarySessionName(String repoRoot) {
|
||||
return 'clide-claude-${_slugify(repoRoot)}';
|
||||
}
|
||||
|
||||
/// Nth secondary session name. [n] starts at 1.
|
||||
String secondarySessionName(String repoRoot, int n) {
|
||||
return '${primarySessionName(repoRoot)}-$n';
|
||||
}
|
||||
|
||||
// tmux session names max out at 256 chars; keep ours well under.
|
||||
const _maxSlugLen = 80;
|
||||
|
||||
String _slugify(String path) {
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
var p = path;
|
||||
if (home.isNotEmpty && p.startsWith(home)) {
|
||||
p = p.substring(home.length);
|
||||
}
|
||||
p = p.replaceAll('/', '-').replaceAll('.', '');
|
||||
while (p.startsWith('-')) {
|
||||
p = p.substring(1);
|
||||
}
|
||||
while (p.endsWith('-')) {
|
||||
p = p.substring(0, p.length - 1);
|
||||
}
|
||||
if (p.isEmpty) p = 'root';
|
||||
if (p.length > _maxSlugLen) return _hash(path);
|
||||
return p;
|
||||
}
|
||||
|
||||
String _hash(String s) {
|
||||
var h = 2166136261;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
h ^= s.codeUnitAt(i);
|
||||
h = (h * 16777619) & 0xffffffff;
|
||||
}
|
||||
return h.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
Reference in New Issue
Block a user