diff --git a/CHANGELOG.md b/CHANGELOG.md index cc445352..c56fc5e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. empty session — instead of being forwarded to Claude Code, whose `/clear` forked to a new session clide couldn't follow and left the pane unresponsive (T-156). +- `/resume` is now handled by clide too (T-156): it opens a picker of the + workspace's past sessions — each labelled by its first … last user message + and when it was last active — and re-binds the pane to the chosen one, + instead of forwarding Claude Code's session-forking `/resume`. - Claude secondary panes no longer flash a false "session exited" while the session is alive (a transient tmux client exit is now verified against the live session), and the tab and banner agree on the label diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 51e4bdcb..97be9074 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -14,7 +14,9 @@ import 'claude_status.dart'; import 'clipboard_paste.dart'; import 'conversation_controller.dart'; import 'conversation_view.dart'; +import 'session_index.dart'; import 'session_naming.dart'; +import 'session_picker.dart'; import 'slash_commands.dart'; import 'tmux_session.dart' as tmux; import 'transcript_publisher.dart'; @@ -302,11 +304,16 @@ class _ClaudePaneState extends State { // does reach it. void _send(String text) { // Commands clide owns (T-156) are handled here, never forwarded — Claude - // Code's /clear forks the session to a new id our reader can't follow, so - // we tear this session down and start a fresh one instead. - if (clideOwnedCommand(text) == 'clear') { - unawaited(_clearSession()); - return; + // Code's /clear and /resume fork the session to a new id our reader can't + // follow, so clide drives them: /clear starts fresh, /resume picks a past + // session and re-binds to it. + switch (clideOwnedCommand(text)) { + case 'clear': + unawaited(_clearSession()); + return; + case 'resume': + unawaited(_resumeFlow()); + return; } if (_usingTmux) { final session = _sessionName; @@ -328,13 +335,42 @@ class _ClaudePaneState extends State { unawaited(ipc.request('pane.write', args: {'id': id, 'text': encodeClaudeInput(text)})); } - /// clide-owned `/clear` (T-156): tear this pane's session down and respawn a - /// brand-new, empty one. A fresh session id is forced — even for the primary, - /// whose id is normally deterministic — so we start empty rather than resume - /// the old transcript; _spawn's self-heal kills the now-stale tmux session - /// because the new id has no transcript yet. The old transcript is left on - /// disk (history preserved, just detached from this pane). + /// clide-owned `/clear` (T-156): respawn this pane on a brand-new, empty + /// session. A fresh id is forced — even for the primary, whose id is normally + /// deterministic — so we start empty rather than resume the old transcript. Future _clearSession() async { + if (mounted) setState(() => _statusLine = 'clearing…'); + await _respawnWithSession(freshSessionId()); + } + + /// clide-owned `/resume` (T-156): pick a past session for this workspace and + /// re-bind the pane to it. Claude Code's own /resume forks to a session our + /// reader can't follow, so clide drives the switch. + Future _resumeFlow() async { + final root = _repoRoot; + final dialog = _kernel()?.dialog; + if (root == null || dialog == null) return; + final home = Platform.environment['HOME'] ?? ''; + final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}'); + final sessions = await listSessions(dir); + if (!mounted) return; + final picked = await dialog.show( + (ctx, dismiss) => SessionPickerDialog( + sessions: sessions, + onPick: (id) => dismiss(id), + onCancel: dismiss, + ), + ); + if (picked == null || !mounted) return; + setState(() => _statusLine = 'resuming…'); + await _respawnWithSession(picked); + } + + /// Tear the current session down and respawn the pane bound to [sessionId]. + /// The tmux session is killed first so `new-session` starts a fresh client + /// on the new id rather than re-attaching the still-running old claude; the + /// old transcript is left on disk (history preserved, detached). + Future _respawnWithSession(String sessionId) async { _conversation?.dispose(); _conversation = null; unawaited(_feed?.dispose()); @@ -343,13 +379,10 @@ class _ClaudePaneState extends State { _statusSub = null; _eventSub?.cancel(); _eventSub = null; - _sessionId = freshSessionId(); - if (mounted) { - setState(() { - _status = const SessionStatus(); - _statusLine = 'clearing…'; - }); - } + final old = _sessionName; + if (old != null) await tmux.killSession(old); + _sessionId = sessionId; + if (mounted) setState(() => _status = const SessionStatus()); await _spawn(); } diff --git a/lib/builtin/claude/src/session_index.dart b/lib/builtin/claude/src/session_index.dart new file mode 100644 index 00000000..dab8b94b --- /dev/null +++ b/lib/builtin/claude/src/session_index.dart @@ -0,0 +1,140 @@ +/// Enumerates the Claude sessions recorded for a workspace and summarises +/// each by its first and last user message — the labels the /resume picker +/// shows (T-156). Flutter-free so it unit-tests under `dart test`. +/// +/// Each session is a `.jsonl` transcript in the munged project dir +/// (`~/.claude/projects//`). Bookends are read from a bounded +/// window at each end of the file, so even a multi-MB transcript summarises +/// cheaply. +library; + +import 'dart:convert'; +import 'dart:io'; + +/// One session in the workspace, summarised for the picker. +class SessionSummary { + const SessionSummary({ + required this.id, + required this.modified, + this.firstUser, + this.lastUser, + }); + + /// The session id (the `` of `.jsonl`). + final String id; + final DateTime modified; + + /// First / last *user prompt* text in the session (tool-result-only user + /// records don't count), or null if none. + final String? firstUser; + final String? lastUser; + + /// "first … last" — the picker's primary label. Falls back to the id when + /// the session carries no user prompt. + String get label { + final f = firstUser, l = lastUser; + if (f == null && l == null) return id; + if (f == null) return l!; + if (l == null || l == f) return f; + return '$f … $l'; + } +} + +/// The user-prompt text in a transcript record, or null if [record] isn't a +/// user prompt (e.g. a tool_result-only user record, or a non-user record). +String? userText(Map record) { + if (record['type'] != 'user') return null; + final msg = record['message']; + if (msg is! Map) return null; + final content = msg['content']; + if (content is String) { + final t = content.trim(); + return t.isEmpty ? null : t; + } + if (content is List) { + final texts = content.whereType().where((b) => b['type'] == 'text').map((b) => b['text']).whereType(); + final joined = texts.join(' ').trim(); + return joined.isEmpty ? null : joined; + } + return null; +} + +String? _firstUserText(Iterable lines) { + for (final line in lines) { + final t = _userTextOf(line); + if (t != null) return t; + } + return null; +} + +String? _lastUserText(Iterable lines) { + String? last; + for (final line in lines) { + final t = _userTextOf(line); + if (t != null) last = t; + } + return last; +} + +String? _userTextOf(String line) { + final trimmed = line.trim(); + if (trimmed.isEmpty || !trimmed.startsWith('{')) return null; + try { + return userText(jsonDecode(trimmed) as Map); + } catch (_) { + return null; + } +} + +/// Sessions in [dir] (the munged project dir), most-recently-modified first, +/// capped at [max]. Each is summarised by bookend user prompts read from a +/// bounded [window] at each end of its transcript. +Future> listSessions( + Directory dir, { + int max = 20, + int window = 128 * 1024, +}) async { + if (!await dir.exists()) return const []; + final files = []; + await for (final e in dir.list(followLinks: false)) { + if (e is File && e.path.endsWith('.jsonl')) files.add(e); + } + final summaries = []; + for (final f in files) { + final stat = await f.stat(); + final bookends = await _bookends(f, window); + summaries.add(SessionSummary( + id: _sessionId(f.path), + modified: stat.modified, + firstUser: bookends.first, + lastUser: bookends.last, + )); + } + summaries.sort((a, b) => b.modified.compareTo(a.modified)); + return summaries.length > max ? summaries.sublist(0, max) : summaries; +} + +String _sessionId(String path) { + final base = path.split(Platform.pathSeparator).last; + return base.endsWith('.jsonl') ? base.substring(0, base.length - 6) : base; +} + +Future<({String? first, String? last})> _bookends(File f, int window) async { + final len = await f.length(); + final raf = await f.open(); + try { + final headLen = len < window ? len : window; + final head = const LineSplitter().convert(utf8.decode(await raf.read(headLen), allowMalformed: true)); + final first = _firstUserText(head); + + if (len <= window) { + return (first: first, last: _lastUserText(head)); + } + await raf.setPosition(len - window); + var tail = const LineSplitter().convert(utf8.decode(await raf.read(window), allowMalformed: true)); + if (tail.isNotEmpty) tail = tail.sublist(1); // drop the partial first line + return (first: first, last: _lastUserText(tail)); + } finally { + await raf.close(); + } +} diff --git a/lib/builtin/claude/src/session_picker.dart b/lib/builtin/claude/src/session_picker.dart new file mode 100644 index 00000000..bae46158 --- /dev/null +++ b/lib/builtin/claude/src/session_picker.dart @@ -0,0 +1,147 @@ +/// Modal picker for `/resume` (T-156): lists the workspace's Claude sessions, +/// each labelled `first user line … last user line` with its last-modified +/// time, and returns the chosen session id. No-Material (D-7); shown via the +/// [DialogRouter]. +library; + +import 'package:clide/builtin/claude/src/session_index.dart'; +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class SessionPickerDialog extends StatefulWidget { + const SessionPickerDialog({ + super.key, + required this.sessions, + required this.onPick, + required this.onCancel, + }); + + final List sessions; + final void Function(String id) onPick; + final VoidCallback onCancel; + + @override + State createState() => _SessionPickerDialogState(); +} + +class _SessionPickerDialogState extends State { + int _selected = 0; + + void _move(int delta) { + if (widget.sessions.isEmpty) return; + setState(() { + _selected = (_selected + delta) % widget.sessions.length; + if (_selected < 0) _selected += widget.sessions.length; + }); + } + + void _confirm() { + if (widget.sessions.isEmpty) return; + widget.onPick(widget.sessions[_selected].id); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent e) { + if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored; + switch (e.logicalKey) { + case LogicalKeyboardKey.arrowDown: + _move(1); + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowUp: + _move(-1); + return KeyEventResult.handled; + case LogicalKeyboardKey.escape: + widget.onCancel(); + return KeyEventResult.handled; + case LogicalKeyboardKey.enter: + case LogicalKeyboardKey.numpadEnter: + _confirm(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final theme = ClideTheme.of(context).surface; + return Focus( + autofocus: true, + onKeyEvent: _onKey, + child: Container( + width: 560, + constraints: const BoxConstraints(maxHeight: 420), + 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, 8), + child: ClideText('Resume a Claude session', fontSize: clideFontBody, color: theme.globalForeground), + ), + if (widget.sessions.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(14, 4, 14, 16), + child: ClideText('No sessions found for this workspace.', muted: true, fontSize: clideFontSmall), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.sessions.length, + itemBuilder: (ctx, i) => _row(theme, i), + ), + ), + ], + ), + ), + ); + } + + Widget _row(SurfaceTokens theme, int i) { + final s = widget.sessions[i]; + final selected = i == _selected; + return GestureDetector( + onTap: () => widget.onPick(s.id), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + color: selected ? theme.panelActiveBorder : null, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClideText( + s.label, + fontSize: clideFontSmall, + color: theme.globalForeground, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ClideText(relativeTime(s.modified), muted: true, fontSize: clideFontSmall), + ], + ), + ), + ), + ); + } +} + +/// Short human label for [when] relative to now ("just now", "5m ago", …). +String relativeTime(DateTime when, {DateTime? now}) { + final d = (now ?? DateTime.now()).difference(when); + if (d.inSeconds < 45) return 'just now'; + if (d.inMinutes < 60) return '${d.inMinutes}m ago'; + if (d.inHours < 24) return '${d.inHours}h ago'; + if (d.inDays < 7) return '${d.inDays}d ago'; + final w = when.toLocal(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${w.year}-${two(w.month)}-${two(w.day)}'; +} diff --git a/lib/builtin/claude/src/slash_commands.dart b/lib/builtin/claude/src/slash_commands.dart index b8f02a74..d2c7ff98 100644 --- a/lib/builtin/claude/src/slash_commands.dart +++ b/lib/builtin/claude/src/slash_commands.dart @@ -30,7 +30,7 @@ bool isKnownSlashCommand(String text, Iterable known) { /// Slash commands clide handles itself instead of forwarding to Claude: /// Claude Code's own handling forks the session to a new id that clide's /// transcript reader can't follow, so clide owns the semantics (T-156). -const Set kClideOwnedCommands = {'clear'}; +const Set kClideOwnedCommands = {'clear', 'resume'}; /// The clide-owned command in [text] (a single-line leading-slash token in /// [kClideOwnedCommands]), or null. diff --git a/test/builtin/claude/session_index_test.dart b/test/builtin/claude/session_index_test.dart new file mode 100644 index 00000000..e30b739d --- /dev/null +++ b/test/builtin/claude/session_index_test.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:clide/builtin/claude/src/session_index.dart'; +import 'package:test/test.dart'; + +String userLine(String text) => jsonEncode({ + 'type': 'user', + 'message': {'role': 'user', 'content': text}, + }); + +String userBlocksLine(String text) => jsonEncode({ + 'type': 'user', + 'message': { + 'role': 'user', + 'content': [ + {'type': 'text', 'text': text}, + ], + }, + }); + +String toolResultLine() => jsonEncode({ + 'type': 'user', + 'message': { + 'role': 'user', + 'content': [ + {'type': 'tool_result', 'content': 'output'}, + ], + }, + }); + +String assistantLine(String text) => jsonEncode({ + 'type': 'assistant', + 'message': { + 'role': 'assistant', + 'content': [ + {'type': 'text', 'text': text}, + ], + }, + }); + +void main() { + group('userText', () { + test('extracts string and text-block content', () { + expect(userText(jsonDecode(userLine('hello')) as Map), 'hello'); + expect(userText(jsonDecode(userBlocksLine('blocky')) as Map), 'blocky'); + }); + + test('ignores tool-result-only user records and non-user records', () { + expect(userText(jsonDecode(toolResultLine()) as Map), isNull); + expect(userText(jsonDecode(assistantLine('hi')) as Map), isNull); + }); + }); + + group('listSessions', () { + late Directory dir; + setUp(() async => dir = await Directory.systemTemp.createTemp('session_index_test')); + tearDown(() async { + if (await dir.exists()) await dir.delete(recursive: true); + }); + + Future writeSession(String id, List lines) async { + await File('${dir.path}/$id.jsonl').writeAsString('${lines.join('\n')}\n'); + } + + test('empty / missing dir yields no sessions', () async { + expect(await listSessions(dir), isEmpty); + expect(await listSessions(Directory('${dir.path}/nope')), isEmpty); + }); + + test('summarises each session with first … last bookends', () async { + await writeSession('aaaa', [ + userLine('start the swallow'), + assistantLine('ok'), + toolResultLine(), + userLine('now the peacock'), + ]); + final sessions = await listSessions(dir); + expect(sessions, hasLength(1)); + expect(sessions.single.id, 'aaaa'); + expect(sessions.single.firstUser, 'start the swallow'); + expect(sessions.single.lastUser, 'now the peacock'); + expect(sessions.single.label, 'start the swallow … now the peacock'); + }); + + test('a single-prompt session labels without an ellipsis', () async { + await writeSession('bbbb', [userLine('only one'), assistantLine('reply')]); + expect((await listSessions(dir)).single.label, 'only one'); + }); + + test('orders most-recently-modified first', () async { + await writeSession('old', [userLine('older')]); + await Future.delayed(const Duration(milliseconds: 50)); + await writeSession('new', [userLine('newer')]); + final sessions = await listSessions(dir); + expect(sessions.map((s) => s.id), ['new', 'old']); + // Defensive: timestamps are non-increasing regardless of FS granularity. + for (var i = 1; i < sessions.length; i++) { + expect(sessions[i - 1].modified.isBefore(sessions[i].modified), isFalse); + } + }); + + test('reads the last user prompt from the tail of a large transcript', () async { + final lines = [ + userLine('the very first prompt'), + for (var i = 0; i < 50; i++) assistantLine('filler line number $i to push past the window'), + userLine('the very last prompt'), + ]; + await writeSession('big', lines); + // Tiny window forces the head/tail split path. + final sessions = await listSessions(dir, window: 256); + expect(sessions.single.firstUser, 'the very first prompt'); + expect(sessions.single.lastUser, 'the very last prompt'); + }); + }); +} diff --git a/test/builtin/claude/session_picker_test.dart b/test/builtin/claude/session_picker_test.dart new file mode 100644 index 00000000..e7bf7670 --- /dev/null +++ b/test/builtin/claude/session_picker_test.dart @@ -0,0 +1,81 @@ +import 'package:clide/builtin/claude/src/session_index.dart'; +import 'package:clide/builtin/claude/src/session_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('relativeTime', () { + final now = DateTime(2026, 5, 23, 12); + test('buckets recent times', () { + expect(relativeTime(now.subtract(const Duration(seconds: 10)), now: now), 'just now'); + expect(relativeTime(now.subtract(const Duration(minutes: 5)), now: now), '5m ago'); + expect(relativeTime(now.subtract(const Duration(hours: 3)), now: now), '3h ago'); + expect(relativeTime(now.subtract(const Duration(days: 2)), now: now), '2d ago'); + }); + + test('falls back to a date for older sessions', () { + expect(relativeTime(DateTime(2026, 1, 9), now: now), '2026-01-09'); + }); + }); + + group('SessionPickerDialog', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + List two() => [ + SessionSummary(id: 'aaa', modified: DateTime.now(), firstUser: 'first a', lastUser: 'last a'), + SessionSummary(id: 'bbb', modified: DateTime.now(), firstUser: 'first b', lastUser: 'last b'), + ]; + + testWidgets('renders first … last labels and picks with arrow + Enter', (tester) async { + String? picked; + await tester.pumpWidget(harness( + f, + SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {}), + )); + await tester.pump(); + expect(find.text('first a … last a'), findsOneWidget); + expect(find.text('first b … last b'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // select bbb + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + expect(picked, 'bbb'); + }); + + testWidgets('Escape cancels', (tester) async { + var cancelled = false; + await tester.pumpWidget(harness( + f, + SessionPickerDialog(sessions: two(), onPick: (_) {}, onCancel: () => cancelled = true), + )); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + expect(cancelled, isTrue); + }); + + testWidgets('tap picks a row', (tester) async { + String? picked; + await tester.pumpWidget(harness( + f, + SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {}), + )); + await tester.pump(); + await tester.tap(find.text('first b … last b')); + expect(picked, 'bbb'); + }); + + testWidgets('empty list shows a message', (tester) async { + await tester.pumpWidget(harness( + f, + SessionPickerDialog(sessions: const [], onPick: (_) {}, onCancel: () {}), + )); + await tester.pump(); + expect(find.text('No sessions found for this workspace.'), findsOneWidget); + }); + }); +} diff --git a/test/builtin/claude/slash_commands_test.dart b/test/builtin/claude/slash_commands_test.dart index 07c053e0..ee6eaebf 100644 --- a/test/builtin/claude/slash_commands_test.dart +++ b/test/builtin/claude/slash_commands_test.dart @@ -37,9 +37,10 @@ void main() { }); group('clideOwnedCommand', () { - test('recognises /clear as clide-owned', () { + test('recognises /clear and /resume as clide-owned', () { expect(clideOwnedCommand('/clear'), 'clear'); expect(clideOwnedCommand('/clear '), 'clear'); + expect(clideOwnedCommand('/resume'), 'resume'); }); test('returns null for commands clide forwards to Claude', () {