diff --git a/src/Netclaw.Cli.Tests/Tui/InitWizardPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InitWizardPageTests.cs
index a4efea61e..5e443ae50 100644
--- a/src/Netclaw.Cli.Tests/Tui/InitWizardPageTests.cs
+++ b/src/Netclaw.Cli.Tests/Tui/InitWizardPageTests.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -103,6 +103,26 @@ public async Task DownArrowThenEnter_SelectsSecondProvider()
Assert.Equal(_registry.KnownTypeKeys[1], vm.ProviderStep.SelectedProviderType);
}
+ [Fact]
+ public async Task Escape_AtRoot_DoesNotQuit()
+ {
+ // Regression for #1764: Escape at the wizard root used to RequestQuit()
+ // (GoBack() returns false on step 0). It must be a no-op; only Ctrl+Q
+ // quits. Proof: the wizard stays alive and Enter still commits the
+ // highlighted provider.
+ var (_, app, vm) = CreateHeadlessApp(out var input);
+
+ input.EnqueueKey(ConsoleKey.Escape); // must be a no-op
+ input.EnqueueKey(ConsoleKey.DownArrow); // move off row 0
+ input.EnqueueKey(ConsoleKey.Enter); // commit second provider
+ input.EnqueueKey(ConsoleKey.Q, false, false, true);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await app.RunAsync(cts.Token);
+
+ Assert.Equal(_registry.KnownTypeKeys[1], vm.ProviderStep.SelectedProviderType);
+ }
+
[Fact]
public async Task GitHubCopilotEnterpriseInputs_AcceptTypedHostAndApiBase()
{
diff --git a/src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs b/src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs
index a90b8b86a..7ea2a8b42 100644
--- a/src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs
+++ b/src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs
@@ -33,6 +33,46 @@ public ProviderManagerPageTests()
public void Dispose() => _dir.Dispose();
+ [Fact]
+ public async Task Escape_AtRoot_DoesNotQuit()
+ {
+ // Regression for #1764: in standalone `netclaw provider`, Escape at the
+ // root used to Shutdown(). It must be a no-op; only Ctrl+Q quits.
+ // Proof: the list survives Escape and a subsequent Delete still works.
+ WriteConfig(new Dictionary
+ {
+ ["configVersion"] = 1,
+ ["Providers"] = new Dictionary
+ {
+ ["alpha-ollama"] = new Dictionary
+ {
+ ["Type"] = "ollama",
+ ["Endpoint"] = "http://localhost:11434",
+ ["AuthMethod"] = "None"
+ },
+ ["bravo-ollama"] = new Dictionary
+ {
+ ["Type"] = "ollama",
+ ["Endpoint"] = "http://localhost:11435",
+ ["AuthMethod"] = "None"
+ }
+ }
+ });
+
+ var (_, app, vm) = CreateHeadlessApp(out var input);
+
+ input.EnqueueKey(ConsoleKey.Escape); // must be a no-op at root
+ input.EnqueueKey(ConsoleKey.DownArrow); // move highlight off row 0
+ input.EnqueueKey(ConsoleKey.Delete); // start remove for highlighted row
+ input.EnqueueKey(ConsoleKey.Enter); // confirm "Yes, remove"
+ input.EnqueueKey(ConsoleKey.Q, control: true);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await app.RunAsync(cts.Token);
+
+ Assert.DoesNotContain(vm.DisplayProviders, p => p.ConfiguredName == "bravo-ollama");
+ }
+
[Fact]
public async Task GitHubCopilotEnterpriseInputs_AcceptTypedHostAndApiBase()
{
diff --git a/src/Netclaw.Cli.Tests/Tui/SessionsPageTests.cs b/src/Netclaw.Cli.Tests/Tui/SessionsPageTests.cs
index 49fa851cb..1ff1681a8 100644
--- a/src/Netclaw.Cli.Tests/Tui/SessionsPageTests.cs
+++ b/src/Netclaw.Cli.Tests/Tui/SessionsPageTests.cs
@@ -224,6 +224,29 @@ public async Task NKey_StartsNewChat_WithoutResuming()
Assert.Null(nav.ResumeSessionId);
}
+ [Fact]
+ public async Task Escape_AtRoot_DoesNotQuit()
+ {
+ // Regression for #1764: Escape used to call Shutdown() at the session picker
+ // root, so one stray tap killed `netclaw sessions`. It must be a no-op;
+ // only Ctrl+Q quits. Proof: the app stays alive and Enter still resumes.
+ var sessions = new[]
+ {
+ CreateSession("session-001", "tui", 1, _time.GetUtcNow().AddMinutes(-1)),
+ };
+
+ var (_, app, _, nav) = CreateHeadlessApp(out var input, sessions);
+
+ input.EnqueueKey(ConsoleKey.Escape); // must be a no-op
+ input.EnqueueKey(ConsoleKey.Enter); // still on sessions root -> resume
+ input.EnqueueKey(ConsoleKey.Q, false, false, true);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await app.RunAsync(cts.Token);
+
+ Assert.Equal("001", nav.ResumeSessionId);
+ }
+
[Fact]
public async Task LongList_RendersManyRows_ForScrollableList()
{
diff --git a/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs
index 381d535fc..c71641978 100644
--- a/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs
+++ b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs
@@ -119,7 +119,7 @@ private LayoutNode BuildStatusBar()
private LayoutNode BuildKeyBindings()
{
- return NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Enter] Select [Esc] Quit [Ctrl+Q] Quit");
+ return NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Enter] Select [Ctrl+Q] Quit");
}
private void HandleKeyPress(KeyPressed key)
@@ -131,11 +131,7 @@ private void HandleKeyPress(KeyPressed key)
return;
}
- if (keyInfo.Key == ConsoleKey.Escape)
- {
- ViewModel.RequestQuit();
- return;
- }
+ // Escape is a no-op at the dashboard root; Ctrl+Q is the only quit key.
_entryList?.HandleInput(keyInfo);
ViewModel.RequestRedraw();
diff --git a/src/Netclaw.Cli/Tui/InitWizardPage.cs b/src/Netclaw.Cli/Tui/InitWizardPage.cs
index 1ef7edc6e..ee73fb0f1 100644
--- a/src/Netclaw.Cli/Tui/InitWizardPage.cs
+++ b/src/Netclaw.Cli/Tui/InitWizardPage.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -235,9 +235,11 @@ private LayoutNode BuildKeyBindings()
$" [Enter] {doneLabel} [Ctrl+Q] Quit").WithForeground(Color.BrightBlack);
}
- var backLabel = ViewModel.Orchestrator.CurrentStepIndex.Value == 0 ? "Quit" : "Back";
+ var backHint = ViewModel.Orchestrator.CurrentStepIndex.Value == 0
+ ? "" // wizard root: Escape is a no-op, Ctrl+Q quits
+ : " [Esc] Back";
return (ILayoutNode)new TextNode(
- $" [\u2191/\u2193] Navigate [Enter] Next [Esc] {backLabel} [Ctrl+Q] Quit").WithForeground(Color.BrightBlack);
+ $" [\u2191/\u2193] Navigate [Enter] Next{backHint} [Ctrl+Q] Quit").WithForeground(Color.BrightBlack);
})
.AsLayout()
.Height(1);
@@ -283,11 +285,12 @@ private void HandleKeyPress(KeyPressed key)
{
var keyInfo = key.KeyInfo;
- // Escape: go back (orchestrator handles sub-step back internally)
+ // Escape: go back (orchestrator handles sub-step back internally).
+ // At the wizard root GoBack() returns false and Escape is a no-op;
+ // Ctrl+Q is the only quit key.
if (keyInfo.Key == ConsoleKey.Escape)
{
- if (!ViewModel.GoBack())
- ViewModel.RequestQuit();
+ ViewModel.GoBack();
return;
}
diff --git a/src/Netclaw.Cli/Tui/ModelManagerPage.cs b/src/Netclaw.Cli/Tui/ModelManagerPage.cs
index ea2693cf8..4aae49382 100644
--- a/src/Netclaw.Cli/Tui/ModelManagerPage.cs
+++ b/src/Netclaw.Cli/Tui/ModelManagerPage.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -96,10 +96,10 @@ private LayoutNode BuildKeyBindings()
var text = state switch
{
ModelManagerState.RoleOverview =>
- // Embedded in `netclaw config`, Esc backs out to the dashboard; standalone it exits.
+ // Embedded in `netclaw config`, Esc backs out to the dashboard; standalone Ctrl+Q quits.
ViewModel.IsEmbeddedInConfig
? " [\u2191/\u2193] Navigate [Enter] Assign [D] Discover [C] Clear [Esc] Back [Ctrl+Q] Quit"
- : " [\u2191/\u2193] Navigate [Enter] Assign [D] Discover [C] Clear [Esc] Quit",
+ : " [\u2191/\u2193] Navigate [Enter] Assign [D] Discover [C] Clear [Ctrl+Q] Quit",
ModelManagerState.ConfirmAssignment =>
" [Enter] Confirm [Esc] Cancel",
_ =>
diff --git a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs
index fa26f37da..770db0395 100644
--- a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs
+++ b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -289,11 +289,8 @@ public void GoBack()
RouteRequested?.Invoke("/config");
Navigate?.Invoke("/config");
}
- else
- {
- // Standalone `netclaw model`: backing out past the root exits the app.
- Shutdown();
- }
+ // Standalone `netclaw model`: backing out past the root is a no-op.
+ // Escape is a cancel key, not a quit key; Ctrl+Q quits.
break;
}
diff --git a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs
index 447021f14..966ed14b0 100644
--- a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs
+++ b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs
@@ -138,10 +138,10 @@ private LayoutNode BuildKeyBindings()
" Checking providers... [Ctrl+Q] Quit",
ProviderManagerState.List =>
// Embedded in `netclaw config`, Esc backs out to the dashboard (Navigate("/config"));
- // standalone `netclaw provider`, it exits. Match the footer to the real behavior.
+ // standalone: Escape is a no-op at root, Ctrl+Q quits.
ViewModel.IsEmbeddedInConfig
? " [\u2191/\u2193] Navigate [Enter] Select [Delete] Remove [Esc] Back [Ctrl+Q] Quit"
- : " [\u2191/\u2193] Navigate [Enter] Select [Delete] Remove [Esc] Quit [Ctrl+Q] Quit",
+ : " [\u2191/\u2193] Navigate [Enter] Select [Delete] Remove [Ctrl+Q] Quit",
ProviderManagerState.AddSelectType =>
" [\u2191/\u2193] Navigate [Enter] Select [Esc] Back [Ctrl+Q] Quit",
ProviderManagerState.AddName =>
diff --git a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs
index 55bb25c78..8dfa5cb45 100644
--- a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs
+++ b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs
@@ -1059,11 +1059,8 @@ public void GoBack()
RouteRequested?.Invoke("/config");
Navigate?.Invoke("/config");
}
- else
- {
- // Standalone `netclaw provider`: backing out past the root exits the app.
- Shutdown();
- }
+ // Standalone `netclaw provider`: backing out past the root is a no-op.
+ // Escape is a cancel key, not a quit key; Ctrl+Q quits.
break;
}
diff --git a/src/Netclaw.Cli/Tui/ReminderCreatePage.cs b/src/Netclaw.Cli/Tui/ReminderCreatePage.cs
index d0bc215e3..88f4770b4 100644
--- a/src/Netclaw.Cli/Tui/ReminderCreatePage.cs
+++ b/src/Netclaw.Cli/Tui/ReminderCreatePage.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -166,13 +166,13 @@ private LayoutNode BuildKeyBindings()
{
var text = state switch
{
- ReminderCreateState.Title => " [Enter] Next [Esc] Quit [Ctrl+Q] Quit",
+ ReminderCreateState.Title => " [Enter] Next [Ctrl+Q] Quit",
ReminderCreateState.ScheduleType => " [Up/Down] Select [Enter] Next [Esc] Back [Ctrl+Q] Quit",
ReminderCreateState.Schedule => " [Enter] Next [Esc] Back [Ctrl+Q] Quit",
ReminderCreateState.Instructions => " [Ctrl+Enter] Newline [Enter] Next [Esc] Back [Ctrl+Q] Quit",
ReminderCreateState.NotifyInstructions => " [Ctrl+Enter] Newline [Enter] Next [Esc] Back [Ctrl+Q] Quit",
ReminderCreateState.Confirm => " [Up/Down] Select [Enter] Confirm [Esc] Back [Ctrl+Q] Quit",
- ReminderCreateState.Done => " [Up/Down] Select [Enter] Continue [Esc] Quit [Ctrl+Q] Quit",
+ ReminderCreateState.Done => " [Up/Down] Select [Enter] Continue [Ctrl+Q] Quit",
_ => " [Ctrl+Q] Quit"
};
return (ILayoutNode)new TextNode(text).WithForeground(Color.BrightBlack);
diff --git a/src/Netclaw.Cli/Tui/ReminderCreateViewModel.cs b/src/Netclaw.Cli/Tui/ReminderCreateViewModel.cs
index 554b6224f..03961ce21 100644
--- a/src/Netclaw.Cli/Tui/ReminderCreateViewModel.cs
+++ b/src/Netclaw.Cli/Tui/ReminderCreateViewModel.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -187,7 +187,7 @@ public void GoBack()
switch (CurrentState.Value)
{
case ReminderCreateState.Title:
- Shutdown();
+ // Root: Escape is a no-op, Ctrl+Q quits.
return;
case ReminderCreateState.ScheduleType:
CurrentState.Value = ReminderCreateState.Title;
@@ -205,7 +205,7 @@ public void GoBack()
CurrentState.Value = ReminderCreateState.NotifyInstructions;
break;
case ReminderCreateState.Done:
- Shutdown();
+ // Terminal state: Escape is a no-op, Ctrl+Q quits.
return;
}
diff --git a/src/Netclaw.Cli/Tui/SessionsViewModel.cs b/src/Netclaw.Cli/Tui/SessionsViewModel.cs
index f4587971a..1497ac603 100644
--- a/src/Netclaw.Cli/Tui/SessionsViewModel.cs
+++ b/src/Netclaw.Cli/Tui/SessionsViewModel.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -97,12 +97,7 @@ public bool HandleKey(ConsoleKeyInfo keyInfo)
return true;
}
- // Escape quits
- if (keyInfo.Key == ConsoleKey.Escape)
- {
- Shutdown();
- return true;
- }
+ // Escape is a no-op at the session picker root; Ctrl+Q is the only quit key.
// N starts a new chat (no resume)
if (keyInfo.Key == ConsoleKey.N && !keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control))
diff --git a/src/Netclaw.Cli/Tui/StatsViewModel.cs b/src/Netclaw.Cli/Tui/StatsViewModel.cs
index d5db926da..45aaa1c4a 100644
--- a/src/Netclaw.Cli/Tui/StatsViewModel.cs
+++ b/src/Netclaw.Cli/Tui/StatsViewModel.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
@@ -42,11 +42,11 @@ private async Task LoadStatsAsync()
try
{
Stats = await _api.GetStatsAsync(_days);
- StatusMessage.Value = " [Q] Quit";
+ StatusMessage.Value = " [Ctrl+Q] Quit";
}
catch
{
- StatusMessage.Value = " Failed to reach daemon. Is it running? [Q] Quit";
+ StatusMessage.Value = " Failed to reach daemon. Is it running? [Ctrl+Q] Quit";
}
IsLoading.Value = false;
@@ -57,9 +57,9 @@ private void HandleKeyPress(KeyPressed key)
{
var keyInfo = key.KeyInfo;
- if (keyInfo.Key == ConsoleKey.Q ||
- keyInfo.Key == ConsoleKey.Escape ||
- (keyInfo.Key == ConsoleKey.C && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)))
+ // Only Ctrl+Q quits. Escape is a no-op at the stats root; plain Q and
+ // Ctrl+C must not kill the view (Ctrl+C is not handled anywhere else in the TUI).
+ if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control))
{
Shutdown();
}
diff --git a/tests/smoke/screenshots/provider-manager-empty.approved.png b/tests/smoke/screenshots/provider-manager-empty.approved.png
index 67f645701..7a564e14b 100644
Binary files a/tests/smoke/screenshots/provider-manager-empty.approved.png and b/tests/smoke/screenshots/provider-manager-empty.approved.png differ
diff --git a/tests/smoke/tapes/sessions-tui.tape b/tests/smoke/tapes/sessions-tui.tape
index 77303cfa6..9d3003028 100644
--- a/tests/smoke/tapes/sessions-tui.tape
+++ b/tests/smoke/tapes/sessions-tui.tape
@@ -1,7 +1,7 @@
# sessions-tui.tape — smoke the interactive `netclaw sessions` TUI browser.
#
# Validates that the sessions page opens (panel title renders), shows
-# the empty/loading state, and exits cleanly on Escape. This covers
+# the empty/loading state, and exits cleanly on Ctrl+Q. This covers
# SessionsPage rendering regressions (including the for-loop →
# SelectionListNode.WithFillHeight() change from #1351) without
# requiring a live daemon or existing sessions.
@@ -17,7 +17,8 @@ Wait+Screen@10s /Sessions/
Sleep 300ms
# ─── Exit TUI ────────────────────────────────────────────────────────
-Escape
+# #1764: Escape is a no-op at the sessions root; Ctrl+Q is the only quit key.
+Ctrl+Q
Sleep 1s
Wait+Screen@10s /TAPE\$/