fix(ui): report failed paste/drop in the interactive terminal

PasteAsync silently swallowed every exception and returned void, so a
package update renaming Iciclecreek's private write seam would break
Ctrl+V/drop with zero user-visible signal. PasteAsync/SendTextAsync now
return bool (mirroring SendLineAsync), and the view checks the result,
routing a failure through InteractiveTerminalViewModel.ErrorReported ->
ConPtyPaneViewModel.ErrorReported -> the existing footer error strip.
This commit is contained in:
mika kuns
2026-09-10 13:35:16 +02:00
parent acff815706
commit 0cec0a77ee
9 changed files with 112 additions and 9 deletions
@@ -384,6 +384,7 @@
"conptyStarting": "Sitzung wird gestartet…", "conptyStarting": "Sitzung wird gestartet…",
"conptyExited": "Sitzung beendet (Code {0}).", "conptyExited": "Sitzung beendet (Code {0}).",
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.", "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", "mergeHelperTitle": "Merge-Helfer",
"mergeHelperHandoffTitleSuffix": " (Übergabe)", "mergeHelperHandoffTitleSuffix": " (Übergabe)",
"mergeHelperTaskTitle": "Listen-Handler: {0}", "mergeHelperTaskTitle": "Listen-Handler: {0}",
@@ -384,6 +384,7 @@
"conptyStarting": "Starting session…", "conptyStarting": "Starting session…",
"conptyExited": "Session ended (code {0}).", "conptyExited": "Session ended (code {0}).",
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.", "usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
"terminalSendFailed": "Couldn't send that to the terminal session.",
"mergeHelperTitle": "Merge Helper", "mergeHelperTitle": "Merge Helper",
"mergeHelperHandoffTitleSuffix": " (Handoff)", "mergeHelperHandoffTitleSuffix": " (Handoff)",
"mergeHelperTaskTitle": "List handler: {0}", "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 /// 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. /// it a multi-line paste is read line by line and submits on the first newline.
/// </summary> /// </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 try
{ {
if (s_terminalViewField?.GetValue(control) is TerminalView view) if (s_terminalViewField?.GetValue(control) is not TerminalView view) return false;
await view.PasteAsync(); 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. // 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;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using Iciclecreek.Terminal; 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"/> — /// <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; /// 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 /// 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); public Task<bool> SendTextAsync(string text) => _session.SendTextAsync(text);
/// <summary>Pastes the clipboard into the session (Ctrl+V) — see /// <summary>Pastes the clipboard into the session (Ctrl+V) — see
/// <see cref="PtyTerminalSession.PasteAsync"/>.</summary> /// <see cref="PtyTerminalSession.PasteAsync"/>. False when there is no running process to
public Task PasteAsync() => _session.PasteAsync(); /// paste into (or the write seam is gone).</summary>
public Task<bool> PasteAsync() => _session.PasteAsync();
public void Kill() => _session.Kill(); public void Kill() => _session.Kill();
@@ -89,14 +89,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
{ {
terminal.PropertyChanged += OnTerminalPropertyChanged; terminal.PropertyChanged += OnTerminalPropertyChanged;
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup; terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
terminal.ErrorReported += OnTerminalErrorReported;
} }
private void Unsubscribe(InteractiveTerminalViewModel terminal) private void Unsubscribe(InteractiveTerminalViewModel terminal)
{ {
terminal.PropertyChanged -= OnTerminalPropertyChanged; terminal.PropertyChanged -= OnTerminalPropertyChanged;
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup; terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
terminal.ErrorReported -= OnTerminalErrorReported;
} }
private void OnTerminalErrorReported(string message) => ErrorReported?.Invoke(message);
private void OnTerminalExitedAfterStartup(int exitCode) private void OnTerminalExitedAfterStartup(int exitCode)
{ {
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, 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; if (DataContext is not InteractiveTerminalViewModel vm) return;
e.Handled = true; 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; private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
@@ -62,7 +68,13 @@ public partial class InteractiveTerminalView : UserControl
e.Handled = true; e.Handled = true;
var text = FormatDroppedPaths(e.DataTransfer!.TryGetFiles()?.Select(i => i.TryGetLocalPath())); 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 /// <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 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); 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; 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 // 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 terminal.IsRunning = true; // records LaunchedAt via the first clock call
return terminal; 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);
}
} }