fix(claude-do): merge fix(ui): fehlgeschlagenes Einfügen/Droppen im interaktiven T

ClaudeDo-Task: 81931eef-4d05-49d0-a476-7346cba7fbe5
This commit is contained in:
mika kuns
2026-09-10 13:35:22 +02:00
9 changed files with 112 additions and 9 deletions
@@ -384,6 +384,7 @@
"conptyStarting": "Sitzung wird gestartet…",
"conptyExited": "Sitzung beendet (Code {0}).",
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
"terminalSendFailed": "Konnte nicht an die Terminal-Sitzung gesendet werden.",
"mergeHelperTitle": "Merge-Helfer",
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
"mergeHelperTaskTitle": "Listen-Handler: {0}",
@@ -384,6 +384,7 @@
"conptyStarting": "Starting session…",
"conptyExited": "Session ended (code {0}).",
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
"terminalSendFailed": "Couldn't send that to the terminal session.",
"mergeHelperTitle": "Merge Helper",
"mergeHelperHandoffTitleSuffix": " (Handoff)",
"mergeHelperTaskTitle": "List handler: {0}",
@@ -162,15 +162,19 @@ public sealed class PtyTerminalSession : IDisposable
/// writing the clipboard text raw, because that is what honours bracketed-paste mode — without
/// it a multi-line paste is read line by line and submits on the first newline.
/// </summary>
public async Task PasteAsync()
public async Task<bool> PasteAsync()
{
if (_control is not { } control || !IsRunning) return;
if (_control is not { } control || !IsRunning) return false;
try
{
if (s_terminalViewField?.GetValue(control) is TerminalView view)
await view.PasteAsync();
if (s_terminalViewField?.GetValue(control) is not TerminalView view) return false;
await view.PasteAsync();
return true;
}
catch (Exception)
{
return false;
}
catch (Exception) { /* best-effort, same as SendLineAsync */ }
}
// Long enough that the two writes can't be read as one chunk by the child's stdin reader.
@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using Iciclecreek.Terminal;
@@ -111,6 +112,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
}
}
/// <summary>Raised when a user-initiated paste or drop couldn't reach the session (write seam
/// gone, no running process) — mirrors the island ViewModels' ErrorReported, forwarded by
/// <see cref="ClaudeDo.Ui.ViewModels.MissionControl.ConPtyPaneViewModel"/> into the footer error strip.</summary>
public event Action<string>? ErrorReported;
/// <summary>Called by the view after a failed <see cref="PasteAsync"/>/<see cref="SendTextAsync"/>
/// so the failure surfaces through <see cref="ErrorReported"/> instead of vanishing silently.</summary>
public void ReportSendFailed() => ErrorReported?.Invoke(Loc.T("missionControl.terminalSendFailed"));
/// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> —
/// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all;
/// the worker turns it into a usage-limit auto-restart for list-handler sessions
@@ -156,8 +166,9 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
public Task<bool> SendTextAsync(string text) => _session.SendTextAsync(text);
/// <summary>Pastes the clipboard into the session (Ctrl+V) — see
/// <see cref="PtyTerminalSession.PasteAsync"/>.</summary>
public Task PasteAsync() => _session.PasteAsync();
/// <see cref="PtyTerminalSession.PasteAsync"/>. False when there is no running process to
/// paste into (or the write seam is gone).</summary>
public Task<bool> PasteAsync() => _session.PasteAsync();
public void Kill() => _session.Kill();
@@ -89,14 +89,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
{
terminal.PropertyChanged += OnTerminalPropertyChanged;
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
terminal.ErrorReported += OnTerminalErrorReported;
}
private void Unsubscribe(InteractiveTerminalViewModel terminal)
{
terminal.PropertyChanged -= OnTerminalPropertyChanged;
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
terminal.ErrorReported -= OnTerminalErrorReported;
}
private void OnTerminalErrorReported(string message) => ErrorReported?.Invoke(message);
private void OnTerminalExitedAfterStartup(int exitCode)
{
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode);
@@ -43,7 +43,13 @@ public partial class InteractiveTerminalView : UserControl
if (DataContext is not InteractiveTerminalViewModel vm) return;
e.Handled = true;
_ = vm.PasteAsync();
_ = PasteAndReportAsync(vm);
}
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm)
{
if (!await vm.PasteAsync())
vm.ReportSendFailed();
}
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
@@ -62,7 +68,13 @@ public partial class InteractiveTerminalView : UserControl
e.Handled = true;
var text = FormatDroppedPaths(e.DataTransfer!.TryGetFiles()?.Select(i => i.TryGetLocalPath()));
if (text.Length > 0) _ = vm.SendTextAsync(text);
if (text.Length > 0) _ = SendTextAndReportAsync(vm, text);
}
private static async System.Threading.Tasks.Task SendTextAndReportAsync(InteractiveTerminalViewModel vm, string text)
{
if (!await vm.SendTextAsync(text))
vm.ReportSendFailed();
}
/// <summary>Joins dropped file paths into one insertable chunk: quoted when they contain a
@@ -35,4 +35,14 @@ public class PtyTerminalSessionTests
Assert.Equal(0, gate.CurrentCount); // acquired
}
// Fix: PasteAsync used to be void and swallow every failure. Not-started is the cheap case to
// cover without a real TerminalControl/ConPTY spawn.
[Fact]
public async Task PasteAsync_ReturnsFalse_WhenNotStarted()
{
using var session = new PtyTerminalSession();
Assert.False(await session.PasteAsync());
}
}
@@ -219,6 +219,35 @@ public class ConPtyPaneViewModelTests
Assert.True(raised);
}
// ── Terminal.ErrorReported forwarding (fix: a failed paste/drop had no path to the footer
// error strip) ────────────────────────────────────────────────────────────────────────────
[Fact]
public void TerminalErrorReported_Forwards_ToPaneErrorReported()
{
using var pane = NewTaskPane(NeverCompletes);
string? error = null;
pane.ErrorReported += msg => error = msg;
pane.Terminal.ReportSendFailed();
Assert.NotNull(error);
}
[Fact]
public void TerminalErrorReported_StillWiredAfterRetry()
{
using var pane = NewTaskPane(() => Failing());
pane.Start();
pane.RetryCommand.Execute(null);
string? error = null;
pane.ErrorReported += msg => error = msg;
pane.Terminal.ReportSendFailed();
Assert.NotNull(error);
}
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
// Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch
@@ -124,4 +124,35 @@ public class InteractiveTerminalViewModelTests
terminal.IsRunning = true; // records LaunchedAt via the first clock call
return terminal;
}
// ── Paste/drop failure reporting (fix: PasteAsync used to swallow every exception and return
// void, so a failed Ctrl+V or file drop gave the user no signal at all) ────────────────────
[Fact]
public async Task PasteAsync_ReturnsFalse_WhenSessionNotRunning()
{
using var terminal = new InteractiveTerminalViewModel();
Assert.False(await terminal.PasteAsync());
}
[Fact]
public async Task SendTextAsync_ReturnsFalse_WhenSessionNotRunning()
{
using var terminal = new InteractiveTerminalViewModel();
Assert.False(await terminal.SendTextAsync("dropped/path"));
}
[Fact]
public void ReportSendFailed_RaisesErrorReported()
{
using var terminal = new InteractiveTerminalViewModel();
string? error = null;
terminal.ErrorReported += msg => error = msg;
terminal.ReportSendFailed();
Assert.NotNull(error);
}
}