diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 356d064d..2bb0b81e 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -348,6 +348,7 @@
"closeSession": "Sitzung beenden",
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
"conptyStarting": "Sitzung wird gestartet…",
+ "conptyExited": "Sitzung beendet (Code {0}).",
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
"mergeHelperTitle": "Merge-Helfer",
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index a52b1569..c013ac84 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -348,6 +348,7 @@
"closeSession": "Kill session",
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
"conptyStarting": "Starting session…",
+ "conptyExited": "Session ended (code {0}).",
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
"mergeHelperTitle": "Merge Helper",
"mergeHelperHandoffTitleSuffix": " (Handoff)",
diff --git a/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs
index b0fed886..370d6906 100644
--- a/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs
@@ -36,14 +36,29 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
/// can show a spinner instead of an empty black pane.
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
+ /// True once the process has died without a already covering it —
+ /// i.e. every death that isn't a launch-time failure: a crash after running a while, a clean exit,
+ /// or a quiet exit inside the startup grace window. Drives the host's "session ended" banner so a
+ /// dead pane never looks like it's still running/thinking.
+ public bool HasExitedAfterStartup => HasExited && StartError is null;
+
partial void OnIsRunningChanged(bool value)
{
OnPropertyChanged(nameof(IsStarting));
if (value) _startedAtUtc = _utcNow();
}
- partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
- partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
+ partial void OnHasExitedChanged(bool value)
+ {
+ OnPropertyChanged(nameof(IsStarting));
+ OnPropertyChanged(nameof(HasExitedAfterStartup));
+ }
+
+ partial void OnStartErrorChanged(string? value)
+ {
+ OnPropertyChanged(nameof(IsStarting));
+ OnPropertyChanged(nameof(HasExitedAfterStartup));
+ }
public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow)
{
diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
index 5737ccd9..bf9b789b 100644
--- a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
@@ -2,6 +2,7 @@ using System;
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.MissionControl;
@@ -137,8 +138,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
SubmitForReviewCommand.NotifyCanExecuteChanged();
RetryCommand.NotifyCanExecuteChanged();
}
+
+ if (e.PropertyName is nameof(InteractiveTerminalViewModel.HasExitedAfterStartup)
+ or nameof(InteractiveTerminalViewModel.ExitCode))
+ {
+ OnPropertyChanged(nameof(ExitedBannerText));
+ }
}
+ /// "Session ended (code N)" text for —
+ /// computed here (rather than formatted in XAML) since loc:Tr has no binding-arg support.
+ public string ExitedBannerText => Loc.T("missionControl.conptyExited", Terminal.ExitCode ?? 0);
+
/// Types a message into this pane's live session and submits it (the worker's
/// pre-usage-limit warning). False when the session isn't running.
public System.Threading.Tasks.Task SendLineAsync(string text) => Terminal.SendLineAsync(text);
@@ -167,9 +178,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged();
- // A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
- // re-opening the same task would otherwise just re-focus a dead tile.
- private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
+ // A dead session permanently occupies the TaskId dedupe slot unless the user can retry — whether
+ // it died at launch (StartError) or after (HasExitedAfterStartup), the same descriptor factory
+ // can always relaunch it, so any HasExited state is retryable.
+ private bool CanRetry() => Terminal.HasExited;
[RelayCommand(CanExecute = nameof(CanRetry))]
private void Retry()
diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml
index f6048afb..abf6854e 100644
--- a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml
+++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml
@@ -55,6 +55,27 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs
index 2b1d1a4a..6746d6e1 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs
@@ -151,6 +151,30 @@ public class ConPtyPaneViewModelTests
Assert.True(pane.RetryCommand.CanExecute(null));
}
+ // ── HasExitedAfterStartup banner (fix: a mid-run death used to leave a silent dead tile — no
+ // StartError, no signal at all) ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RetryCommand_Enabled_AfterDeathAfterStartup()
+ {
+ using var pane = NewTaskPane(NeverCompletes);
+
+ DieAfterRunning(pane, exitCode: 1);
+
+ Assert.True(pane.Terminal.HasExitedAfterStartup);
+ Assert.True(pane.RetryCommand.CanExecute(null));
+ }
+
+ [Fact]
+ public void ExitedBannerText_IncludesExitCode()
+ {
+ using var pane = NewTaskPane(NeverCompletes);
+
+ DieAfterRunning(pane, exitCode: 42);
+
+ Assert.Contains("42", pane.ExitedBannerText);
+ }
+
// ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ────────────────────
[Fact]
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/InteractiveTerminalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/InteractiveTerminalViewModelTests.cs
index 4d6b7a5d..cdbad1c1 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/InteractiveTerminalViewModelTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/InteractiveTerminalViewModelTests.cs
@@ -36,6 +36,7 @@ public class InteractiveTerminalViewModelTests
Assert.True(terminal.HasExited);
Assert.Null(terminal.StartError);
+ Assert.True(terminal.HasExitedAfterStartup); // no StartError banner, so the host needs this signal instead
}
[Fact]
@@ -48,6 +49,30 @@ public class InteractiveTerminalViewModelTests
Assert.True(terminal.HasExited);
Assert.Null(terminal.StartError);
+ Assert.True(terminal.HasExitedAfterStartup);
+ }
+
+ // ── HasExitedAfterStartup (drives the host's "session ended" banner — every death that
+ // ISN'T already covered by StartError) ─────────────────────────────────────────────────
+
+ [Fact]
+ public void FreshTerminal_HasExitedAfterStartupIsFalse()
+ {
+ using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
+
+ Assert.False(terminal.HasExitedAfterStartup);
+ }
+
+ [Fact]
+ public void ProcessExitsNonzero_WithinGraceWindow_HasExitedAfterStartupIsFalse()
+ {
+ using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
+ terminal.IsRunning = true;
+
+ terminal.OnSessionProcessExited(null, 1); // startup failure — StartError covers it instead
+
+ Assert.NotNull(terminal.StartError);
+ Assert.False(terminal.HasExitedAfterStartup);
}
// ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile;