diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 65108742..5a392bf1 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2141,3 +2141,4 @@ Refinement (2026-05-22, refinement-agent review): 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; 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', 'in_progress', 'done', NULL, '2026-05-22 18:14:32', '2026-05-22 18:14:32', '2026-05-22 18:14:32', NULL, 'a284c7249dda3c63a52a202bad07f105', 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-138', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 21:08:05', '2026-05-22 21:08:05', '2026-05-22 21:08:05', NULL, 'c3dfefe24d912081cd89ac0be066b641', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index f2b20885..86924ae7 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2486,3 +2486,4 @@ Refinement (2026-05-22, refinement-agent review): 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); 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.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 18:14:32', NULL, '6651a2c3c9099f7d38553f6cf9e41e98', 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-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:08:05', NULL, '2e6b932ad78ac78d83c86c5e08290ed3', 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 b1d94d5d..5a3cfb49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Native composer in the Claude pane (T-138) — type below the + conversation and press Enter to send (Shift+Enter for a newline); + input reaches Claude over `pane.write`. Multi-line text is sent as a + bracketed paste so it submits as one message. - 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.) + builtin stays for general use. - 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_composer.dart b/lib/builtin/claude/src/claude_composer.dart new file mode 100644 index 00000000..8fca8640 --- /dev/null +++ b/lib/builtin/claude/src/claude_composer.dart @@ -0,0 +1,185 @@ +/// Native input composer for the Claude pane (epic T-132, T-138). +/// +/// A no-Material [EditableText] (D-7) below the [ConversationView]. +/// Enter submits; Shift+Enter inserts a newline. Submitted text is sent +/// to Claude's tmux session via `pane.write` (the same CLI verb the +/// terminal pane uses — D-6 parity), so there's no Claude-only input +/// path. File/image paste (the `@path` mechanism) is layered on top via +/// the paste-intent override; plain text paste falls through to the +/// default. +library; + +import 'dart:async'; + +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Encodes [text] for delivery to Claude's PTY via `pane.write`. +/// +/// Single-line input is sent literally followed by a carriage return +/// (submit). Multi-line input is wrapped in bracketed-paste markers so +/// the embedded newlines are treated as pasted content rather than a +/// stream of submits, then a trailing CR submits the whole block. +String encodeClaudeInput(String text) { + if (text.contains('\n')) { + return '\x1b[200~$text\x1b[201~\r'; + } + return '$text\r'; +} + +/// Intent fired when the user presses Enter (no modifiers) to submit. +class SubmitComposerIntent extends Intent { + const SubmitComposerIntent(); +} + +class ClaudeComposer extends StatefulWidget { + const ClaudeComposer({ + super.key, + required this.onSubmit, + this.enabled = true, + this.hint = 'Message Claude… (Enter to send · Shift+Enter for newline)', + this.pasteResolver, + }); + + /// Called with the raw composed text when the user submits. The text + /// is not yet PTY-encoded — the pane wraps it with [encodeClaudeInput]. + final void Function(String text) onSubmit; + + final bool enabled; + final String hint; + + /// Optional override of paste handling: given nothing, returns the + /// text to insert at the cursor (e.g. `@/path/to/file`) or null to + /// fall back to the default plain-text paste. Injected so the pane can + /// wire in native file/image clipboard support and tests can fake it. + final Future Function()? pasteResolver; + + @override + State createState() => _ClaudeComposerState(); +} + +class _ClaudeComposerState extends State { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focus = FocusNode(); + + @override + void initState() { + super.initState(); + _controller.addListener(_onTextChanged); + } + + @override + void dispose() { + _controller.removeListener(_onTextChanged); + _controller.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _onTextChanged() => setState(() {}); + + void _submit() { + if (!widget.enabled) return; + final text = _controller.text; + if (text.trim().isEmpty) return; + widget.onSubmit(text); + _controller.clear(); + } + + Future _handlePaste() async { + final resolver = widget.pasteResolver; + if (resolver != null) { + final inserted = await resolver(); + if (inserted != null) { + if (!mounted) return; + _insertAtCursor(inserted); + return; + } + } + // Fall back to the default plain-text paste. + final data = await Clipboard.getData(Clipboard.kTextPlain); + final clip = data?.text; + if (clip != null && clip.isNotEmpty && mounted) { + _insertAtCursor(clip); + } + } + + void _insertAtCursor(String insertion) { + final value = _controller.value; + final sel = value.selection; + final base = sel.isValid ? sel : TextSelection.collapsed(offset: value.text.length); + final newText = value.text.replaceRange(base.start, base.end, insertion); + final caret = base.start + insertion.length; + _controller.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: caret), + ); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final hasText = _controller.text.isNotEmpty; + final fg = widget.enabled ? tokens.globalForeground : tokens.globalTextMuted; + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 6, 10, 10), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: tokens.globalBorder), + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Semantics( + label: widget.hint, + textField: true, + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(), + }, + child: Actions( + actions: { + SubmitComposerIntent: CallbackAction( + onInvoke: (_) { + _submit(); + return null; + }, + ), + PasteTextIntent: CallbackAction( + onInvoke: (_) { + unawaited(_handlePaste()); + return null; + }, + ), + }, + child: Stack( + children: [ + if (!hasText) + Positioned( + left: 0, + top: 0, + right: 0, + child: ClideText(widget.hint, muted: true, fontSize: clideFontBody), + ), + EditableText( + controller: _controller, + focusNode: _focus, + readOnly: !widget.enabled, + style: TextStyle(fontSize: clideFontBody, color: fg, height: 1.4), + cursorColor: tokens.globalFocus, + backgroundCursorColor: tokens.globalTextMuted, + maxLines: 8, + minLines: 1, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index d750e8ff..2a87856c 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -7,6 +7,7 @@ import 'package:clide/widgets/widgets.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; +import 'claude_composer.dart'; import 'conversation_controller.dart'; import 'conversation_view.dart'; import 'session_naming.dart'; @@ -211,6 +212,16 @@ class _ClaudePaneState extends State { setState(() {}); } + // Send composed text to Claude's tmux session. pane.write delivers it + // to the PTY (the attached tmux client), which forwards to claude — the + // same input verb the terminal pane uses, so D-6 parity holds. + void _send(String text) { + final id = _paneId; + final ipc = _ipc(); + if (id == null || ipc == null) return; + unawaited(ipc.request('pane.write', args: {'id': id, 'text': encodeClaudeInput(text)})); + } + void _subscribe() { final kernel = _kernel(); if (kernel == null) return; @@ -251,7 +262,15 @@ class _ClaudePaneState extends State { child: ClideText(_error!, muted: true), ); } else if (_conversation != null) { - body = ConversationView(controller: _conversation!); + body = Column( + children: [ + Expanded(child: ConversationView(controller: _conversation!)), + ClaudeComposer( + enabled: _paneId != null, + onSubmit: _send, + ), + ], + ); } else { body = const Center(child: ClideText('attaching…', muted: true)); } diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart new file mode 100644 index 00000000..40117945 --- /dev/null +++ b/test/builtin/claude/claude_composer_test.dart @@ -0,0 +1,83 @@ +/// Tests for the native Claude input composer (T-138): Enter submits, +/// Shift+Enter does not, blank input is ignored, and submitted text is +/// PTY-encoded for `pane.write`. +library; + +import 'package:clide/builtin/claude/src/claude_composer.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + group('encodeClaudeInput', () { + test('single-line input gets a trailing carriage return', () { + expect(encodeClaudeInput('hello claude'), 'hello claude\r'); + }); + + test('multi-line input is wrapped in bracketed-paste markers', () { + expect( + encodeClaudeInput('line one\nline two'), + '\x1b[200~line one\nline two\x1b[201~\r', + ); + }); + }); + + group('ClaudeComposer', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + Future> pump(WidgetTester tester, {bool enabled = true}) async { + final submitted = []; + await tester.pumpWidget(harness( + f, + ClaudeComposer(enabled: enabled, onSubmit: submitted.add), + )); + return submitted; + } + + testWidgets('Enter submits the text and clears the field', (tester) async { + final submitted = await pump(tester); + await tester.enterText(find.byType(EditableText), 'hey there'); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(submitted, ['hey there']); + expect(tester.widget(find.byType(EditableText)).controller.text, isEmpty); + }); + + testWidgets('Shift+Enter does not submit', (tester) async { + final submitted = await pump(tester); + await tester.enterText(find.byType(EditableText), 'draft'); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + expect(submitted, isEmpty); + }); + + testWidgets('blank input is ignored', (tester) async { + final submitted = await pump(tester); + await tester.enterText(find.byType(EditableText), ' '); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(submitted, isEmpty); + }); + + testWidgets('disabled composer is read-only', (tester) async { + await pump(tester, enabled: false); + expect(tester.widget(find.byType(EditableText)).readOnly, isTrue); + }); + }); +}