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
23 changes: 23 additions & 0 deletions src/Netclaw.Cli.Tests/Tui/ApprovalsManagerPageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
19 changes: 8 additions & 11 deletions src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace Netclaw.Cli.Tui;
/// </summary>
public sealed class ApprovalsManagerPage : ReactivePage<ApprovalsManagerViewModel>
{
private SelectionListNode<string>? _approvalList;
private SelectionListNode<ApprovalDisplayItem>? _approvalList;
private SelectionListNode<string>? _confirmList;
private DynamicLayoutNode? _contentNode;
private readonly CompositeDisposable _stepSubs = [];
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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;
}

Expand Down
9 changes: 2 additions & 7 deletions src/Netclaw.Cli/Tui/ApprovalsManagerViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ public ApprovalsManagerViewModel(NetclawPaths paths, TimeProvider timeProvider)
public ReactiveProperty<int> StateVersion { get; } = new(0);

public List<ApprovalDisplayItem> DisplayApprovals { get; } = [];
public int SelectedIndex { get; set; }

public ApprovalDisplayItem? PendingRevoke { get; private set; }

Expand Down Expand Up @@ -84,22 +83,18 @@ public void Refresh()
}
}

if (SelectedIndex >= DisplayApprovals.Count)
SelectedIndex = Math.Max(0, DisplayApprovals.Count - 1);

CurrentState.Value = DisplayApprovals.Count == 0
? ApprovalsManagerState.Empty
: ApprovalsManagerState.List;

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++;
}
Expand Down
15 changes: 10 additions & 5 deletions tests/smoke/assertions/approvals.sh
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 23 additions & 5 deletions tests/smoke/tapes/approvals.tape
Original file line number Diff line number Diff line change
@@ -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\$/

Expand Down
Loading