feat(details): attach clipboard image on Ctrl+V in the task description

Extracts the clipboard bitmap read into a shared ClipboardBitmap helper
(also used by the terminal view); the description TextBox now routes an
image paste through AddFilesAsync as a unique paste-*.png attachment.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-10-02 10:31:47 +02:00
co-authored by Claude Sonnet 5.5
parent d8f78056c1
commit 393e59460d
6 changed files with 84 additions and 18 deletions
@@ -0,0 +1,26 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media.Imaging;
namespace ClaudeDo.Ui.Services;
/// <summary>Reads a bitmap off the clipboard; shared by the terminal view and the description editor.</summary>
public static class ClipboardBitmap
{
/// <summary>The clipboard bitmap (caller disposes), or null if there is none or the read failed.</summary>
public static async Task<Bitmap?> TryGetAsync(TopLevel? topLevel)
{
if (topLevel?.Clipboard is not { } clipboard) return null;
try
{
using var data = await clipboard.TryGetDataAsync();
return data is null ? null : await AsyncDataTransferExtensions.TryGetBitmapAsync(data);
}
catch (Exception)
{
return null;
}
}
}
@@ -22,8 +22,11 @@ public static class ClipboardImageTempStore
/// <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");
internal static string NewPngPath(string dir, DateTime utcNow) => Path.Combine(dir, NewPngFileName(utcNow));
/// <summary>A fresh, collision-free <c>paste-….png</c> file name (also used as an attachment name).</summary>
public static string NewPngFileName(DateTime utcNow) =>
$"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>
@@ -67,7 +67,7 @@ public partial class InteractiveTerminalView : UserControl
Avalonia.Media.Imaging.Bitmap? bitmap = null;
try
{
bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view));
bitmap = await ClipboardBitmap.TryGetAsync(TopLevel.GetTopLevel(view));
if (bitmap is null)
{
if (!await vm.PasteAsync()) vm.ReportSendFailed();
@@ -90,20 +90,6 @@ public partial class InteractiveTerminalView : UserControl
}
}
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;
private void OnDragOver(object? sender, DragEventArgs e)
@@ -103,7 +103,8 @@
<!-- Description -->
<Panel IsVisible="{Binding IsDescriptionSection}">
<!-- Edit mode: raw TextBox -->
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
<TextBox x:Name="DescriptionBox"
Text="{Binding EditableDescription, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="80"
@@ -3,6 +3,7 @@ using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands.Detail;
@@ -12,6 +13,41 @@ public partial class DescriptionStepsCard : UserControl
public DescriptionStepsCard()
{
InitializeComponent();
// Tunnel: the TextBox handles Ctrl+V itself (text only), so an image paste has to be caught first.
DescriptionBox.AddHandler(KeyDownEvent, OnDescriptionPreviewKeyDown, RoutingStrategies.Tunnel);
}
private void OnDescriptionPreviewKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.V || !e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Alt))
return;
// The clipboard read is async, so swallow the key and replay the text paste when there is no image.
e.Handled = true;
_ = PasteAsync();
}
/// <summary>Attaches a clipboard bitmap as a PNG file; anything else falls back to the normal text paste.</summary>
private async System.Threading.Tasks.Task PasteAsync()
{
try
{
using var bitmap = await ClipboardBitmap.TryGetAsync(TopLevel.GetTopLevel(this));
if (bitmap is null || DataContext is not DetailsIslandViewModel vm)
{
DescriptionBox.Paste();
return;
}
using var png = new System.IO.MemoryStream();
bitmap.Save(png);
png.Position = 0;
await vm.AddFilesAsync(new[] { (ClipboardImageTempStore.NewPngFileName(DateTime.UtcNow), (System.IO.Stream)png) });
}
catch (Exception)
{
// Paste is best-effort; a failed clipboard/encode must not surface as an unhandled UI exception.
}
}
private async void OnCopyClick(object? sender, RoutedEventArgs e)