From ce5996f869051012ec8b17e32a75d12e2150dbb4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 00:16:01 +0200 Subject: [PATCH] show pasted files/images as removable chips in the composer (T-142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting a file or image now adds a chip above the input instead of inserting the raw @path as editable text: an image thumbnail (Image.file of the cache/temp file, with an icon fallback) or a file icon plus the basename, each with a × to cancel it before sending. On submit the chips' @path tokens are appended to the typed text and the chips clear. resolveClipboardAttachment now returns ComposerAttachment descriptors (path + isImage) rather than a pre-joined token string, so the composer can render and manage each one. No new package dependency. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 1 + CHANGELOG.md | 9 +- lib/builtin/claude/src/claude_composer.dart | 212 +++++++++++++----- lib/builtin/claude/src/clipboard_paste.dart | 45 +++- test/builtin/claude/claude_composer_test.dart | 69 +++++- test/builtin/claude/clipboard_paste_test.dart | 25 ++- 7 files changed, 269 insertions(+), 93 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 85b19c53..f61514b0 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2143,3 +2143,4 @@ 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-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; 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', 'in_progress', 'done', NULL, '2026-05-22 21:49:09', '2026-05-22 21:49:09', '2026-05-22 21:49:09', NULL, 'e4be02e339d638da70a4e7ce0f850691', 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-142', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:09:09', '2026-05-22 22:09:09', '2026-05-22 22:09:09', NULL, 'b799657bf436bc44e306ef71658c80e5', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 713a33ff..53c71701 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2488,3 +2488,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, 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); 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.', 'done', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:49:09', NULL, '8b247ce6f9fc63c6f56030303aece2e8', 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-142', 'task', 'T-132', 'Composer attachment chips (thumbnail/icon on paste)', 'Show pasted files/images as chips above the composer input instead of inserting the @path as text. Image chips render a thumbnail (Image.file of the cache/temp file); other types show a Phosphor file icon + basename; each chip has a remove ×. On submit, the typed text plus the chips'' @path tokens are assembled into the message sent via tmux.sendMessage, then text + chips clear. Typed @path text still works. resolveClipboardAttachment changes to return a list of attachment descriptors (path + isImage). No new package deps (Image.file + existing Phosphor icons). Builds on T-138.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-22 22:06:59', '2026-05-22 22:09:09', NULL, 'da746aa3a523e3b351eaa13f19fa51e1', 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 dabfc824..0a8d7ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. Submits via the tmux server (bracketed paste + Enter), so input reaches Claude even when no tmux client is attached; multi-line goes as one message. -- File and image paste in the composer (T-138) — Ctrl/Cmd+V of a copied - file or a clipboard image inserts an `@path` reference (images are - saved to a cache dir first); plain text pastes inline. Backed by a - native `clide/clipboard` channel (GTK + macOS). +- File and image paste in the composer (T-138, T-142) — Ctrl/Cmd+V of a + copied file or clipboard image adds a removable chip (image thumbnail + or file icon) above the input; on send its `@path` is appended to the + message. Plain text pastes inline. Backed by a native `clide/clipboard` + channel (GTK + macOS). - 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 diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index 8fca8640..d4a18581 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -1,17 +1,19 @@ /// 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. +/// Enter submits; Shift+Enter inserts a newline. The composed message +/// (typed text plus any attachment `@path` tokens) is handed to +/// [ClaudeComposer.onSubmit]; the pane delivers it to Claude. Pasted +/// files/images show as removable chips (T-142); plain text paste falls +/// through to the default. library; import 'dart:async'; +import 'dart:io'; +import 'package:clide/builtin/claude/src/clipboard_paste.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/services.dart'; import 'package:flutter/widgets.dart'; @@ -43,18 +45,18 @@ class ClaudeComposer extends StatefulWidget { 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]. + /// Called with the composed message (typed text plus attachment `@path` + /// tokens) when the user submits. The pane delivers it to Claude. 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; + /// Optional override of paste handling: returns the attachments on the + /// clipboard (files / images), or an empty list 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(); @@ -63,6 +65,7 @@ class ClaudeComposer extends StatefulWidget { class _ClaudeComposerState extends State { final TextEditingController _controller = TextEditingController(); final FocusNode _focus = FocusNode(); + final List _attachments = []; @override void initState() { @@ -83,18 +86,25 @@ class _ClaudeComposerState extends State { void _submit() { if (!widget.enabled) return; final text = _controller.text; - if (text.trim().isEmpty) return; - widget.onSubmit(text); + final tokens = _attachments.map((a) => a.pathToken); + if (text.trim().isEmpty && _attachments.isEmpty) return; + // Typed text first, then the attachment @path references. + final message = [ + if (text.trim().isNotEmpty) text, + ...tokens, + ].join(' '); + widget.onSubmit(message); _controller.clear(); + setState(() => _attachments.clear()); } Future _handlePaste() async { final resolver = widget.pasteResolver; if (resolver != null) { - final inserted = await resolver(); - if (inserted != null) { + final attachments = await resolver(); + if (attachments.isNotEmpty) { if (!mounted) return; - _insertAtCursor(inserted); + setState(() => _attachments.addAll(attachments)); return; } } @@ -106,6 +116,10 @@ class _ClaudeComposerState extends State { } } + void _removeAttachment(ComposerAttachment attachment) { + setState(() => _attachments.remove(attachment)); + } + void _insertAtCursor(String insertion) { final value = _controller.value; final sel = value.selection; @@ -120,66 +134,140 @@ class _ClaudeComposerState extends State { @override Widget build(BuildContext context) { - final tokens = ClideTheme.of(context).surface; + final theme = ClideTheme.of(context).surface; final hasText = _controller.text.isNotEmpty; - final fg = widget.enabled ? tokens.globalForeground : tokens.globalTextMuted; + final fg = widget.enabled ? theme.globalForeground : theme.globalTextMuted; return Padding( padding: const EdgeInsets.fromLTRB(10, 6, 10, 10), child: Container( decoration: BoxDecoration( - border: Border.all(color: tokens.globalBorder), + border: Border.all(color: theme.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; - }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_attachments.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [for (final a in _attachments) _chip(theme, a)], ), - 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), + ), + 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; + }, ), - 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, + 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: theme.globalFocus, + backgroundCursorColor: theme.globalTextMuted, + maxLines: 8, + minLines: 1, + ), + ], ), - ], + ), ), ), - ), + ], ), ), ); } + + /// One attachment chip: a thumbnail (images) or file icon (other types), + /// the filename, and a remove × that cancels the attachment before send. + Widget _chip(SurfaceTokens theme, ComposerAttachment a) { + return Container( + constraints: const BoxConstraints(maxWidth: 220), + decoration: BoxDecoration( + color: theme.panelBackground, + border: Border.all(color: theme.globalBorder), + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.fromLTRB(6, 4, 4, 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _chipLeading(theme, a), + const SizedBox(width: 6), + Flexible( + child: ClideText( + a.fileName, + fontSize: clideFontSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + Semantics( + button: true, + label: 'Remove ${a.fileName}', + child: GestureDetector( + key: ValueKey('composer-remove-${a.path}'), + onTap: () => _removeAttachment(a), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: ClideIcon(PhosphorIcons.xMark, size: 12, color: theme.globalTextMuted), + ), + ), + ), + ], + ), + ); + } + + Widget _chipLeading(SurfaceTokens theme, ComposerAttachment a) { + const dim = 28.0; + if (a.isImage) { + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(a.path), + width: dim, + height: dim, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => ClideIcon(PhosphorIcons.image, size: 18, color: theme.globalTextMuted), + ), + ); + } + return ClideIcon(PhosphorIcons.fileText, size: 18, color: theme.globalTextMuted); + } } diff --git a/lib/builtin/claude/src/clipboard_paste.dart b/lib/builtin/claude/src/clipboard_paste.dart index 26f44100..adc6eb0d 100644 --- a/lib/builtin/claude/src/clipboard_paste.dart +++ b/lib/builtin/claude/src/clipboard_paste.dart @@ -13,6 +13,31 @@ import 'dart:io'; import 'package:flutter/services.dart'; +/// A pasted file or image the composer shows as a chip and sends to +/// Claude as an `@path` reference. +class ComposerAttachment { + const ComposerAttachment({required this.path, required this.isImage}); + + /// Absolute path on disk (a real file, or a temp file for a pasted + /// raw image). + final String path; + + /// Whether [path] is a raster image — chips render a thumbnail for + /// these and a file icon otherwise. + final bool isImage; + + /// The token inserted into the message Claude receives. + String get pathToken => '@$path'; + + /// Last path segment, for the chip label. + String get fileName => path.split('/').where((s) => s.isNotEmpty).lastOrNull ?? path; +} + +bool _looksLikeImage(String path) { + final p = path.toLowerCase(); + return p.endsWith('.png') || p.endsWith('.jpg') || p.endsWith('.jpeg') || p.endsWith('.gif') || p.endsWith('.webp') || p.endsWith('.bmp'); +} + /// Read side of the OS clipboard for the non-text content the composer /// turns into `@path` tokens. Abstracted so the resolver is testable /// without the platform channel. @@ -67,21 +92,23 @@ String pasteCacheDir() { return '$base/clide/pasted'; } -/// Resolve a paste into a string to insert at the composer's cursor, or -/// null to fall back to plain-text paste. +/// Resolve a paste into composer attachments, or an empty list to fall +/// back to plain-text paste. /// -/// Files already on disk become `@path` tokens directly. A raw image is -/// written to [tempDir] (default [pasteCacheDir]) and referenced by its -/// `@path`. Returns null when the clipboard holds neither, so the +/// Files already on disk become attachments directly. A raw image is +/// written to [tempDir] (default [pasteCacheDir]) and attached by its +/// path. Returns an empty list when the clipboard holds neither, so the /// composer pastes text instead. -Future resolveClipboardAttachment( +Future> resolveClipboardAttachment( ClipboardSource source, { Directory? tempDir, DateTime Function() now = DateTime.now, }) async { final files = await source.readFiles(); if (files.isNotEmpty) { - return files.map((p) => '@$p').join(' '); + return [ + for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p)), + ]; } final image = await source.readImage(); @@ -90,8 +117,8 @@ Future resolveClipboardAttachment( await dir.create(recursive: true); final file = File('${dir.path}/paste-${now().millisecondsSinceEpoch}.png'); await file.writeAsBytes(image); - return '@${file.path}'; + return [ComposerAttachment(path: file.path, isImage: true)]; } - return null; + return const []; } diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index 659eb2f1..e2eae26b 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -4,6 +4,7 @@ library; import 'package:clide/builtin/claude/src/claude_composer.dart'; +import 'package:clide/builtin/claude/src/clipboard_paste.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -80,7 +81,16 @@ void main() { expect(tester.widget(find.byType(EditableText)).readOnly, isTrue); }); - testWidgets('Ctrl+V routes through the paste resolver and inserts its @path', (tester) async { + Future pasteAttachment(WidgetTester tester) async { + tester.widget(find.byType(EditableText)).focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + await tester.pumpAndSettle(); + } + + testWidgets('Ctrl+V of a file shows a chip and leaves the text field empty', (tester) async { // WidgetsApp provides DefaultTextEditingShortcuts in the real app; // the bare harness doesn't, so wrap explicitly to map Ctrl+V -> // PasteTextIntent, which the composer's Actions override intercepts. @@ -89,22 +99,59 @@ void main() { DefaultTextEditingShortcuts( child: ClaudeComposer( onSubmit: (_) {}, - pasteResolver: () async => '@/tmp/shot.png', + pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)], ), ), )); - tester.widget(find.byType(EditableText)).focusNode.requestFocus(); - await tester.pump(); + await pasteAttachment(tester); - await tester.sendKeyDownEvent(LogicalKeyboardKey.control); - await tester.sendKeyEvent(LogicalKeyboardKey.keyV); - await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + expect(find.text('notes.txt'), findsOneWidget); + expect(tester.widget(find.byType(EditableText)).controller.text, isEmpty); + }); + + testWidgets('submit appends attachment @path tokens to the message', (tester) async { + final submitted = []; + await tester.pumpWidget(harness( + f, + DefaultTextEditingShortcuts( + child: ClaudeComposer( + onSubmit: submitted.add, + pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)], + ), + ), + )); + await tester.enterText(find.byType(EditableText), 'look at this'); + await pasteAttachment(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(EditableText)).controller.text, - '@/tmp/shot.png', - ); + expect(submitted, ['look at this @/tmp/notes.txt']); + // Chip cleared after send. + expect(find.text('notes.txt'), findsNothing); + }); + + testWidgets('remove × cancels the attachment before send', (tester) async { + final submitted = []; + await tester.pumpWidget(harness( + f, + DefaultTextEditingShortcuts( + child: ClaudeComposer( + onSubmit: submitted.add, + pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)], + ), + ), + )); + await pasteAttachment(tester); + expect(find.text('notes.txt'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('composer-remove-/tmp/notes.txt'))); + await tester.pumpAndSettle(); + expect(find.text('notes.txt'), findsNothing); + + await tester.enterText(find.byType(EditableText), 'just text'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(submitted, ['just text']); }); }); } diff --git a/test/builtin/claude/clipboard_paste_test.dart b/test/builtin/claude/clipboard_paste_test.dart index cded267f..baaf1a42 100644 --- a/test/builtin/claude/clipboard_paste_test.dart +++ b/test/builtin/claude/clipboard_paste_test.dart @@ -25,13 +25,15 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('resolveClipboardAttachment', () { - test('files become space-joined @path tokens (no temp file)', () async { + test('files become attachments with isImage by extension', () async { final source = _FakeSource(files: ['/home/u/a.txt', '/home/u/b.png']); final result = await resolveClipboardAttachment(source); - expect(result, '@/home/u/a.txt @/home/u/b.png'); + expect(result.map((a) => a.path), ['/home/u/a.txt', '/home/u/b.png']); + expect(result.map((a) => a.isImage), [false, true]); + expect(result.map((a) => a.pathToken), ['@/home/u/a.txt', '@/home/u/b.png']); }); - test('a raw image is written to a temp file and referenced by @path', () async { + test('a raw image is written to a temp file and attached', () async { final dir = await Directory.systemTemp.createTemp('clide-paste-test-'); addTearDown(() => dir.delete(recursive: true)); final bytes = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); @@ -44,7 +46,9 @@ void main() { ); final expectedPath = '${dir.path}/paste-1700000000000.png'; - expect(result, '@$expectedPath'); + expect(result, hasLength(1)); + expect(result.single.isImage, isTrue); + expect(result.single.path, expectedPath); expect(await File(expectedPath).readAsBytes(), bytes); }); @@ -52,11 +56,18 @@ void main() { final result = await resolveClipboardAttachment( _FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])), ); - expect(result, '@/x/y'); + expect(result.map((a) => a.path), ['/x/y']); }); - test('empty clipboard returns null (fall back to text)', () async { - expect(await resolveClipboardAttachment(_FakeSource()), isNull); + test('empty clipboard returns no attachments (fall back to text)', () async { + expect(await resolveClipboardAttachment(_FakeSource()), isEmpty); + }); + }); + + group('ComposerAttachment', () { + test('fileName is the last path segment', () { + expect(const ComposerAttachment(path: '/a/b/c.png', isImage: true).fileName, 'c.png'); + expect(const ComposerAttachment(path: 'bare.txt', isImage: false).fileName, 'bare.txt'); }); });