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);
+ }
+}