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
2 changes: 1 addition & 1 deletion scripts/smoke/run-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}"

# Cheapest harness checks first so a harness-level break fails fast
# before paying for the wizard + probe tapes.
LIGHT_TAPES=(help init-wizard init-wizard-reverse-proxy provider-add provider-rename tui-cleanup)
LIGHT_TAPES=(help init-wizard init-wizard-reverse-proxy provider-add provider-rename tui-cleanup mcp-permissions approvals model-manager sessions-tui)
FULL_TAPES=("${LIGHT_TAPES[@]}")

LIGHT_SCENARIOS=(
Expand Down
21 changes: 19 additions & 2 deletions src/Netclaw.Cli/Daemon/DaemonApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,28 @@ public static string ResolveEndpoint(NetclawPaths? paths = null)

// ── Sessions ──────────────────────────────────────────────────────

public async Task<List<SessionCatalogEntryDto>> ListSessionsAsync(CancellationToken ct = default)
public async Task<List<SessionCatalogEntryDto>> ListSessionsAsync(
int? limit = null,
int? offset = null,
CancellationToken ct = default)
{
using var cts = CreateTimeoutCts(DefaultTimeout, ct);
var client = CreateHttpClient();
using var response = await client.GetAsync($"{_endpoint}/api/sessions", cts.Token);
var url = $"{_endpoint}/api/sessions";
if (limit.HasValue || offset.HasValue)
{
var separator = "?";
if (limit.HasValue)
{
url += $"{separator}limit={limit.Value}";
separator = "&";
}

if (offset.HasValue)
url += $"{separator}offset={offset.Value}";
}

using var response = await client.GetAsync(url, cts.Token);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync(cts.Token);
return await JsonSerializer.DeserializeAsync<List<SessionCatalogEntryDto>>(stream, JsonDefaults.Api, cts.Token) ?? [];
Expand Down
62 changes: 53 additions & 9 deletions src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// <copyright file="McpToolPermissionsPage.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
Expand All @@ -20,6 +20,8 @@ public sealed class McpToolPermissionsPage : ReactivePage<McpToolPermissionsView
private SelectionListNode<string>? _serverList;
private DynamicLayoutNode? _contentNode;
private DynamicLayoutNode? _footerNode;
private DynamicLayoutNode? _toolRowsNode;
private ScrollableContainerNode? _toolScrollNode;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

private readonly CompositeDisposable _stepSubs = [];
private int _gridCursor;
private bool _confirmingSave;
Expand Down Expand Up @@ -62,6 +64,8 @@ private LayoutNode BuildContent()
_contentNode = new DynamicLayoutNode(() =>
{
_serverList = null;
_toolScrollNode = null;
_toolRowsNode = null;
_stepSubs.Clear();

return ViewModel.CurrentState.Value switch
Expand All @@ -78,7 +82,7 @@ private LayoutNode BuildContent()
.Subscribe(_ => _contentNode.Invalidate())
.DisposeWith(Subscriptions);

return _contentNode;
return _contentNode.Fill();
}

private ILayoutNode BuildLoading()
Expand Down Expand Up @@ -117,7 +121,7 @@ private ILayoutNode BuildServerList()

return Layouts.Vertical()
.WithChild(new TextNode("Select a server:").WithForeground(Color.White))
.WithChild(_serverList);
.WithChild(_serverList.WithFillHeight());
}

private ILayoutNode BuildToolGrid()
Expand All @@ -126,7 +130,6 @@ private ILayoutNode BuildToolGrid()
var audienceLabel = ViewModel.SelectedAudience.ToWireValue();
var serverAllowed = ViewModel.IsServerAllowedForSelectedAudience();
var serverDefault = ViewModel.GetServerDefault();
var tools = ViewModel.DiscoveredTools;

var maxRow = TotalRows - 1;
if (_gridCursor > maxRow) _gridCursor = maxRow;
Expand Down Expand Up @@ -167,8 +170,24 @@ private ILayoutNode BuildToolGrid()

layout = layout.WithSpacing(1);

// Tool rows
// Tool rows live in a separate DynamicLayoutNode so cursor navigation
// can invalidate just the rows without resetting the scroll container.
_toolRowsNode = new DynamicLayoutNode(BuildToolRows);
_toolScrollNode = new ScrollableContainerNode()
.WithAutoScroll(AutoScrollPolicy.None)
.WithContent(_toolRowsNode);
_toolScrollNode.Fill();
layout = layout.WithChild(_toolScrollNode);

return layout;
}

private ILayoutNode BuildToolRows()
{
var tools = ViewModel.DiscoveredTools;
var serverAllowed = ViewModel.IsServerAllowedForSelectedAudience();
var maxToolNameLen = tools.Count > 0 ? tools.Max(t => t.Length) : 0;
var rows = Layouts.Vertical();

for (var i = 0; i < tools.Count; i++)
{
Expand All @@ -194,10 +213,26 @@ private ILayoutNode BuildToolGrid()
node = node.WithForeground(Color.White);
else
node = node.WithForeground(Color.BrightBlack);
layout = layout.WithChild(node);
rows = rows.WithChild(node);
}

return layout;
return rows;
}

// Adjusts the scroll container so the cursor row stays in the visible window.
// Called after each Up/Down keypress; uses ContentHeight/MaxScroll from the
// previous render (valid as long as the tool list hasn't changed size).
private void EnsureToolCursorVisible()
{
if (_toolScrollNode is null || _gridCursor < FirstToolRow) return;
var toolIdx = _gridCursor - FirstToolRow;
if (_toolScrollNode.MaxScroll == 0) return;
var viewportH = _toolScrollNode.ContentHeight - _toolScrollNode.MaxScroll;
if (viewportH <= 0) return;
if (toolIdx < _toolScrollNode.ScrollOffset)
_toolScrollNode.ScrollTo(toolIdx);
else if (toolIdx >= _toolScrollNode.ScrollOffset + viewportH)
_toolScrollNode.ScrollTo(toolIdx - viewportH + 1);
}

private static Color ColorForMode(ToolApprovalMode mode) => mode switch
Expand Down Expand Up @@ -299,12 +334,14 @@ private void HandleKeyPress(KeyPressed key)
{
case ConsoleKey.UpArrow:
if (_gridCursor > 0) _gridCursor--;
InvalidateAndRedraw();
EnsureToolCursorVisible();
InvalidateCursorAndRedraw();
return;

case ConsoleKey.DownArrow:
if (_gridCursor < TotalRows - 1) _gridCursor++;
InvalidateAndRedraw();
EnsureToolCursorVisible();
InvalidateCursorAndRedraw();
return;

case ConsoleKey.RightArrow:
Expand Down Expand Up @@ -450,4 +487,11 @@ private void InvalidateAndRedraw()
_footerNode?.Invalidate();
ViewModel.RequestRedraw();
}

private void InvalidateCursorAndRedraw()
{
_toolRowsNode?.Invalidate();
_footerNode?.Invalidate();
ViewModel.RequestRedraw();
}
}
4 changes: 4 additions & 0 deletions src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public sealed class McpToolPermissionsViewModel : ReactiveViewModel
{
private readonly NetclawPaths _paths;
private readonly DaemonApi _daemonApi;
private bool _initializedForTests;

public McpToolPermissionsViewModel(NetclawPaths paths, DaemonApi daemonApi)
{
Expand Down Expand Up @@ -68,6 +69,7 @@ public McpToolPermissionsViewModel(NetclawPaths paths, DaemonApi daemonApi)
public override void OnActivated()
{
base.OnActivated();
if (_initializedForTests) return;
_ = LoadServersAsync();
}

Expand Down Expand Up @@ -120,13 +122,15 @@ public void SelectServer(McpServerName serverName)
/// </summary>
internal void InitializeForTests(McpServerName serverName, IEnumerable<string> tools)
{
_initializedForTests = true;
SelectedServer = serverName.Value;
DiscoveredTools.Clear();
DiscoveredTools.AddRange(tools);
Profiles = LoadToolConfig().AudienceProfiles;
if (!_pendingGrants.ContainsKey(serverName.Value))
InitializePendingGrantsFromConfig(serverName);
CurrentState.Value = ToolPermissionsState.ToolGrid;
NotifyStateChanged();
}

internal void SetSelectedAudienceForTests(TrustAudience audience)
Expand Down
50 changes: 1 addition & 49 deletions src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ private ILayoutNode BuildListView()
return Layouts.Vertical()
.WithChild(new TextNode($" {"Audience",-10} {"Tool",-20} {"Approval",-44} Added")
.WithForeground(Color.White).Bold())
.WithChild(new FillSelectionListNode<string>(_approvalList));
.WithChild(_approvalList.WithFillHeight());
}

private ILayoutNode BuildRevokeConfirmView()
Expand Down Expand Up @@ -224,51 +224,3 @@ private void HandleKeyPress(KeyPressed key)
}
}
}

/// <summary>
/// Fills all vertical space the parent allocates with a Termina
/// <see cref="SelectionListNode{T}"/>. The stock node caps its own height at
/// <c>_visibleRows</c> (default 10) and exposes no fill mode, so a long list
/// renders only 10 rows regardless of terminal size — and its scroll math and
/// scrollbar are both sized to that 10-row window. This adapter is
/// <see cref="SizeConstraint.Fill"/>-constrained and pushes the row count the
/// layout engine actually grants into <see cref="SelectionListNode{T}.WithVisibleRows"/>
/// at measure/render time, so the list grows with the terminal and its
/// scrollbar spans the full visible height.
/// </summary>
// TODO: remove this adapter once Termina ships a built-in fill mode for
// SelectionListNode<T> — tracked at https://github.com/Aaronontheweb/termina/issues/207
internal sealed class FillSelectionListNode<T> : LayoutNode, IInvalidatingNode
{
private readonly SelectionListNode<T> _inner;

public FillSelectionListNode(SelectionListNode<T> inner)
{
_inner = inner;
Fill();
WidthFill();
}

public Observable<Unit> Invalidated => _inner.Invalidated;

public override Size Measure(Size available)
{
_inner.WithVisibleRows(available.Height);
return available;
}

public override void Render(IRenderContext context, Rect bounds)
{
// bounds.Height is the row count the layout engine actually granted;
// keep _visibleRows in sync so the list's scroll math and scrollbar
// match what's on screen.
_inner.WithVisibleRows(bounds.Height);
_inner.Render(context, bounds);
}

public override void Dispose()
{
_inner.Dispose();
base.Dispose();
}
}
10 changes: 8 additions & 2 deletions src/Netclaw.Cli/Tui/ModelManagerPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ private ILayoutNode BuildProviderSelection()
return Layouts.Vertical()
.WithChild(new TextNode($" Select provider for {ViewModel.SelectedRole ?? "role"}:")
.WithForeground(Color.White))
.WithChild(_providerList);
.WithChild(_providerList.WithFillHeight());
}

private ILayoutNode BuildDiscoverModels()
Expand Down Expand Up @@ -312,7 +312,7 @@ private ILayoutNode BuildDiscoverModels()

return Layouts.Vertical()
.WithChild(new TextNode(title).WithForeground(Color.White))
.WithChild(_modelList);
.WithChild(_modelList.WithFillHeight());
}

private ILayoutNode BuildConfirmAssignment()
Expand Down Expand Up @@ -361,6 +361,12 @@ private void HandleKeyPress(KeyPressed key)
var keyInfo = key.KeyInfo;
var state = ViewModel.CurrentState.Value;

if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control))
{
ViewModel.RequestQuit();
return;
}

if (keyInfo.Key == ConsoleKey.Escape)
{
ViewModel.GoBack();
Expand Down
5 changes: 2 additions & 3 deletions src/Netclaw.Cli/Tui/ProviderManagerPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ private ILayoutNode BuildInnerLayout()
{
return Layouts.Vertical()
.WithSpacing(1)
.WithChild(BuildContent())
.WithChild(Layouts.Empty().Fill())
.WithChild(BuildContent().Fill())
.WithChild(BuildStatusBar())
.WithChild(BuildKeyBindings());
}
Expand Down Expand Up @@ -253,7 +252,7 @@ private ILayoutNode BuildProviderListView()
return Layouts.Vertical()
.WithChild(new TextNode($" {"",2}{"Provider",-36} {"Auth",-12} Endpoint")
.WithForeground(Color.White).Bold())
.WithChild(_providerList);
.WithChild(_providerList.WithFillHeight());
}

private ILayoutNode BuildAddSelectTypeView()
Expand Down
18 changes: 7 additions & 11 deletions src/Netclaw.Cli/Tui/SessionsPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,13 @@ public override ILayoutNode BuildLayout()
}
else
{
for (var i = 0; i < ViewModel.Sessions.Count; i++)
{
var session = ViewModel.Sessions[i];
var isSelected = i == selectedIndex;
var line = FormatSessionLine(session);

content.WithChild(
new TextNode(isSelected ? $"> {line}" : $" {line}")
.WithForeground(isSelected ? Color.Cyan : Color.White)
.Height(1));
}
var items = ViewModel.Sessions.Select(FormatSessionLine).ToList();
content.WithChild(
Layouts.SelectionList(items)
.WithMode(SelectionMode.Single)
.WithHighlightColors(Color.Black, Color.Cyan)
.WithHighlightedIndex(selectedIndex)
.WithFillHeight());
}

return (ILayoutNode)Layouts.Vertical()
Expand Down
Loading
Loading