diff --git a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs index 6caa854c3..a0339b0d2 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs @@ -355,6 +355,44 @@ public async Task ToolGrid_ManyTools_HeaderRowsNotOverwrittenByScrollContent() $"Expected 'Server default' row not overwritten by tool list. Screen:\n{terminal}"); } + [Fact] + public async Task ToolGrid_RightArrowOnScrolledToolRow_PreservesScrollPosition() + { + var (terminal, app, vm) = CreateHeadlessApp(out var input, height: 18); + + var tools = Enumerable.Range(1, 50) + .Select(i => $"tool-{i:00}") + .ToList(); + + vm.InitializeForTests(new McpServerName("notion"), tools); + vm.SetSelectedAudienceForTests(TrustAudience.Personal); + + if (!vm.IsServerAllowedForSelectedAudience()) + vm.ToggleServerAccess(); + + // Cursor starts at row 0. Move to row 38: + // row 0 = Audience, row 1 = Server enabled, row 2 = Server default, + // row 3 = tool-01, so row 38 = tool-36. + for (var i = 0; i < 38; i++) + input.EnqueueKey(ConsoleKey.DownArrow); + + input.EnqueueKey(ConsoleKey.RightArrow); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.True(terminal.Contains("tool-36"), + $"Expected scrolled/focused row to remain visible after RightArrow. Screen:\n{terminal}"); + Assert.False(terminal.Contains("tool-01"), + $"Expected scroll not to reset to the top after RightArrow. Screen:\n{terminal}"); + AssertLineHasBackground(terminal, "tool-36", Color.Cyan); + + var (mode, inherited) = vm.GetEffectiveMode(new ToolName("tool-36")); + Assert.Equal(ToolApprovalMode.Auto, mode); + Assert.False(inherited); + } + [Fact] public async Task Loading_Escape_QuitsInsteadOfStalling() { @@ -371,9 +409,9 @@ public async Task Loading_Escape_QuitsInsteadOfStalling() // ── Helpers ────────────────────────────────────────────────────────────── private (VirtualTerminal Terminal, TerminaApplication App, McpToolPermissionsViewModel Vm) - CreateHeadlessApp(out VirtualInputSource input) + CreateHeadlessApp(out VirtualInputSource input, int width = 120, int height = 40) { - var terminal = new VirtualTerminal(120, 40); + var terminal = new VirtualTerminal(width, height); var virtualInput = new VirtualInputSource(); input = virtualInput; diff --git a/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs b/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs index e472f4238..b2bd14476 100644 --- a/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs +++ b/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs @@ -86,8 +86,21 @@ private LayoutNode BuildContent() }; }); + // ToolGrid updates invalidate only the header and rows, so the scroll container persists and keeps its scroll position. ViewModel.StateVersion - .Subscribe(_ => _contentNode.Invalidate()) + .Subscribe(_ => + { + if (ViewModel.CurrentState.Value == ToolPermissionsState.ToolGrid + && _toolRowsNode is not null) + { + _gridHeaderRowsNode?.Invalidate(); + _toolRowsNode.Invalidate(); + } + else + { + _contentNode.Invalidate(); + } + }) .DisposeWith(Subscriptions); return _contentNode.Fill();