From 8fd251ac48c1a0920ea4d1379f2ed99bc8e79b5e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 22 May 2026 23:36:18 +0200 Subject: [PATCH] add file/image paste to the Claude composer via native clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter's clipboard is text-only and tmux/send-keys carry text only, so a pasted file or image must reach Claude as an @path reference (per the T-134 spike). A native clide/clipboard MethodChannel reads the non-text clipboard: GTK (gtk_clipboard_wait_for_image/uris) on Linux, NSPasteboard on macOS. The composer overrides PasteTextIntent — Ctrl/Cmd+V resolves a file path or writes a clipboard image to a cache dir, inserts the @path, and falls back to plain-text paste otherwise. No new package dependency. macOS handler is written but unverified on this Linux box — needs a build on a Mac. Linux path builds and is covered by tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 + lib/builtin/claude/src/claude_pane.dart | 2 + lib/builtin/claude/src/clipboard_paste.dart | 97 +++++++++++++++++++ linux/runner/clide_app.cc | 62 ++++++++++++ macos/Runner/AppDelegate.swift | 30 ++++++ test/builtin/claude/claude_composer_test.dart | 27 ++++++ test/builtin/claude/clipboard_paste_test.dart | 92 ++++++++++++++++++ 7 files changed, 314 insertions(+) create mode 100644 lib/builtin/claude/src/clipboard_paste.dart create mode 100644 test/builtin/claude/clipboard_paste_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3cfb49..b5932f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. 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. +- 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). - 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_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 2a87856c..450a88d5 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; import 'claude_composer.dart'; +import 'clipboard_paste.dart'; import 'conversation_controller.dart'; import 'conversation_view.dart'; import 'session_naming.dart'; @@ -268,6 +269,7 @@ class _ClaudePaneState extends State { ClaudeComposer( enabled: _paneId != null, onSubmit: _send, + pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), ), ], ); diff --git a/lib/builtin/claude/src/clipboard_paste.dart b/lib/builtin/claude/src/clipboard_paste.dart new file mode 100644 index 00000000..26f44100 --- /dev/null +++ b/lib/builtin/claude/src/clipboard_paste.dart @@ -0,0 +1,97 @@ +/// File/image clipboard paste for the Claude composer (T-138). +/// +/// `tmux send-keys` / `pane.write` carry text only, and Claude reads +/// files via `@path` references — so a pasted file or image is always +/// delivered as an `@/absolute/path` token, never as bytes (see the +/// T-134 spike). Flutter's built-in clipboard is text-only, so image +/// and file reads go through a native [MethodChannel] (`clide/clipboard`, +/// implemented per-OS in the GTK and macOS runners). Plain-text paste +/// stays on the Flutter clipboard and is handled by the composer. +library; + +import 'dart:io'; + +import 'package:flutter/services.dart'; + +/// 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. +abstract interface class ClipboardSource { + /// Absolute paths of files currently on the clipboard (copied in a + /// file manager). Empty when there are none. + Future> readFiles(); + + /// PNG bytes of an image on the clipboard (e.g. a screenshot), or null + /// when there is no image. + Future readImage(); +} + +/// [ClipboardSource] backed by the native `clide/clipboard` channel. +/// Degrades to "nothing on the clipboard" when no platform handler is +/// registered (e.g. tests, unsupported platforms). +class NativeClipboard implements ClipboardSource { + const NativeClipboard(); + + static const _channel = MethodChannel('clide/clipboard'); + + @override + Future> readFiles() async { + try { + final r = await _channel.invokeListMethod('readFiles'); + return r ?? const []; + } on MissingPluginException { + return const []; + } + } + + @override + Future readImage() async { + try { + return await _channel.invokeMethod('readImage'); + } on MissingPluginException { + return null; + } + } +} + +/// Directory pasted-image temp files are written to. Mirrors the D-70 +/// socket-path convention: macOS `~/Library/Caches/clide/pasted`, else +/// `$XDG_CACHE_HOME` (or `~/.cache`) `/clide/pasted`. +String pasteCacheDir() { + final home = Platform.environment['HOME'] ?? '/tmp'; + if (Platform.isMacOS) { + return '$home/Library/Caches/clide/pasted'; + } + final xdg = Platform.environment['XDG_CACHE_HOME']; + final base = (xdg != null && xdg.isNotEmpty) ? xdg : '$home/.cache'; + 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. +/// +/// 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 +/// composer pastes text instead. +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(' '); + } + + final image = await source.readImage(); + if (image != null && image.isNotEmpty) { + final dir = tempDir ?? Directory(pasteCacheDir()); + await dir.create(recursive: true); + final file = File('${dir.path}/paste-${now().millisecondsSinceEpoch}.png'); + await file.writeAsBytes(image); + return '@${file.path}'; + } + + return null; +} diff --git a/linux/runner/clide_app.cc b/linux/runner/clide_app.cc index 977de7ca..0bff56e7 100644 --- a/linux/runner/clide_app.cc +++ b/linux/runner/clide_app.cc @@ -245,6 +245,68 @@ static void clide_app_activate(GApplication* application) { }, window, nullptr); + // T-138: method channel for native clipboard image/file reads. Flutter's + // built-in clipboard is text-only; the composer turns a pasted file or + // image into a Claude `@path` reference, which needs the non-text + // clipboard targets read here. + g_autoptr(FlStandardMethodCodec) clip_codec = fl_standard_method_codec_new(); + FlMethodChannel* clip_channel = fl_method_channel_new( + fl_engine_get_binary_messenger(engine), "clide/clipboard", + FL_METHOD_CODEC(clip_codec)); + g_object_set_data(G_OBJECT(window), "clide_clipboard_channel", clip_channel); + fl_method_channel_set_method_call_handler( + clip_channel, + [](FlMethodChannel* channel, FlMethodCall* method_call, + gpointer user_data) { + const gchar* method = fl_method_call_get_name(method_call); + g_autoptr(FlMethodResponse) response = nullptr; + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + + if (g_strcmp0(method, "readImage") == 0) { + GdkPixbuf* pixbuf = gtk_clipboard_wait_for_image(clipboard); + if (pixbuf != nullptr) { + gchar* buffer = nullptr; + gsize buffer_size = 0; + g_autoptr(GError) error = nullptr; + if (gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "png", + &error, nullptr)) { + g_autoptr(FlValue) val = + fl_value_new_uint8_list((const uint8_t*)buffer, buffer_size); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(val)); + g_free(buffer); + } else { + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_null())); + } + g_object_unref(pixbuf); + } else { + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_null())); + } + } else if (g_strcmp0(method, "readFiles") == 0) { + g_autoptr(FlValue) list = fl_value_new_list(); + gchar** uris = gtk_clipboard_wait_for_uris(clipboard); + if (uris != nullptr) { + for (int i = 0; uris[i] != nullptr; i++) { + g_autofree gchar* path = + g_filename_from_uri(uris[i], nullptr, nullptr); + if (path != nullptr) { + fl_value_append_take(list, fl_value_new_string(path)); + } + } + g_strfreev(uris); + } + response = + FL_METHOD_RESPONSE(fl_method_success_response_new(list)); + } else { + response = + FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + fl_method_call_respond(method_call, response, nullptr); + }, + window, nullptr); + gtk_widget_grab_focus(GTK_WIDGET(view)); } diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index db4272f8..bf1064f0 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -64,5 +64,35 @@ class AppDelegate: FlutterAppDelegate { result(FlutterMethodNotImplemented) } } + + // T-138: native clipboard image/file reads. Flutter's built-in + // clipboard is text-only; the composer turns a pasted file or image + // into a Claude `@path` reference, which needs the non-text pasteboard + // contents read here. + let clipboardChannel = FlutterMethodChannel( + name: "clide/clipboard", + binaryMessenger: flutterVC.engine.binaryMessenger) + + clipboardChannel.setMethodCallHandler { (call, result) in + let pasteboard = NSPasteboard.general + switch call.method { + case "readImage": + if let image = NSImage(pasteboard: pasteboard), + let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + result(FlutterStandardTypedData(bytes: png)) + } else { + result(nil) + } + case "readFiles": + let urls = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? [] + result(urls.map { $0.path }) + default: + result(FlutterMethodNotImplemented) + } + } } } diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index 40117945..659eb2f1 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -79,5 +79,32 @@ void main() { await pump(tester, enabled: false); expect(tester.widget(find.byType(EditableText)).readOnly, isTrue); }); + + testWidgets('Ctrl+V routes through the paste resolver and inserts its @path', (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. + await tester.pumpWidget(harness( + f, + DefaultTextEditingShortcuts( + child: ClaudeComposer( + onSubmit: (_) {}, + pasteResolver: () async => '@/tmp/shot.png', + ), + ), + )); + 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(); + + expect( + tester.widget(find.byType(EditableText)).controller.text, + '@/tmp/shot.png', + ); + }); }); } diff --git a/test/builtin/claude/clipboard_paste_test.dart b/test/builtin/claude/clipboard_paste_test.dart new file mode 100644 index 00000000..cded267f --- /dev/null +++ b/test/builtin/claude/clipboard_paste_test.dart @@ -0,0 +1,92 @@ +/// Tests for clipboard paste resolution (T-138): files become `@path` +/// tokens, a raw image is written to a temp file and referenced by +/// `@path`, and an empty clipboard falls back to text (null). Also +/// covers the NativeClipboard channel client's decode + missing-handler +/// guard. +library; + +import 'dart:io'; + +import 'package:clide/builtin/claude/src/clipboard_paste.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeSource implements ClipboardSource { + _FakeSource({this.files = const [], this.image}); + final List files; + final Uint8List? image; + @override + Future> readFiles() async => files; + @override + Future readImage() async => image; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('resolveClipboardAttachment', () { + test('files become space-joined @path tokens (no temp file)', () 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'); + }); + + test('a raw image is written to a temp file and referenced by @path', () 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]); + final fixedNow = DateTime.fromMillisecondsSinceEpoch(1700000000000); + + final result = await resolveClipboardAttachment( + _FakeSource(image: bytes), + tempDir: dir, + now: () => fixedNow, + ); + + final expectedPath = '${dir.path}/paste-1700000000000.png'; + expect(result, '@$expectedPath'); + expect(await File(expectedPath).readAsBytes(), bytes); + }); + + test('files take precedence over an image', () async { + final result = await resolveClipboardAttachment( + _FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])), + ); + expect(result, '@/x/y'); + }); + + test('empty clipboard returns null (fall back to text)', () async { + expect(await resolveClipboardAttachment(_FakeSource()), isNull); + }); + }); + + group('NativeClipboard', () { + const channel = MethodChannel('clide/clipboard'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test('readFiles decodes the native string list', () async { + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'readFiles'); + return ['/a/b', '/c/d']; + }); + expect(await const NativeClipboard().readFiles(), ['/a/b', '/c/d']); + }); + + test('readImage decodes native bytes', () async { + final bytes = Uint8List.fromList([1, 2, 3, 4]); + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'readImage'); + return bytes; + }); + expect(await const NativeClipboard().readImage(), bytes); + }); + + test('missing platform handler degrades to empty / null', () async { + // No mock handler registered -> MissingPluginException, swallowed. + expect(await const NativeClipboard().readFiles(), isEmpty); + expect(await const NativeClipboard().readImage(), isNull); + }); + }); +}