render the Claude pane natively from the transcript (T-137)
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 27s
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 27s
The Phase-1 wedge of epic T-132 (D-75): the Claude pane no longer renders the PTY's TUI. It runs claude in tmux as before (so the transcript is written) but displays the conversation as native cards read from the transcript via TranscriptReader — user / assistant markdown / thinking / tool-use / tool-result. The whole list sits under a new no-Material ClideSelectionArea (SelectableRegion-based, since Flutter's SelectionArea is Material and D-7 bans it), so text selects and copies across cards — recovering the terminal's one real advantage. ClaudePane drops its Terminal model and the resize-driven spawn trigger (spawn now fires once on didChangeDependencies with a fixed tmux size, since the TUI isn't shown); pane.output is no longer consumed. The terminal builtin is untouched and still available as a general tool. Input/composer is the next ticket (T-138). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 <sid>/subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams/<team>/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: <munged>/<session-uuid>/subagents/agent-<id>.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 <sid>/subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams/<team>/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: <munged>/<session-uuid>/subagents/agent-<id>.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 <munged>/<sid>/subagents/agent-<hex>.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;
|
||||
|
||||
@@ -2469,3 +2469,19 @@ Refinement (2026-05-22, refinement-agent review): READY. Verified against real t
|
||||
- Subagent transcripts: <munged>/<session-uuid>/subagents/agent-<id>.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 <sid>/subagents/agent-*.jsonl grows. 2) Lifecycle: tmux -L clide -C control-mode pane-add/pane-died events vs polling list-panes. 3) ~/.claude/teams/<team>/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: <munged>/<session-uuid>/subagents/agent-<id>.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 <munged>/<sid>/subagents/agent-<hex>.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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ClaudePane> {
|
||||
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<DaemonEvent>? _eventSub;
|
||||
ConversationController? _conversation;
|
||||
String? _paneId;
|
||||
String? _sessionName;
|
||||
String? _error;
|
||||
@@ -42,19 +46,20 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
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<ClaudePane> {
|
||||
_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 = <String>[
|
||||
'tmux',
|
||||
@@ -188,34 +193,21 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
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<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) {
|
||||
_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<ClaudePane> {
|
||||
});
|
||||
}
|
||||
|
||||
// -- 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<ClaudePane> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<ConversationItem>` (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<ConversationItem> stream,
|
||||
Future<void> 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<void> Function()? _onDispose;
|
||||
late final StreamSubscription<ConversationItem> _sub;
|
||||
final List<ConversationItem> _items = [];
|
||||
|
||||
/// Items in arrival (transcript) order.
|
||||
List<ConversationItem> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ConversationView> createState() => _ConversationViewState();
|
||||
}
|
||||
|
||||
class _ConversationViewState extends State<ConversationView> {
|
||||
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],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ClideSelectionArea> createState() => _ClideSelectionAreaState();
|
||||
}
|
||||
|
||||
class _ClideSelectionAreaState extends State<ClideSelectionArea> {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> _data = {'text': null};
|
||||
Future<Object?> handleMethodCall(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case 'Clipboard.setData':
|
||||
_data = Map<String, dynamic>.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<ConversationItem>();
|
||||
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<void>.delayed(Duration.zero);
|
||||
|
||||
expect(c.items, hasLength(2));
|
||||
expect(c.items.first, isA<UserMessage>());
|
||||
expect(notifications, 2);
|
||||
await ctrl.close();
|
||||
});
|
||||
|
||||
test('onDispose is invoked on dispose', () async {
|
||||
final ctrl = StreamController<ConversationItem>();
|
||||
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<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
final stream = StreamController<ConversationItem>.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<void> 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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user