diff --git a/src/Netclaw.Cli.Tests/Tui/ApprovalsManagerPageTests.cs b/src/Netclaw.Cli.Tests/Tui/ApprovalsManagerPageTests.cs index 3a1483779..fb58febac 100644 --- a/src/Netclaw.Cli.Tests/Tui/ApprovalsManagerPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ApprovalsManagerPageTests.cs @@ -150,6 +150,29 @@ public async Task PressingR_OnSelection_TransitionsToConfirmAndRevokesOnEnter() Assert.Equal(ApprovalsManagerState.List, vm.CurrentState.Value); } + [Theory] + [InlineData(ConsoleKey.R)] + [InlineData(ConsoleKey.Delete)] + public async Task RevokeKey_OnSecondRow_RevokesHighlightedEntry(ConsoleKey revokeKey) + { + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("alpha")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("bravo")); + + var (_, app, _) = CreateHeadlessApp(out var input); + + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(revokeKey); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var remaining = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.Contains(remaining, entry => entry.Verb == "alpha"); + Assert.DoesNotContain(remaining, entry => entry.Verb == "bravo"); + } + [Fact] public async Task EscOnConfirm_CancelsRevoke() { diff --git a/src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs b/src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs index 258fba035..ddb9a9bc5 100644 --- a/src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs @@ -20,7 +20,7 @@ namespace Netclaw.Cli.Tui; /// public sealed class ApprovalsManagerPage : ReactivePage { - private SelectionListNode? _approvalList; + private SelectionListNode? _approvalList; private SelectionListNode? _confirmList; private DynamicLayoutNode? _contentNode; private readonly CompositeDisposable _stepSubs = []; @@ -127,11 +127,10 @@ private ILayoutNode BuildEmptyView() private ILayoutNode BuildListView() { - var rows = ViewModel.DisplayApprovals - .Select(item => $"{item.AudienceWire,-10} {item.ToolName,-20} {item.DisplayText,-44} {item.AddedText}") - .ToList(); - - _approvalList = Layouts.SelectionList(rows) + _approvalList = Layouts.SelectionList( + ViewModel.DisplayApprovals, + static item => + $"{item.AudienceWire,-10} {item.ToolName,-20} {item.DisplayText,-44} {item.AddedText}") .WithMode(SelectionMode.Single) .WithHighlightColors(Color.Black, Color.Cyan); @@ -142,10 +141,7 @@ private ILayoutNode BuildListView() .Subscribe(selected => { if (selected.Count == 0) return; - var idx = rows.IndexOf(selected[0]); - if (idx < 0) return; - ViewModel.SelectedIndex = idx; - ViewModel.StartRevoke(); + ViewModel.StartRevoke(selected[0]); }) .DisposeWith(_stepSubs); @@ -207,7 +203,8 @@ private void HandleKeyPress(KeyPressed key) { if (keyInfo.Key == ConsoleKey.R || keyInfo.Key == ConsoleKey.Delete) { - ViewModel.StartRevoke(); + if (_approvalList?.HighlightedItem is { } highlighted) + ViewModel.StartRevoke(highlighted.Value); return; } diff --git a/src/Netclaw.Cli/Tui/ApprovalsManagerViewModel.cs b/src/Netclaw.Cli/Tui/ApprovalsManagerViewModel.cs index 59d2dbb20..3883124e2 100644 --- a/src/Netclaw.Cli/Tui/ApprovalsManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ApprovalsManagerViewModel.cs @@ -50,7 +50,6 @@ public ApprovalsManagerViewModel(NetclawPaths paths, TimeProvider timeProvider) public ReactiveProperty StateVersion { get; } = new(0); public List DisplayApprovals { get; } = []; - public int SelectedIndex { get; set; } public ApprovalDisplayItem? PendingRevoke { get; private set; } @@ -84,9 +83,6 @@ public void Refresh() } } - if (SelectedIndex >= DisplayApprovals.Count) - SelectedIndex = Math.Max(0, DisplayApprovals.Count - 1); - CurrentState.Value = DisplayApprovals.Count == 0 ? ApprovalsManagerState.Empty : ApprovalsManagerState.List; @@ -94,12 +90,11 @@ public void Refresh() StateVersion.Value++; } - public void StartRevoke() + public void StartRevoke(ApprovalDisplayItem target) { if (CurrentState.Value != ApprovalsManagerState.List) return; - if (SelectedIndex < 0 || SelectedIndex >= DisplayApprovals.Count) return; - PendingRevoke = DisplayApprovals[SelectedIndex]; + PendingRevoke = target; CurrentState.Value = ApprovalsManagerState.RevokeConfirm; StateVersion.Value++; } diff --git a/tests/smoke/assertions/approvals.sh b/tests/smoke/assertions/approvals.sh index fd0e2bcf4..324ae4db0 100755 --- a/tests/smoke/assertions/approvals.sh +++ b/tests/smoke/assertions/approvals.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash # approvals.tape post-tape assertion. -# -# The tape's Wait+Screen anchors on "Approvals Manager" and TAPE$ are -# the primary regression detectors — a rendering failure or crash exits -# vhs non-zero. This script intentionally does nothing further. set -euo pipefail -echo "approvals: no post-tape assertion (vhs exit code is the test)" + +approvals_path="${NETCLAW_HOME}/config/tool-approvals.json" + +jq -e ' + .version == 2 + and (.audiences.personal.shell_execute | length) == 1 + and .audiences.personal.shell_execute[0].verb == "alpha" +' "$approvals_path" >/dev/null + +echo "approvals: the highlighted approval was revoked" diff --git a/tests/smoke/tapes/approvals.tape b/tests/smoke/tapes/approvals.tape index 148067a6a..07f8b83ce 100644 --- a/tests/smoke/tapes/approvals.tape +++ b/tests/smoke/tapes/approvals.tape @@ -1,22 +1,40 @@ # approvals.tape — smoke the `netclaw approvals` TUI. # -# Validates that the page opens (panel title renders), shows the -# no-daemon / empty state, and exits cleanly on Ctrl+Q. This covers -# ApprovalsManagerPage rendering regressions (including the .WithFillHeight() -# scroll fix from #1351) without requiring a live daemon. +# Validates that the page revokes the highlighted row and exits cleanly. +# This covers the selection regression from #1703 without a live daemon. Output "/tmp/tape-approvals.gif" +# ─── Seed approvals ────────────────────────────────────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Wait+Screen@5s /TAPE\$/ + +Type "first=alpha second=bravo jq -n '{version:2,audiences:{personal:{shell_execute:[{verb:env.first},{verb:env.second}]}}}' > $NETCLAW_HOME/config/tool-approvals.json" +Enter +Wait+Screen@5s /TAPE\$/ + # ─── Launch ────────────────────────────────────────────────────────── Type "netclaw approvals" Enter # PanelNode title always renders regardless of daemon state. Wait+Screen@10s /Approvals Manager/ -Sleep 300ms +Wait+Screen@5s /alpha anywhere/ +Wait+Screen@5s /bravo anywhere/ + +# ─── Revoke the second row ─────────────────────────────────────────── +Down +Wait+Screen@5s /bravo anywhere/ +Type "r" +Wait+Screen@5s /Revoke 'bravo anywhere'/ +Enter +Wait+Screen@5s /Removed 'bravo anywhere'/ # ─── Exit TUI ──────────────────────────────────────────────────────── Ctrl+Q + +# Wait for the alternate screen to close before the shell receives input. Sleep 1s Wait+Screen@10s /TAPE\$/