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\$/