Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/Netclaw.Cli.Tests/Tui/InitWizardPageTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="InitWizardPageTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -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()
{
Expand Down
40 changes: 40 additions & 0 deletions src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>
{
["configVersion"] = 1,
["Providers"] = new Dictionary<string, object>
{
["alpha-ollama"] = new Dictionary<string, object>
{
["Type"] = "ollama",
["Endpoint"] = "http://localhost:11434",
["AuthMethod"] = "None"
},
["bravo-ollama"] = new Dictionary<string, object>
{
["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()
{
Expand Down
23 changes: 23 additions & 0 deletions src/Netclaw.Cli.Tests/Tui/SessionsPageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
8 changes: 2 additions & 6 deletions src/Netclaw.Cli/Tui/ConfigDashboardPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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();
Expand Down
15 changes: 9 additions & 6 deletions src/Netclaw.Cli/Tui/InitWizardPage.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="InitWizardPage.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 3 additions & 3 deletions src/Netclaw.Cli/Tui/ModelManagerPage.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="ModelManagerPage.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -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",
_ =>
Expand Down
9 changes: 3 additions & 6 deletions src/Netclaw.Cli/Tui/ModelManagerViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="ModelManagerViewModel.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/Netclaw.Cli/Tui/ProviderManagerPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
7 changes: 2 additions & 5 deletions src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions src/Netclaw.Cli/Tui/ReminderCreatePage.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="ReminderCreatePage.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/Netclaw.Cli/Tui/ReminderCreateViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="ReminderCreateViewModel.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
9 changes: 2 additions & 7 deletions src/Netclaw.Cli/Tui/SessionsViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="SessionsViewModel.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -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))
Expand Down
12 changes: 6 additions & 6 deletions src/Netclaw.Cli/Tui/StatsViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="StatsViewModel.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down
Binary file modified tests/smoke/screenshots/provider-manager-empty.approved.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions tests/smoke/tapes/sessions-tui.tape
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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\$/

Expand Down
Loading