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:
2026-05-22 23:36:18 +02:00
co-authored by Claude Opus 4.7
parent b3fa69c982
commit 8fd251ac48
7 changed files with 314 additions and 0 deletions
+2
View File
@@ -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<ClaudePane> {
ClaudeComposer(
enabled: _paneId != null,
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;
}