diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 9f606ab5..3184224c 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2112,3 +2112,31 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-133', 'status', 'in_progress', 'done', NULL, '2026-05-22 17:37:23', '2026-05-22 17:37:23', '2026-05-22 17:37:23', NULL, '010ab0fda58e4be2ea699c102e1b2695', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-135', 'status', 'in_progress', 'done', NULL, '2026-05-22 17:37:23', '2026-05-22 17:37:23', '2026-05-22 17:37:23', NULL, '1bfd08b75a99bbf3c46408b0c5f799f2', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-136', 'status', 'in_progress', 'done', NULL, '2026-05-22 17:37:23', '2026-05-22 17:37:23', '2026-05-22 17:37:23', NULL, 'eea74c531ac4796fe6dba221018e80dd', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-134', 'status', 'in_progress', 'done', NULL, '2026-05-22 17:55:11', '2026-05-22 17:55:11', '2026-05-22 17:55:11', NULL, '13385e9b6de3d3979b03c22de9db73f6', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-134', 'description', 'Investigate empirically and write a findings note pinned to the current Claude Code version. 1) Team spawn inside tmux -L clide with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + teammateMode: tmux -> teammate panes (list-panes -a) + each teammate''s /subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams//config.json layout: tmuxPaneId, agentId, agentType populate at spawn. 4) Image/file paste intake when driven via send-keys (path/@-ref vs clipboard-image vs unsupported). 5) Active session-id discovery (capture at spawn vs newest JSONL in munged dir). Already validated this session: transcript paths + schema (type/message/agentId/version) + subagent transcript location. Acceptance: findings note answering 1-5, CC version pinned. Blocks T6, T7. + +--- +Refinement (2026-05-22, refinement-agent review): +- Environment confirmed: tmux 3.6a; claude 2.1.148 (the version an-idea.md verified for tmux teammate spawning) -- PIN findings to 2.1.148. teammateMode absent from ~/.claude/settings.json (default auto) -> set teammateMode:tmux explicitly OR launch the lead inside tmux and rely on auto. +- Check 3 ALREADY answered from a real ~/.claude/teams/*/config.json: {name,description,createdAt,leadAgentId,leadSessionId,members[]}; member = {agentId,name,agentType,model,joinedAt,tmuxPaneId (empty for lead, e.g. %154 for teammates),cwd,subscriptions,backendType:''tmux'',isActive,mode}. tmuxPaneId DOES populate -- spike just re-verifies on the pinned version. +- Subagent path: //subagents/agent-.jsonl (session UUID is a directory). +- Check 5 (session-id) resolved by T-136: newest *.jsonl by mtime. Spike confirms only. +- tmux -L clide socket may not exist unless the app is running; spike creates its own: tmux -L clide new-session -d -s teamhost ... claude. +- Check 4 (paste): scope to @path file references (verifiable via send-keys headlessly). Clipboard-image needs an interactive display ($DISPLAY) -- note as interactive-only. +- ACP directionality (an-idea.md s5) is OUT OF SCOPE: Zed-client concern; clide owns its GUI and does not use ACP. +- Deliverable: findings note at docs/spikes/cc-team-internals-2.1.148.md (in-repo), feeding D-75 when authored (T-133). D-75 is claimed-but-unauthored (T-133''s job) -- the reference is forward-looking, not dangling.', 'Investigate empirically and write a findings note pinned to the current Claude Code version. 1) Team spawn inside tmux -L clide with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + teammateMode: tmux -> teammate panes (list-panes -a) + each teammate''s /subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams//config.json layout: tmuxPaneId, agentId, agentType populate at spawn. 4) Image/file paste intake when driven via send-keys (path/@-ref vs clipboard-image vs unsupported). 5) Active session-id discovery (capture at spawn vs newest JSONL in munged dir). Already validated this session: transcript paths + schema (type/message/agentId/version) + subagent transcript location. Acceptance: findings note answering 1-5, CC version pinned. Blocks T6, T7. + +--- +Refinement (2026-05-22, refinement-agent review): +- Environment confirmed: tmux 3.6a; claude 2.1.148 (the version an-idea.md verified for tmux teammate spawning) -- PIN findings to 2.1.148. teammateMode absent from ~/.claude/settings.json (default auto) -> set teammateMode:tmux explicitly OR launch the lead inside tmux and rely on auto. +- Check 3 ALREADY answered from a real ~/.claude/teams/*/config.json: {name,description,createdAt,leadAgentId,leadSessionId,members[]}; member = {agentId,name,agentType,model,joinedAt,tmuxPaneId (empty for lead, e.g. %154 for teammates),cwd,subscriptions,backendType:''tmux'',isActive,mode}. tmuxPaneId DOES populate -- spike just re-verifies on the pinned version. +- Subagent path: //subagents/agent-.jsonl (session UUID is a directory). +- Check 5 (session-id) resolved by T-136: newest *.jsonl by mtime. Spike confirms only. +- tmux -L clide socket may not exist unless the app is running; spike creates its own: tmux -L clide new-session -d -s teamhost ... claude. +- Check 4 (paste): scope to @path file references (verifiable via send-keys headlessly). Clipboard-image needs an interactive display ($DISPLAY) -- note as interactive-only. +- ACP directionality (an-idea.md s5) is OUT OF SCOPE: Zed-client concern; clide owns its GUI and does not use ACP. +- Deliverable: findings note at docs/spikes/cc-team-internals-2.1.148.md (in-repo), feeding D-75 when authored (T-133). D-75 is claimed-but-unauthored (T-133''s job) -- the reference is forward-looking, not dangling. + +--- +Findings (2026-05-22): docs/spikes/cc-team-internals-2.1.148.md. All 5 checks answered from real artifacts + tmux manual + synthetic control-mode test (no live run/quota). Key risk for T-139: pane->teammate->transcript identity joins only on agentType (config <-> .meta.json), ambiguous for same-type teammates -> needs joinedAt/mtime or lead-transcript tiebreaker. tmux 3.6a has no %pane-died; poll list-panes -a. Teammate transcripts at //subagents/agent-.jsonl + .meta.json{agentType,description}.', NULL, '2026-05-22 17:55:11', '2026-05-22 17:55:11', '2026-05-22 17:55:11', NULL, 'dbf4f56d1ec9d2234f255826c5f88f68', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 17:58:07', '2026-05-22 17:58:07', '2026-05-22 17:58:07', NULL, '4e67262d4b04f8541e71fe8f03106fbf', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 346fa841..55cf670e 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2469,3 +2469,19 @@ Refinement (2026-05-22, refinement-agent review): READY. Verified against real t - Subagent transcripts: //subagents/agent-.jsonl + sibling .meta.json ({agentType,description}); each record carries agentId. - Tail: dart:io File.openRead with a byte-position cursor (File.length + RandomAccessFile.setPosition on poll). Do NOT use lib/src/files/watcher.dart. Zero new deps. - Logger: lib/kernel/src/log.dart for the version drift-guard. Coverage floor 95%.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:37:23', NULL, 'f68fd700a3487296ac6a00632b050543', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-134', 'task', 'T-132', 'Spike: validate Claude Code team/transcript internals (pin a CC version)', 'Investigate empirically and write a findings note pinned to the current Claude Code version. 1) Team spawn inside tmux -L clide with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + teammateMode: tmux -> teammate panes (list-panes -a) + each teammate''s /subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams//config.json layout: tmuxPaneId, agentId, agentType populate at spawn. 4) Image/file paste intake when driven via send-keys (path/@-ref vs clipboard-image vs unsupported). 5) Active session-id discovery (capture at spawn vs newest JSONL in munged dir). Already validated this session: transcript paths + schema (type/message/agentId/version) + subagent transcript location. Acceptance: findings note answering 1-5, CC version pinned. Blocks T6, T7. + +--- +Refinement (2026-05-22, refinement-agent review): +- Environment confirmed: tmux 3.6a; claude 2.1.148 (the version an-idea.md verified for tmux teammate spawning) -- PIN findings to 2.1.148. teammateMode absent from ~/.claude/settings.json (default auto) -> set teammateMode:tmux explicitly OR launch the lead inside tmux and rely on auto. +- Check 3 ALREADY answered from a real ~/.claude/teams/*/config.json: {name,description,createdAt,leadAgentId,leadSessionId,members[]}; member = {agentId,name,agentType,model,joinedAt,tmuxPaneId (empty for lead, e.g. %154 for teammates),cwd,subscriptions,backendType:''tmux'',isActive,mode}. tmuxPaneId DOES populate -- spike just re-verifies on the pinned version. +- Subagent path: //subagents/agent-.jsonl (session UUID is a directory). +- Check 5 (session-id) resolved by T-136: newest *.jsonl by mtime. Spike confirms only. +- tmux -L clide socket may not exist unless the app is running; spike creates its own: tmux -L clide new-session -d -s teamhost ... claude. +- Check 4 (paste): scope to @path file references (verifiable via send-keys headlessly). Clipboard-image needs an interactive display ($DISPLAY) -- note as interactive-only. +- ACP directionality (an-idea.md s5) is OUT OF SCOPE: Zed-client concern; clide owns its GUI and does not use ACP. +- Deliverable: findings note at docs/spikes/cc-team-internals-2.1.148.md (in-repo), feeding D-75 when authored (T-133). D-75 is claimed-but-unauthored (T-133''s job) -- the reference is forward-looking, not dangling. + +--- +Findings (2026-05-22): docs/spikes/cc-team-internals-2.1.148.md. All 5 checks answered from real artifacts + tmux manual + synthetic control-mode test (no live run/quota). Key risk for T-139: pane->teammate->transcript identity joins only on agentType (config <-> .meta.json), ambiguous for same-type teammates -> needs joinedAt/mtime or lead-transcript tiebreaker. tmux 3.6a has no %pane-died; poll list-panes -a. Teammate transcripts at //subagents/agent-.jsonl + .meta.json{agentType,description}.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:55:11', NULL, '2f9a445df8c60beafe126aff339410b8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:58:07', NULL, 'e8244b90386314574545f0c1a7bd012c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 93667378..c50f5c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Claude pane renders natively from the transcript (T-137, D-75) — the + conversation shows as native cards (user / assistant markdown / + thinking / tool-use / result) instead of a terminal, with text + selection + copy across cards. Claude still runs in tmux; the terminal + builtin stays for general use. (Input composer lands next, T-138.) - Multi-file editor tabs — the editor pane now shows one tab per open buffer (filename + a dot when unsaved) via the shared tab strip; opening a second file no longer replaces the first. Click a tab to diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index a9f34deb..12bf11c2 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:clide/clide.dart'; @@ -7,8 +6,9 @@ import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; -import 'package:clide/src/terminal/terminal.dart'; +import 'conversation_controller.dart'; +import 'conversation_view.dart'; import 'session_naming.dart'; import 'tmux_session.dart' as tmux; @@ -29,11 +29,15 @@ class ClaudePane extends StatefulWidget { } class _ClaudePaneState extends State { - static const _maxLines = 50000; + // Fixed tmux window size — Claude's TUI is no longer rendered (we read + // its transcript instead, T-137/D-75), so a sane default is enough to + // keep claude's layout happy inside the headless tmux session. + static const _cols = 120; + static const _rows = 40; static String? _tmuxConfPath; - late final Terminal _terminal; StreamSubscription? _eventSub; + ConversationController? _conversation; String? _paneId; String? _sessionName; String? _error; @@ -42,19 +46,20 @@ class _ClaudePaneState extends State { bool _spawned = false; @override - void initState() { - super.initState(); - _terminal = Terminal(maxLines: _maxLines); - _terminal.onOutput = _onTerminalOutput; - _terminal.onResize = _onTerminalResize; - // Don't spawn here — wait for the first onResize from TerminalView - // so the PTY gets real dimensions, not 80x24 defaults. + void didChangeDependencies() { + super.didChangeDependencies(); + // Spawn once, after the kernel is available. The conversation renders + // from the transcript, so we no longer wait on a terminal resize. + if (!_spawned) { + _spawned = true; + unawaited(_spawnWhenReady()); + } } @override void dispose() { - _resizeTimer?.cancel(); - _flushTimer?.cancel(); + _conversation?.dispose(); + _conversation = null; _eventSub?.cancel(); _eventSub = null; final id = _paneId; @@ -132,8 +137,8 @@ class _ClaudePaneState extends State { _sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!); final tmuxConf = await _ensureTmuxConf(); - final cols = _terminal.viewWidth; - final rows = _terminal.viewHeight; + const cols = _cols; + const rows = _rows; var argv = [ 'tmux', @@ -188,34 +193,21 @@ class _ClaudePaneState extends State { if (!mounted) return; _paneId = resp.data['id'] as String?; + // Render the conversation natively from the transcript (T-137/D-75) + // rather than the PTY's TUI output. claude runs in tmux; we tail its + // transcript JSONL for the workspace. + _conversation = ConversationController.forWorkspace(repoRoot); _subscribe(); setState(() {}); } - // -- output batching ------------------------------------------------------ - - final _outputBuf = StringBuffer(); - Timer? _flushTimer; - - void _flushOutput() { - _flushTimer = null; - if (_outputBuf.isEmpty) return; - _terminal.write(_outputBuf.toString()); - _outputBuf.clear(); - } - void _subscribe() { final kernel = _kernel(); if (kernel == null) return; + // Lifecycle only — content comes from the transcript, not pane.output. _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) { - _outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true)); - _flushTimer ??= Timer(Duration.zero, _flushOutput); - } case 'pane.exit': setState(() => _statusLine = widget.isPrimary ? 'session exited — restart clide to retry' : 'session exited'); case 'pane.closed': @@ -224,43 +216,6 @@ class _ClaudePaneState extends State { }); } - // -- terminal callbacks --------------------------------------------------- - - void _onTerminalOutput(String text) { - final id = _paneId; - if (id == null) return; - _ipc()?.request('pane.write', args: {'id': id, 'text': text}); - } - - Timer? _resizeTimer; - - void _onTerminalResize(int cols, int rows, int _, int __) { - if (!_spawned) { - _spawned = true; - _spawnWhenReady(); - return; - } - _resizeTimer?.cancel(); - _resizeTimer = Timer(const Duration(milliseconds: 150), () { - final id = _paneId; - if (id == null) return; - _ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows}); - if (_sessionName != null) { - Process.run('tmux', [ - '-L', - 'clide', - 'resize-window', - '-t', - _sessionName!, - '-x', - '$cols', - '-y', - '$rows', - ]); - } - }); - } - // -- helpers -------------------------------------------------------------- DaemonClient? _ipc() => _kernel()?.ipc; @@ -279,12 +234,17 @@ class _ClaudePaneState extends State { 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, autofocus: true); + final Widget body; + if (_error != null) { + body = Padding( + padding: const EdgeInsets.all(16), + child: ClideText(_error!, muted: true), + ); + } else if (_conversation != null) { + body = ConversationView(controller: _conversation!); + } else { + body = const Center(child: ClideText('attaching…', muted: true)); + } if (!widget.showChrome) return body; diff --git a/lib/builtin/claude/src/conversation_controller.dart b/lib/builtin/claude/src/conversation_controller.dart new file mode 100644 index 00000000..2ccd6c46 --- /dev/null +++ b/lib/builtin/claude/src/conversation_controller.dart @@ -0,0 +1,53 @@ +/// Accumulates [ConversationItem]s from a transcript stream for the +/// native Claude conversation view (epic T-132, D-75). +/// +/// Thin [ChangeNotifier] over a `Stream` (normally +/// [TranscriptReader.stream]). Kept separate from the widget so it can +/// be unit-tested with a plain stream and reused per teammate panel +/// when the team work (T-139/T-140) lands. +library; + +import 'dart:async'; + +import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:flutter/foundation.dart'; + +class ConversationController extends ChangeNotifier { + /// Listens to [stream] and accumulates items. [onDispose] is invoked + /// from [dispose] — wire it to the reader's `dispose` so cancelling + /// the view tears down the underlying tail. + ConversationController({ + required Stream stream, + Future Function()? onDispose, + }) : _onDispose = onDispose { + _sub = stream.listen(_onItem); + } + + /// Convenience: build a controller backed by a live [TranscriptReader] + /// for [workspacePath]. + factory ConversationController.forWorkspace(String workspacePath) { + final reader = TranscriptReader(workspacePath); + return ConversationController(stream: reader.stream, onDispose: reader.dispose); + } + + final Future Function()? _onDispose; + late final StreamSubscription _sub; + final List _items = []; + + /// Items in arrival (transcript) order. + List get items => List.unmodifiable(_items); + + bool get isEmpty => _items.isEmpty; + + void _onItem(ConversationItem item) { + _items.add(item); + notifyListeners(); + } + + @override + void dispose() { + unawaited(_sub.cancel()); + unawaited(_onDispose?.call()); + super.dispose(); + } +} diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart new file mode 100644 index 00000000..2f88b23d --- /dev/null +++ b/lib/builtin/claude/src/conversation_view.dart @@ -0,0 +1,191 @@ +/// Native render of a Claude conversation from the transcript (epic +/// T-132, D-75) — replaces the terminal as the Claude display surface. +/// +/// Renders [ConversationItem]s (from a [ConversationController]) as +/// native cards: user messages, assistant markdown, thinking blocks, +/// tool-use and tool-result cards. The whole list sits under a single +/// [SelectionArea] so text selects + copies across cards (the one +/// terminal affordance we keep — see T-135). Input/composer is a +/// separate concern (T-138). +library; + +import 'dart:convert'; + +import 'package:clide/builtin/claude/src/conversation_controller.dart'; +import 'package:clide/builtin/claude/src/transcript_reader.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/widgets.dart'; + +class ConversationView extends StatefulWidget { + const ConversationView({super.key, required this.controller}); + + final ConversationController controller; + + @override + State createState() => _ConversationViewState(); +} + +class _ConversationViewState extends State { + final ScrollController _scroll = ScrollController(); + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onChanged); + } + + @override + void didUpdateWidget(ConversationView old) { + super.didUpdateWidget(old); + if (old.controller != widget.controller) { + old.controller.removeListener(_onChanged); + widget.controller.addListener(_onChanged); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onChanged); + _scroll.dispose(); + super.dispose(); + } + + void _onChanged() { + if (!mounted) return; + setState(() {}); + // Follow the tail — jump to the bottom after the new item lays out. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + }); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final items = widget.controller.items; + + if (items.isEmpty) { + return ColoredBox( + color: tokens.panelBackground, + child: const Center( + child: ClideText('Waiting for Claude…', muted: true), + ), + ); + } + + return ColoredBox( + color: tokens.panelBackground, + child: ClideSelectionArea( + child: ClideScrollbar( + controller: _scroll, + child: ListView.builder( + controller: _scroll, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: items.length, + itemBuilder: (context, i) => _ConversationTurn(item: items[i], tokens: tokens), + ), + ), + ), + ); + } +} + +/// One conversation item, rendered by kind. +class _ConversationTurn extends StatelessWidget { + const _ConversationTurn({required this.item, required this.tokens}); + + final ConversationItem item; + final SurfaceTokens tokens; + + @override + Widget build(BuildContext context) { + final i = item; + return switch (i) { + UserMessage() => _labelled('you', tokens.globalForeground, ClideMarkdown(i.text)), + AssistantTextMessage() => _labelled('claude', tokens.globalFocus, ClideMarkdown(i.text)), + AssistantThinkingMessage() => _labelled( + 'thinking', + tokens.globalTextMuted, + ClideText(i.thinking, muted: true, fontSize: clideFontMeta), + ), + AssistantToolUse() => _toolUse(i), + ToolResultMessage() => _toolResult(i), + }; + } + + /// A labelled turn: a small role tag above the body. + Widget _labelled(String label, Color labelColor, Widget body) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClideText( + label, + fontSize: clideFontSmall, + color: labelColor, + fontFamily: clideMonoFamily, + ), + const SizedBox(height: 4), + body, + ], + ), + ); + } + + Widget _toolUse(AssistantToolUse t) { + final pretty = const JsonEncoder.withIndent(' ').convert(t.input); + return _card( + borderColor: tokens.panelBorder, + header: Row( + children: [ + ClideText('›', color: tokens.globalFocus, fontFamily: clideMonoFamily), + const SizedBox(width: 6), + ClideText(t.name, fontWeight: FontWeight.w500, fontFamily: clideMonoFamily), + ], + ), + body: ClideCodeBlock(source: pretty, language: 'json'), + ); + } + + Widget _toolResult(ToolResultMessage t) { + final color = t.isError ? tokens.statusError : tokens.globalTextMuted; + return _card( + borderColor: t.isError ? tokens.statusError : tokens.panelBorder, + header: ClideText( + t.isError ? 'error' : 'result', + fontSize: clideFontSmall, + color: color, + fontFamily: clideMonoFamily, + ), + body: ClideText( + t.content, + fontSize: clideFontMeta, + fontFamily: clideMonoFamily, + color: tokens.globalForeground, + ), + ); + } + + Widget _card({required Color borderColor, required Widget header, required Widget body}) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: tokens.globalBackground, + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [header, const SizedBox(height: 6), body], + ), + ), + ); + } +} diff --git a/lib/widgets/src/clide_selection_area.dart b/lib/widgets/src/clide_selection_area.dart new file mode 100644 index 00000000..48a3a25d --- /dev/null +++ b/lib/widgets/src/clide_selection_area.dart @@ -0,0 +1,43 @@ +import 'package:flutter/widgets.dart'; + +/// No-Material text selection wrapper (D-7 — clide ships no Material). +/// +/// Flutter's convenient `SelectionArea` lives in `package:flutter/material.dart`, +/// so clide can't use it. This wraps the widget-layer [SelectableRegion] +/// with the desktop-appropriate setup: a managed [FocusNode], no on-screen +/// selection handles (`emptyTextSelectionControls` — desktop selects by +/// mouse drag), and [DefaultTextEditingShortcuts] so Ctrl/Cmd+A and +/// Ctrl/Cmd+C work even outside a `WidgetsApp`. +/// +/// Any descendant `Text` / `Text.rich` (e.g. [ClideMarkdown], +/// [ClideCodeBlock] after T-135) becomes selectable, and selection + +/// copy span across them. +class ClideSelectionArea extends StatefulWidget { + const ClideSelectionArea({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _ClideSelectionAreaState(); +} + +class _ClideSelectionAreaState extends State { + final FocusNode _focusNode = FocusNode(debugLabel: 'ClideSelectionArea'); + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DefaultTextEditingShortcuts( + child: SelectableRegion( + focusNode: _focusNode, + selectionControls: emptyTextSelectionControls, + child: widget.child, + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 83cb1a98..9e3432bd 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -20,6 +20,7 @@ export 'src/clide_pane_chrome.dart'; export 'src/clide_resize_border.dart'; export 'src/clide_pty_view.dart'; export 'src/clide_scrollbar.dart'; +export 'src/clide_selection_area.dart'; export 'src/clide_spine.dart'; export 'src/clide_surface.dart'; export 'src/clide_tab_bar.dart'; diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart new file mode 100644 index 00000000..c4cdcbdf --- /dev/null +++ b/test/builtin/claude/conversation_view_test.dart @@ -0,0 +1,151 @@ +/// Widget tests for the native Claude ConversationView + controller +/// (T-137): renders each transcript item kind as a card, and text +/// selects + copies across cards via ClideSelectionArea (the terminal +/// affordance we keep, T-135). +library; + +import 'dart:async'; + +import 'package:clide/builtin/claude/src/conversation_controller.dart'; +import 'package:clide/builtin/claude/src/conversation_view.dart'; +import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +final _t = DateTime.utc(2026, 1, 1); + +UserMessage _user(String text) => UserMessage(uuid: 'u', timestamp: _t, isSidechain: false, text: text); +AssistantTextMessage _asst(String text) => AssistantTextMessage(uuid: 'a', timestamp: _t, isSidechain: false, text: text); +AssistantThinkingMessage _think(String text) => AssistantThinkingMessage(uuid: 't', timestamp: _t, isSidechain: false, thinking: text); +AssistantToolUse _tool(String name, Map input) => + AssistantToolUse(uuid: 'tu', timestamp: _t, isSidechain: false, toolUseId: 'x1', name: name, input: input); +ToolResultMessage _result(String content, {bool isError = false}) => + ToolResultMessage(uuid: 'r', timestamp: _t, isSidechain: false, toolUseId: 'x1', content: content, isError: isError); + +class _MockClipboard { + Map _data = {'text': null}; + Future handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'Clipboard.setData': + _data = Map.from(call.arguments as Map); + case 'Clipboard.getData': + return _data; + case 'Clipboard.hasStrings': + final t = _data['text'] as String?; + return {'value': t != null && t.isNotEmpty}; + } + return null; + } + + String? get text => _data['text'] as String?; +} + +void main() { + group('ConversationController', () { + test('accumulates items from the stream and notifies', () async { + final ctrl = StreamController(); + final c = ConversationController(stream: ctrl.stream); + addTearDown(c.dispose); + var notifications = 0; + c.addListener(() => notifications++); + + expect(c.isEmpty, isTrue); + ctrl.add(_user('hi')); + ctrl.add(_asst('hello')); + await Future.delayed(Duration.zero); + + expect(c.items, hasLength(2)); + expect(c.items.first, isA()); + expect(notifications, 2); + await ctrl.close(); + }); + + test('onDispose is invoked on dispose', () async { + final ctrl = StreamController(); + var disposed = false; + final c = ConversationController(stream: ctrl.stream, onDispose: () async => disposed = true); + c.dispose(); + expect(disposed, isTrue); + await ctrl.close(); + }); + }); + + group('ConversationView', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + Future pumpWith(WidgetTester tester, List items) async { + tester.view.physicalSize = const Size(900, 700); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + final stream = StreamController.broadcast(); + final c = ConversationController(stream: stream.stream); + addTearDown(c.dispose); + await tester.pumpWidget(harness(f, ConversationView(controller: c))); + for (final it in items) { + stream.add(it); + } + await tester.pumpAndSettle(); + return c; + } + + testWidgets('empty controller shows the waiting hint', (tester) async { + await pumpWith(tester, const []); + expect(find.text('Waiting for Claude…'), findsOneWidget); + }); + + testWidgets('renders a card per item kind with role/tool labels', (tester) async { + await pumpWith(tester, [ + _user('a question'), + _asst('an answer'), + _think('hmm'), + _tool('Bash', {'command': 'ls'}), + _result('ok'), + _result('boom', isError: true), + ]); + expect(find.text('you'), findsOneWidget); + expect(find.text('claude'), findsOneWidget); + expect(find.text('thinking'), findsOneWidget); + expect(find.text('Bash'), findsOneWidget); + expect(find.text('result'), findsOneWidget); + expect(find.text('error'), findsOneWidget); + }); + + testWidgets('select-all + copy spans multiple cards', (tester) async { + final clipboard = _MockClipboard(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, clipboard.handleMethodCall); + addTearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + + await pumpWith(tester, [_user('question text'), _asst('answer text')]); + + // Focus the selection region, select all, copy. + final region = find.byType(ClideSelectionArea); + expect(region, findsOneWidget); + await tester.tap(region); + await tester.pump(); + Future keys(LogicalKeyboardKey k) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyDownEvent(k); + await tester.sendKeyUpEvent(k); + await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + await tester.pump(); + } + + await keys(LogicalKeyboardKey.keyA); + await keys(LogicalKeyboardKey.keyC); + await tester.pump(); + + final copied = clipboard.text ?? ''; + expect(copied, contains('question text')); + expect(copied, contains('answer text')); + }); + }); +}