From 56161e4e9b17784558cba691117affb7d86a2a4c Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 16 Sep 2026 12:16:35 +0200 Subject: [PATCH] feat(ui): Bilder aus der Zwischenablage ins interaktive Terminal einfuegen Ctrl+V mit einem Bild in der Zwischenablage schreibt es als temporaeres PNG unter ~/.claudeDo/tmp/ und fuegt den Pfad in den Composer ein, genau wie ein Datei-Drop -- Claude liest die Datei selbst. Text-Paste laeuft unveraendert ueber den bestehenden bracketed-paste-Pfad. Avalonia 12.0.4 liest Clipboard-Bitmaps bereits selbst (IClipboard. TryGetDataAsync + AsyncDataTransferExtensions.TryGetBitmapAsync), kein Win32-P/Invoke noetig. Alte Tmp-Dateien (>24h) werden beim App-Start aufgeraeumt (ClaudeDo.App/Program.cs), keine eigene Aufraeum-Infrastruktur. --- src/ClaudeDo.App/Program.cs | 2 + .../Services/ClipboardImageTempStore.cs | 45 ++++++++++++++++ .../Views/InteractiveTerminalView.axaml.cs | 49 +++++++++++++++-- .../Services/ClipboardImageTempStoreTests.cs | 52 +++++++++++++++++++ 4 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs create mode 100644 tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index df630b64..381207fc 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -42,6 +42,8 @@ sealed class Program ClaudeDoDbContext.MigrateAndConfigure(db); } + ClipboardImageTempStore.DeleteOlderThan(TimeSpan.FromHours(24), DateTime.UtcNow); + try { ConfigureAppBuilder(AppBuilder.Configure(() => new App(services))) diff --git a/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs b/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs new file mode 100644 index 00000000..20479501 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using ClaudeDo.Data; + +namespace ClaudeDo.Ui.Services; + +/// +/// Temp PNGs written for clipboard-image paste into the interactive terminal (see +/// ) — dropped into the composer as a path, +/// the same way a file drop is, then read by Claude like any other file. +/// +public static class ClipboardImageTempStore +{ + public static string Directory => Path.Combine(Paths.AppDataRoot(), "tmp"); + + /// A fresh, collision-free path for one pasted image. + public static string NewPngPath(DateTime utcNow) => NewPngPath(Directory, utcNow); + + /// Deletes files older than under the tmp dir — called once at + /// app startup, not a running cleanup service. + public static void DeleteOlderThan(TimeSpan maxAge, DateTime utcNow) => DeleteOlderThan(Directory, maxAge, utcNow); + + /// Test seam for — takes the target dir explicitly + /// instead of the real app data root. + internal static string NewPngPath(string dir, DateTime utcNow) => + Path.Combine(dir, $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png"); + + /// Test seam for — takes the target dir + /// explicitly instead of the real app data root. + internal static void DeleteOlderThan(string dir, TimeSpan maxAge, DateTime utcNow) + { + if (!System.IO.Directory.Exists(dir)) return; + + foreach (var file in System.IO.Directory.EnumerateFiles(dir)) + { + try + { + if (utcNow - File.GetLastWriteTimeUtc(file) > maxAge) + File.Delete(file); + } + catch (IOException) { /* best effort */ } + catch (UnauthorizedAccessException) { /* best effort */ } + } + } +} diff --git a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs index 25d1d4b6..7d7bf9db 100644 --- a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Platform.Storage; +using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.Views; @@ -43,13 +45,54 @@ public partial class InteractiveTerminalView : UserControl if (DataContext is not InteractiveTerminalViewModel vm) return; e.Handled = true; - _ = PasteAndReportAsync(vm); + _ = PasteAndReportAsync(this, vm); } - private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm) + // A clipboard image (e.g. a screenshot) can't be typed into the child process, so it's written + // to a temp PNG and its path is inserted the same way a dropped file is — Claude reads the file + // itself. Plain-text clipboard content keeps going through the existing bracketed-paste flow. + // Once a bitmap is confirmed present, any failure (dir/file write, send) is a real paste + // failure — never silently fall back to a text paste of the same clipboard. + private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalView view, InteractiveTerminalViewModel vm) { - if (!await vm.PasteAsync()) + Avalonia.Media.Imaging.Bitmap? bitmap = null; + try + { + bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view)); + if (bitmap is null) + { + if (!await vm.PasteAsync()) vm.ReportSendFailed(); + return; + } + + var path = ClipboardImageTempStore.NewPngPath(DateTime.UtcNow); + Directory.CreateDirectory(ClipboardImageTempStore.Directory); + bitmap.Save(path); + if (!await vm.SendTextAsync(FormatDroppedPaths(new[] { path }))) + vm.ReportSendFailed(); + } + catch (Exception) + { vm.ReportSendFailed(); + } + finally + { + bitmap?.Dispose(); + } + } + + private static async System.Threading.Tasks.Task TryGetClipboardBitmapAsync(TopLevel? topLevel) + { + if (topLevel?.Clipboard is not { } clipboard) return null; + try + { + using var data = await clipboard.TryGetDataAsync(); + return data is null ? null : await Avalonia.Input.AsyncDataTransferExtensions.TryGetBitmapAsync(data); + } + catch (Exception) + { + return null; + } } private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true; diff --git a/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs b/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs new file mode 100644 index 00000000..f335ccdc --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs @@ -0,0 +1,52 @@ +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.Tests.Services; + +public class ClipboardImageTempStoreTests : IDisposable +{ + private readonly string _dir; + + public ClipboardImageTempStoreTests() + { + _dir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + public void Dispose() { try { Directory.Delete(_dir, true); } catch { } } + + [Fact] + public void NewPngPath_IsUniqueAndUnderTheGivenDir() + { + var now = new DateTime(2026, 9, 16, 10, 30, 0, DateTimeKind.Utc); + + var a = ClipboardImageTempStore.NewPngPath(_dir, now); + var b = ClipboardImageTempStore.NewPngPath(_dir, now); + + Assert.NotEqual(a, b); + Assert.Equal(_dir, Path.GetDirectoryName(a)); + Assert.Equal(".png", Path.GetExtension(a)); + } + + [Fact] + public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge() + { + var now = DateTime.UtcNow; + var oldFile = Path.Combine(_dir, "old.png"); + var freshFile = Path.Combine(_dir, "fresh.png"); + File.WriteAllText(oldFile, "x"); + File.WriteAllText(freshFile, "x"); + File.SetLastWriteTimeUtc(oldFile, now - TimeSpan.FromHours(25)); + File.SetLastWriteTimeUtc(freshFile, now - TimeSpan.FromHours(1)); + + ClipboardImageTempStore.DeleteOlderThan(_dir, TimeSpan.FromHours(24), now); + + Assert.False(File.Exists(oldFile)); + Assert.True(File.Exists(freshFile)); + } + + [Fact] + public void DeleteOlderThan_NonExistentDir_DoesNotThrow() + { + ClipboardImageTempStore.DeleteOlderThan(Path.Combine(_dir, "missing"), TimeSpan.FromHours(24), DateTime.UtcNow); + } +}