add file/image paste to the Claude composer via native clipboard
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
conversation and press Enter to send (Shift+Enter for a newline);
|
||||||
input reaches Claude over `pane.write`. Multi-line text is sent as a
|
input reaches Claude over `pane.write`. Multi-line text is sent as a
|
||||||
bracketed paste so it submits as one message.
|
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
|
- 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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart' show rootBundle;
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'claude_composer.dart';
|
import 'claude_composer.dart';
|
||||||
|
import 'clipboard_paste.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';
|
||||||
@@ -268,6 +269,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
ClaudeComposer(
|
ClaudeComposer(
|
||||||
enabled: _paneId != null,
|
enabled: _paneId != null,
|
||||||
onSubmit: _send,
|
onSubmit: _send,
|
||||||
|
pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<List<String>> readFiles();
|
||||||
|
|
||||||
|
/// PNG bytes of an image on the clipboard (e.g. a screenshot), or null
|
||||||
|
/// when there is no image.
|
||||||
|
Future<Uint8List?> 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<List<String>> readFiles() async {
|
||||||
|
try {
|
||||||
|
final r = await _channel.invokeListMethod<String>('readFiles');
|
||||||
|
return r ?? const [];
|
||||||
|
} on MissingPluginException {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> readImage() async {
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod<Uint8List>('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<String?> 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;
|
||||||
|
}
|
||||||
@@ -245,6 +245,68 @@ static void clide_app_activate(GApplication* application) {
|
|||||||
},
|
},
|
||||||
window, nullptr);
|
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));
|
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,5 +64,35 @@ class AppDelegate: FlutterAppDelegate {
|
|||||||
result(FlutterMethodNotImplemented)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,5 +79,32 @@ void main() {
|
|||||||
await pump(tester, enabled: false);
|
await pump(tester, enabled: false);
|
||||||
expect(tester.widget<EditableText>(find.byType(EditableText)).readOnly, isTrue);
|
expect(tester.widget<EditableText>(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<EditableText>(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<EditableText>(find.byType(EditableText)).controller.text,
|
||||||
|
'@/tmp/shot.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> files;
|
||||||
|
final Uint8List? image;
|
||||||
|
@override
|
||||||
|
Future<List<String>> readFiles() async => files;
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> 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 <String>['/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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user