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
195 changes: 195 additions & 0 deletions src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// -----------------------------------------------------------------------
// <copyright file="ModelManagerPageTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Netclaw.Cli.Provider;
using Netclaw.Cli.Tui;
using Netclaw.Configuration;
using Netclaw.Providers;
using Netclaw.Tests.Utilities;
using Termina;
using Termina.Hosting;
using Termina.Input;
using Termina.Terminal;
using Xunit;

namespace Netclaw.Cli.Tests.Tui;

public sealed class ModelManagerPageTests : IDisposable
{
private readonly DisposableTempDir _dir = new();
private readonly NetclawPaths _paths;
private readonly FakeProviderProbe _fakeProbe = new();
private readonly ProviderDescriptorRegistry _registry = ProviderCommand.CreateDefaultRegistry();

public ModelManagerPageTests()
{
_paths = new NetclawPaths(_dir.Path);
_paths.EnsureDirectoriesExist();
}

public void Dispose() => _dir.Dispose();

[Fact]
public async Task InitialRoleOverview_ShowsConfiguredMainAndFallbackModels()
{
WriteConfig(new Dictionary<string, object>
{
["configVersion"] = 1,
["Providers"] = new Dictionary<string, object>
{
["deepseek-test"] = new Dictionary<string, object>
{
["Type"] = "openai-compatible",
["Endpoint"] = "https://api.deepseek.example"
},
["big-gpu"] = new Dictionary<string, object>
{
["Type"] = "openai-compatible",
["Endpoint"] = "http://gpu.example"
}
},
["Models"] = new Dictionary<string, object>
{
["Definitions"] = new Dictionary<string, object>
{
["deepseek-v4-flash"] = new Dictionary<string, object>
{
["Provider"] = "deepseek-test",
["ModelId"] = "deepseek-v4-flash"
},
["fallback-model"] = new Dictionary<string, object>
{
["Provider"] = "big-gpu",
["ModelId"] = "fallback-model"
}
},
["Roles"] = new Dictionary<string, object>
{
["Main"] = "deepseek-v4-flash",
["Fallback"] = "fallback-model"
}
}
});

var (terminal, app) = CreateHeadlessApp(out var input);

using var appCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var run = app.RunAsync(appCts.Token);

try
{
using var overviewCts = CancellationTokenSource.CreateLinkedTokenSource(appCts.Token);
overviewCts.CancelAfter(TimeSpan.FromSeconds(2));
await WaitForConditionAsync(
() => terminal.Contains("deepseek-test")
&& terminal.Contains("deepseek-v4-flash")
&& terminal.Contains("big-gpu")
&& terminal.Contains("fallback-model"),
overviewCts.Token);
}
finally
{
input.EnqueueKey(ConsoleKey.Q, control: true);
await run.WaitAsync(appCts.Token);
}
}

[Fact]
public async Task ModelAssignment_WhenTheUserChangesProvider_ShowsTheSelectedProvidersModels()
{
WriteConfig(new Dictionary<string, object>
{
["configVersion"] = 1,
["Providers"] = new Dictionary<string, object>
{
["alpha-openai"] = new Dictionary<string, object> { ["Type"] = "openai" },
["bravo-copilot"] = new Dictionary<string, object> { ["Type"] = "github-copilot" },
["charlie-deepseek"] = new Dictionary<string, object> { ["Type"] = "deepseek" }
}
});
_fakeProbe.TypeResults["openai"] = SuccessfulProbe("openai-model");
_fakeProbe.TypeResults["github-copilot"] = SuccessfulProbe("copilot-model");
_fakeProbe.TypeResults["deepseek"] = SuccessfulProbe("deepseek-model");

var (terminal, app) = CreateHeadlessApp(out var input);

using var appCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var run = app.RunAsync(appCts.Token);

try
{
await WaitForConditionAsync(() => terminal.Contains("Main"), appCts.Token);

input.EnqueueKey(ConsoleKey.Enter);
await WaitForConditionAsync(() => terminal.Contains("alpha-openai"), appCts.Token);
input.EnqueueKey(ConsoleKey.Enter);
await WaitForConditionAsync(() => terminal.Contains("openai-model"), appCts.Token);

input.EnqueueKey(ConsoleKey.Escape);
await WaitForConditionAsync(() => terminal.Contains("Select provider for Main"), appCts.Token);
input.EnqueueKey(ConsoleKey.DownArrow);
input.EnqueueKey(ConsoleKey.Enter);
await WaitForConditionAsync(
() => terminal.Contains("bravo-copilot") && terminal.Contains("copilot-model"),
appCts.Token);

input.EnqueueKey(ConsoleKey.Escape);
await WaitForConditionAsync(() => terminal.Contains("Select provider for Main"), appCts.Token);
input.EnqueueKey(ConsoleKey.DownArrow);
input.EnqueueKey(ConsoleKey.DownArrow);
input.EnqueueKey(ConsoleKey.Enter);
await WaitForConditionAsync(
() => terminal.Contains("charlie-deepseek") && terminal.Contains("deepseek-model"),
appCts.Token);
}
finally
{
input.EnqueueKey(ConsoleKey.Q, control: true);
await run.WaitAsync(appCts.Token);
}
}

private (VirtualTerminal Terminal, TerminaApplication App) CreateHeadlessApp(
out VirtualInputSource input)
{
var terminal = new VirtualTerminal(160, 40);
var virtualInput = new VirtualInputSource();
input = virtualInput;

var services = new ServiceCollection();
services.AddSingleton<IAnsiTerminal>(terminal);
services.AddTerminaVirtualInput(virtualInput);
services.AddTermina("/model", builder =>
{
builder.RegisterRoute<ModelManagerPage, ModelManagerViewModel>(
"/model",
_ => new ModelManagerPage(),
_ => new ModelManagerViewModel(_paths, _fakeProbe, _registry));
});

var serviceProvider = services.BuildServiceProvider();
return (terminal, serviceProvider.GetRequiredService<TerminaApplication>());
}

private void WriteConfig(Dictionary<string, object> data)
{
File.WriteAllText(_paths.NetclawConfigPath,
JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
}

private static ProviderProbeResult SuccessfulProbe(string modelId)
=> new(true, null, [new DiscoveredModel { ModelId = new ModelId(modelId) }]);

private static async Task WaitForConditionAsync(Func<bool> predicate, CancellationToken ct)
{
while (!predicate())
{
ct.ThrowIfCancellationRequested();
await Task.Yield();
}
}
}
4 changes: 2 additions & 2 deletions src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ public ModelManagerViewModelTests()
public void Dispose() => _dir.Dispose();

[Fact]
public void StartsAtRoleOverview()
public void StartsInLoadingState()
{
using var vm = CreateViewModel();
Assert.Equal(ModelManagerState.RoleOverview, vm.CurrentState.Value);
Assert.Equal(ModelManagerState.Loading, vm.CurrentState.Value);
}

[Fact]
Expand Down
51 changes: 51 additions & 0 deletions src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Netclaw.Cli.Tui;
using Netclaw.Configuration;
using Netclaw.Providers;
using Netclaw.Providers.OAuth;
using Netclaw.Tests.Utilities;
using Termina;
using Termina.Hosting;
Expand Down Expand Up @@ -102,6 +103,47 @@ public async Task GitHubCopilotEnterpriseInputs_AcceptTypedHostAndApiBase()
Assert.Equal("https://api.ghe.example.com", vm.NewVendorOptions["GitHubApiBase"]);
}

[Fact]
public async Task OAuthDeviceFlow_WhenAuthorizationStarts_ShowsTheUserCode()
{
var (terminal, app, vm) = CreateHeadlessApp(out var input);

using var appCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var run = app.RunAsync(appCts.Token);

try
{
await WaitForConditionAsync(() => terminal.Contains("Provider Manager"), appCts.Token);

vm.NewProviderType = "openai";
vm.CurrentState.Value = ProviderManagerState.AddOAuthDeviceFlow;
vm.StateVersion.Value++;
vm.RequestRedraw();

await WaitForConditionAsync(
() => terminal.Contains("Starting device authorization..."),
appCts.Token);

vm.OAuth.UserCode = "ABCD-EFGH";
vm.OAuth.VerificationUri = "https://auth.openai.com/device";
vm.OAuth.FlowState.Value = DeviceFlowState.WaitingForUser;
vm.StateVersion.Value++;
vm.RequestRedraw();

using var transitionCts = CancellationTokenSource.CreateLinkedTokenSource(appCts.Token);
transitionCts.CancelAfter(TimeSpan.FromSeconds(2));
await WaitForConditionAsync(
() => terminal.Contains("Enter code: ABCD-EFGH")
&& !terminal.Contains("Starting device authorization..."),
transitionCts.Token);
}
finally
{
input.EnqueueKey(ConsoleKey.Q, control: true);
await run.WaitAsync(appCts.Token);
}
}

private (VirtualTerminal Terminal, TerminaApplication App, ProviderManagerViewModel Vm)
CreateHeadlessApp(out VirtualInputSource input)
{
Expand Down Expand Up @@ -178,4 +220,13 @@ private void WriteConfig(Dictionary<string, object> data)
File.WriteAllText(_paths.NetclawConfigPath,
JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
}

private static async Task WaitForConditionAsync(Func<bool> predicate, CancellationToken ct)
{
while (!predicate())
{
ct.ThrowIfCancellationRequested();
await Task.Yield();
}
}
}
9 changes: 8 additions & 1 deletion src/Netclaw.Cli/Tui/ModelManagerPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ private LayoutNode BuildContent()

return ViewModel.CurrentState.Value switch
{
ModelManagerState.Loading => BuildLoading(),
ModelManagerState.RoleOverview => BuildRoleOverview(),
ModelManagerState.SelectProvider => BuildProviderSelection(),
ModelManagerState.DiscoverModels => BuildDiscoverModels(),
Expand Down Expand Up @@ -135,9 +136,14 @@ private LayoutNode BuildKeyBindings()
// Content views
// ═══════════════════════════════════════════════════════════════════

private static ILayoutNode BuildLoading()
{
return new TextNode(" Loading model configuration...")
.WithForeground(Color.BrightBlack);
}

private ILayoutNode BuildRoleOverview()
{
var models = ViewModel.Models;
var items = new[] { "Main", "Fallback", "Compaction" };

_roleList = Layouts.SelectionList(items, role => FormatRoleItem(role, role switch
Expand Down Expand Up @@ -430,6 +436,7 @@ private enum ModelManagerContent
RoleOverview = ModelManagerState.RoleOverview,
SelectProvider = ModelManagerState.SelectProvider,
ConfirmAssignment = ModelManagerState.ConfirmAssignment,
Loading = ModelManagerState.Loading,
DiscoverProbing,
DiscoverFailed,
DiscoverEmpty,
Expand Down
24 changes: 12 additions & 12 deletions src/Netclaw.Cli/Tui/ModelManagerViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public enum ModelManagerState
RoleOverview,
SelectProvider,
DiscoverModels,
ConfirmAssignment
ConfirmAssignment,
Loading
}

/// <summary>
Expand All @@ -47,7 +48,7 @@ public sealed class ModelManagerViewModel : ReactiveViewModel
/// </summary>
internal bool IsEmbeddedInConfig { get; set; }

public ReactiveProperty<ModelManagerState> CurrentState { get; } = new(ModelManagerState.RoleOverview);
public ReactiveProperty<ModelManagerState> CurrentState { get; } = new(ModelManagerState.Loading);
public ReactiveProperty<string> StatusMessage { get; } = new("");
public ReactiveProperty<bool> IsProbing { get; } = new(false);
public ReactiveProperty<ProviderProbeResult?> ProbeResult { get; } = new(null);
Expand Down Expand Up @@ -87,6 +88,8 @@ public override void OnActivated()
{
base.OnActivated();
Refresh();
CurrentState.Value = ModelManagerState.RoleOverview;
NotifyStateChanged();

Input.OfType<IInputEvent, KeyPressed>()
.Subscribe(HandleGlobalKey)
Expand Down Expand Up @@ -151,7 +154,6 @@ public void SelectProvider(string providerName)
{
SelectedProvider = providerName;
CurrentState.Value = ModelManagerState.DiscoverModels;
NotifyStateChanged();
StartProbe();
}

Expand Down Expand Up @@ -252,7 +254,6 @@ public void StartDiscovery(string providerName)
SelectedProvider = providerName;
SelectedRole = null;
CurrentState.Value = ModelManagerState.DiscoverModels;
NotifyStateChanged();
StartProbe();
}

Expand Down Expand Up @@ -306,6 +307,13 @@ public void RequestQuit()
internal void StartProbe()
{
CancelProbe();
ManualModelEntry = false;
SelectedModelId = null;
IsProbing.Value = true;
ProbeResult.Value = null;
ProbeElapsedSeconds.Value = 0;
DiscoveredModels.Clear();
NotifyStateChanged();
ProbeCompletion = ProbeProviderAsync();
}

Expand Down Expand Up @@ -337,14 +345,6 @@ internal async Task ProbeProviderAsync()
var stopwatch = Stopwatch.StartNew();
Exception? probeException = null;

ManualModelEntry = false;
SelectedModelId = null;
IsProbing.Value = true;
ProbeResult.Value = null;
ProbeElapsedSeconds.Value = 0;
DiscoveredModels.Clear();
RequestRedraw();

ProbeDiagnosticsLog.Write(
_paths,
"model-manager",
Expand Down
1 change: 1 addition & 0 deletions src/Netclaw.Cli/Tui/ProviderManagerPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ private LayoutNode BuildContent()
var state = ViewModel.CurrentState.Value;
var revision = state is ProviderManagerState.Loading
or ProviderManagerState.AddValidating
or ProviderManagerState.AddOAuthDeviceFlow
or ProviderManagerState.Details
? ViewModel.StateVersion.Value
: 0;
Expand Down
Loading
Loading