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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user