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.
This commit is contained in:
Mika Kuns
2026-09-16 15:43:38 +02:00
committed by mika kuns
parent 4ff5383b35
commit 56161e4e9b
4 changed files with 145 additions and 3 deletions
+2
View File
@@ -42,6 +42,8 @@ sealed class Program
ClaudeDoDbContext.MigrateAndConfigure(db);
}
ClipboardImageTempStore.DeleteOlderThan(TimeSpan.FromHours(24), DateTime.UtcNow);
try
{
ConfigureAppBuilder(AppBuilder.Configure(() => new App(services)))
@@ -0,0 +1,45 @@
using System;
using System.IO;
using ClaudeDo.Data;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Temp PNGs written for clipboard-image paste into the interactive terminal (see
/// <see cref="ClaudeDo.Ui.Views.InteractiveTerminalView"/>) — dropped into the composer as a path,
/// the same way a file drop is, then read by Claude like any other file.
/// </summary>
public static class ClipboardImageTempStore
{
public static string Directory => Path.Combine(Paths.AppDataRoot(), "tmp");
/// <summary>A fresh, collision-free path for one pasted image.</summary>
public static string NewPngPath(DateTime utcNow) => NewPngPath(Directory, utcNow);
/// <summary>Deletes files older than <paramref name="maxAge"/> under the tmp dir — called once at
/// app startup, not a running cleanup service.</summary>
public static void DeleteOlderThan(TimeSpan maxAge, DateTime utcNow) => DeleteOlderThan(Directory, maxAge, utcNow);
/// <summary>Test seam for <see cref="NewPngPath(DateTime)"/> — takes the target dir explicitly
/// instead of the real app data root.</summary>
internal static string NewPngPath(string dir, DateTime utcNow) =>
Path.Combine(dir, $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png");
/// <summary>Test seam for <see cref="DeleteOlderThan(TimeSpan,DateTime)"/> — takes the target dir
/// explicitly instead of the real app data root.</summary>
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 */ }
}
}
}
@@ -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<Avalonia.Media.Imaging.Bitmap?> 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;