From 0cec0a77ee6a64986bf1d4311d8fcc4e28fb18f7 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 10 Sep 2026 13:23:11 +0200 Subject: [PATCH] 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. --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../Services/PtyTerminalSession.cs | 14 ++++++--- .../InteractiveTerminalViewModel.cs | 15 +++++++-- .../MissionControl/ConPtyPaneViewModel.cs | 4 +++ .../Views/InteractiveTerminalView.axaml.cs | 16 ++++++++-- .../Services/PtyTerminalSessionTests.cs | 10 ++++++ .../ConPtyPaneViewModelTests.cs | 29 +++++++++++++++++ .../InteractiveTerminalViewModelTests.cs | 31 +++++++++++++++++++ 9 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 9cb17e74..2d079d85 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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}", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 729be63e..8f71e486 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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}", diff --git a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs index 597469df..ee8a8bf9 100644 --- a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs +++ b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs @@ -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. /// - public async Task PasteAsync() + public async Task 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. diff --git a/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs index 370d6906..a01dcf67 100644 --- a/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs @@ -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 } } + /// 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 + /// into the footer error strip. + public event Action? ErrorReported; + + /// Called by the view after a failed / + /// so the failure surfaces through instead of vanishing silently. + public void ReportSendFailed() => ErrorReported?.Invoke(Loc.T("missionControl.terminalSendFailed")); + /// Raised when the process died non-zero *after* clearing — /// 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 SendTextAsync(string text) => _session.SendTextAsync(text); /// Pastes the clipboard into the session (Ctrl+V) — see - /// . - public Task PasteAsync() => _session.PasteAsync(); + /// . False when there is no running process to + /// paste into (or the write seam is gone). + public Task PasteAsync() => _session.PasteAsync(); public void Kill() => _session.Kill(); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs index bf9b789b..abe55406 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs @@ -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); diff --git a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs index c84e9558..25d1d4b6 100644 --- a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs @@ -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(); } /// Joins dropped file paths into one insertable chunk: quoted when they contain a diff --git a/tests/ClaudeDo.Ui.Tests/Services/PtyTerminalSessionTests.cs b/tests/ClaudeDo.Ui.Tests/Services/PtyTerminalSessionTests.cs index 5e6f23fc..39ab5095 100644 --- a/tests/ClaudeDo.Ui.Tests/Services/PtyTerminalSessionTests.cs +++ b/tests/ClaudeDo.Ui.Tests/Services/PtyTerminalSessionTests.cs @@ -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()); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs index 6746d6e1..72c92028 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs @@ -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 diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/InteractiveTerminalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/InteractiveTerminalViewModelTests.cs index cdbad1c1..b114cea4 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/InteractiveTerminalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/InteractiveTerminalViewModelTests.cs @@ -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); + } }