add native composer to the Claude pane (T-138)
A no-Material EditableText below the ConversationView. Enter submits, Shift+Enter inserts a newline (a Shortcuts override maps plain Enter to a submit intent; shifted Enter falls through to the default newline). Submitted text goes to Claude over pane.write — the same input verb the terminal pane uses, so D-6 parity holds and there's no Claude-only path. Multi-line input is wrapped in bracketed-paste markers so the embedded newlines submit as a single message instead of a stream of submits. Paste handling is stubbed via an injectable resolver (file/image @path support lands in the next commit); plain-text paste falls through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <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;
|
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;
|
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-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;
|
||||||
|
|||||||
@@ -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 <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);
|
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);
|
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-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 <pane> (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);
|
||||||
|
|||||||
+5
-1
@@ -18,11 +18,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Added
|
### 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
|
- Claude pane renders natively from the transcript (T-137, D-75) — the
|
||||||
conversation shows as native cards (user / assistant markdown /
|
conversation shows as native cards (user / assistant markdown /
|
||||||
thinking / tool-use / result) instead of a terminal, with text
|
thinking / tool-use / result) instead of a terminal, with text
|
||||||
selection + copy across cards. Claude still runs in tmux; the terminal
|
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
|
- Multi-file editor tabs — the editor pane now shows one tab per open
|
||||||
buffer (filename + a dot when unsaved) via the shared tab strip;
|
buffer (filename + a dot when unsaved) via the shared tab strip;
|
||||||
opening a second file no longer replaces the first. Click a tab to
|
opening a second file no longer replaces the first. Click a tab to
|
||||||
|
|||||||
@@ -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<String?> Function()? pasteResolver;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClaudeComposer> createState() => _ClaudeComposerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||||
|
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<void> _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<SubmitComposerIntent>(
|
||||||
|
onInvoke: (_) {
|
||||||
|
_submit();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PasteTextIntent: CallbackAction<PasteTextIntent>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:clide/widgets/widgets.dart';
|
|||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'claude_composer.dart';
|
||||||
import 'conversation_controller.dart';
|
import 'conversation_controller.dart';
|
||||||
import 'conversation_view.dart';
|
import 'conversation_view.dart';
|
||||||
import 'session_naming.dart';
|
import 'session_naming.dart';
|
||||||
@@ -211,6 +212,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
setState(() {});
|
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() {
|
void _subscribe() {
|
||||||
final kernel = _kernel();
|
final kernel = _kernel();
|
||||||
if (kernel == null) return;
|
if (kernel == null) return;
|
||||||
@@ -251,7 +262,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
child: ClideText(_error!, muted: true),
|
child: ClideText(_error!, muted: true),
|
||||||
);
|
);
|
||||||
} else if (_conversation != null) {
|
} else if (_conversation != null) {
|
||||||
body = ConversationView(controller: _conversation!);
|
body = Column(
|
||||||
|
children: [
|
||||||
|
Expanded(child: ConversationView(controller: _conversation!)),
|
||||||
|
ClaudeComposer(
|
||||||
|
enabled: _paneId != null,
|
||||||
|
onSubmit: _send,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
body = const Center(child: ClideText('attaching…', muted: true));
|
body = const Center(child: ClideText('attaching…', muted: true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<List<String>> pump(WidgetTester tester, {bool enabled = true}) async {
|
||||||
|
final submitted = <String>[];
|
||||||
|
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<EditableText>(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<EditableText>(find.byType(EditableText)).readOnly, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user