diff --git a/scripts/smoke/run-tapes.sh b/scripts/smoke/run-tapes.sh index ed2b07963..0c4089eda 100755 --- a/scripts/smoke/run-tapes.sh +++ b/scripts/smoke/run-tapes.sh @@ -28,16 +28,22 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SMOKE_SCRIPTS="${ROOT_DIR}/scripts/smoke" TAPES_DIR="${ROOT_DIR}/tests/smoke-interactive/tapes" -# PR-gating subset. Order matters: help.tape is the cheapest harness check. +# Cheapest harness checks first so CI fails fast on harness-level +# breakage before paying for the wizard + probe tapes. LIGHT_TAPES=( help init-wizard + provider-add + provider-rename + tui-cleanup ) -# Nightly full suite. As more tapes are authored, append them here. FULL_TAPES=( help init-wizard + provider-add + provider-rename + tui-cleanup ) usage() { diff --git a/src/Netclaw.Cli.Tests/Provider/ProviderCommandTests.cs b/src/Netclaw.Cli.Tests/Provider/ProviderCommandTests.cs index 70e14b195..b5e171ae8 100644 --- a/src/Netclaw.Cli.Tests/Provider/ProviderCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Provider/ProviderCommandTests.cs @@ -282,6 +282,102 @@ public void LoadProviders_DecryptsEncryptedOAuthTokenExpiry() Assert.Equal(DateTimeOffset.Parse(expiry), providers["my-openai"].OAuthTokenExpiry!.Value); } + [Fact] + public async Task Rename_CascadesToModelRoles() + { + // End-to-end testing surfaced that the old "config-key-only" behavior + // left dangling Models.*.Provider references and forced users to fix + // each role by hand. Rename now cascades to any role that points at + // the old name in the same atomic write. + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["openai-compatible"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "http://localhost:8000" + } + }, + ["Models"] = new Dictionary + { + ["Main"] = new Dictionary + { + ["Provider"] = "openai-compatible", + ["ModelId"] = "Qwen/Qwen3.6-35B-A3B-FP8" + } + } + }); + + await ProviderCommand.RunAsync( + ["provider", "rename", "openai-compatible", "my-test-provider"], + _paths, + output: _output); + + var output = _output.ToString(); + Assert.Contains("Renamed provider 'openai-compatible' to 'my-test-provider'.", output); + Assert.Contains("Reassigned model role(s): Main", output); + + // Config file: Models.Main.Provider now points at the new name. + using var doc = ReadConfigFile(_paths.NetclawConfigPath); + var main = doc.RootElement.GetProperty("Models").GetProperty("Main"); + Assert.Equal("my-test-provider", main.GetProperty("Provider").GetString()); + Assert.Equal("Qwen/Qwen3.6-35B-A3B-FP8", main.GetProperty("ModelId").GetString()); + } + + [Fact] + public async Task Rename_WithNoModelRefs_ReportsRenameOnly() + { + // A clean rename with no model roles referencing the provider should + // not print any "Reassigned …" follow-up — that's noise. + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + + await ProviderCommand.RunAsync( + ["provider", "rename", "my-vllm", "lab-a100"], + _paths, + output: _output); + + var output = _output.ToString(); + Assert.Contains("Renamed provider 'my-vllm' to 'lab-a100'.", output); + Assert.DoesNotContain("Reassigned", output); + } + + [Fact] + public void GetReferencingModelRoleEntries_ReturnsRoleAndModelId() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Models"] = new Dictionary + { + ["Main"] = new Dictionary + { + ["Provider"] = "my-vllm", + ["ModelId"] = "Qwen/Qwen3-30B" + }, + ["Fallback"] = new Dictionary + { + ["Provider"] = "my-ollama", + ["ModelId"] = "qwen3:30b" + } + } + }); + + var entries = ProviderCommand.GetReferencingModelRoleEntries("my-vllm", _paths); + + Assert.Single(entries); + Assert.Equal("Main", entries[0].Role); + Assert.Equal("Qwen/Qwen3-30B", entries[0].ModelId); + } + private void WriteConfig(Dictionary data) { File.WriteAllText(_paths.NetclawConfigPath, diff --git a/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs b/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs new file mode 100644 index 000000000..a1bd61020 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs @@ -0,0 +1,309 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Provider; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Provider; + +public sealed class ProviderRenamerTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public ProviderRenamerTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Rename_SwapsKeyInConfigAndSecrets() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "http://localhost:8080", + ["AuthMethod"] = "ApiKey" + } + } + }); + + WriteSecrets(new Dictionary + { + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary + { + ["ApiKey"] = "sk-fake" + } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); + + Assert.True(result.Success); + Assert.Null(result.ErrorMessage); + + var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var providers = config.RootElement.GetProperty("Providers"); + Assert.False(providers.TryGetProperty("my-vllm", out _)); + Assert.True(providers.TryGetProperty("lab-a100", out var entry)); + Assert.Equal("openai-compatible", entry.GetProperty("Type").GetString()); + Assert.Equal("http://localhost:8080", entry.GetProperty("Endpoint").GetString()); + + var secrets = JsonDocument.Parse(File.ReadAllText(_paths.SecretsPath)); + var secretProviders = secrets.RootElement.GetProperty("Providers"); + Assert.False(secretProviders.TryGetProperty("my-vllm", out _)); + Assert.True(secretProviders.TryGetProperty("lab-a100", out _)); + } + + [Fact] + public void Rename_NoSecretsEntry_StillSucceeds() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-ollama"] = new Dictionary + { + ["Type"] = "ollama", + ["Endpoint"] = "http://localhost:11434" + } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-ollama", "lab-ollama"); + + Assert.True(result.Success); + + var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var providers = config.RootElement.GetProperty("Providers"); + Assert.True(providers.TryGetProperty("lab-ollama", out _)); + } + + [Fact] + public void Rename_OldNameMissing_ReturnsError() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary() + }); + + var result = ProviderRenamer.Rename(_paths, "does-not-exist", "anything"); + + Assert.False(result.Success); + Assert.Contains("does-not-exist", result.ErrorMessage!); + } + + [Fact] + public void Rename_EmptyNewName_ReturnsError() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm", " "); + + Assert.False(result.Success); + Assert.NotEmpty(result.ErrorMessage!); + } + + [Fact] + public void Rename_CollidesWithExistingProvider_ReturnsError() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm-a"] = new Dictionary { ["Type"] = "openai-compatible" }, + ["my-vllm-b"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm-a", "my-vllm-b"); + + Assert.False(result.Success); + Assert.Contains("my-vllm-b", result.ErrorMessage!); + } + + [Fact] + public void Rename_CollisionCheckIsCaseInsensitive() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" }, + ["my-ollama"] = new Dictionary { ["Type"] = "ollama" } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm", "MY-OLLAMA"); + + Assert.False(result.Success); + } + + [Fact] + public void Rename_CaseOnlyChange_RewritesKeyInPlace() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm", "My-Vllm"); + + Assert.True(result.Success); + + var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var providers = config.RootElement.GetProperty("Providers"); + Assert.True(providers.TryGetProperty("My-Vllm", out _)); + Assert.False(providers.TryGetProperty("my-vllm", out _)); + } + + [Fact] + public void Rename_CascadesToAllMatchingModelRoles() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" }, + ["my-ollama"] = new Dictionary { ["Type"] = "ollama" } + }, + ["Models"] = new Dictionary + { + ["Main"] = new Dictionary + { + ["Provider"] = "my-vllm", + ["ModelId"] = "Qwen/Qwen3-30B" + }, + ["Fallback"] = new Dictionary + { + ["Provider"] = "my-vllm", + ["ModelId"] = "Qwen/Qwen3-7B" + }, + ["Compaction"] = new Dictionary + { + ["Provider"] = "my-ollama", + ["ModelId"] = "qwen3:7b" + } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); + + Assert.True(result.Success); + Assert.Equal(new[] { "Main", "Fallback" }, result.ReassignedModelRoles); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var models = doc.RootElement.GetProperty("Models"); + Assert.Equal("lab-a100", models.GetProperty("Main").GetProperty("Provider").GetString()); + Assert.Equal("lab-a100", models.GetProperty("Fallback").GetProperty("Provider").GetString()); + Assert.Equal("my-ollama", models.GetProperty("Compaction").GetProperty("Provider").GetString()); + } + + [Fact] + public void Rename_NoModelsSection_ReportsEmptyReassignments() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); + + Assert.True(result.Success); + Assert.Empty(result.ReassignedModelRoles); + } + + [Fact] + public void Rename_CascadeIsCaseInsensitive() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + }, + ["Models"] = new Dictionary + { + ["Main"] = new Dictionary + { + ["Provider"] = "MY-VLLM", + ["ModelId"] = "x" + } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm", "lab-a100"); + + Assert.True(result.Success); + Assert.Contains("Main", result.ReassignedModelRoles); + } + + [Fact] + public void Rename_TrimsWhitespaceOnNewName() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary { ["Type"] = "openai-compatible" } + } + }); + + var result = ProviderRenamer.Rename(_paths, "my-vllm", " lab-a100 "); + + Assert.True(result.Success); + + var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var providers = config.RootElement.GetProperty("Providers"); + Assert.True(providers.TryGetProperty("lab-a100", out _)); + } + + private void WriteConfig(Dictionary data) + { + File.WriteAllText(_paths.NetclawConfigPath, + JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + } + + private void WriteSecrets(Dictionary data) + { + File.WriteAllText(_paths.SecretsPath, + JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs index 3f9d6c0aa..08e4aef96 100644 --- a/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs @@ -243,7 +243,7 @@ public async Task EagerProbe_NoConfiguredProviders_GoesDirectlyToList() } [Fact] - public void ActivateSelectedProvider_Unconfigured_StartsAddFlow() + public void ActivateSelectedProvider_Unconfigured_StartsAtAddName() { using var vm = CreateViewModel(); vm.RefreshDisplayProviders(); @@ -254,13 +254,31 @@ public void ActivateSelectedProvider_Unconfigured_StartsAddFlow() vm.SelectedProviderIndex = ollamaIndex; vm.ActivateSelectedProvider(); - // Ollama has [None] auth, so goes straight to AddCredentials - Assert.Equal(ProviderManagerState.AddCredentials, vm.CurrentState.Value); + // Add flow always starts at AddName regardless of auth type so the + // user can confirm or override the auto-generated provider name. + Assert.Equal(ProviderManagerState.AddName, vm.CurrentState.Value); Assert.Equal("ollama", vm.NewProviderType); + Assert.False(string.IsNullOrEmpty(vm.NewProviderName)); + } + + [Fact] + public void AdvanceAfterName_NoAuthProvider_GoesToCredentials() + { + using var vm = CreateViewModel(); + vm.RefreshDisplayProviders(); + vm.CurrentState.Value = ProviderManagerState.List; + + var ollamaIndex = vm.DisplayProviders.FindIndex(p => p.ProviderType == "ollama"); + vm.SelectedProviderIndex = ollamaIndex; + vm.ActivateSelectedProvider(); + vm.AdvanceAfterName(); + + // Ollama has [None] auth, so AdvanceAfterName routes to AddCredentials. + Assert.Equal(ProviderManagerState.AddCredentials, vm.CurrentState.Value); } [Fact] - public void ActivateSelectedProvider_Unconfigured_ApiKeyProvider_GoesToAuthSelect() + public void AdvanceAfterName_ApiKeyProvider_GoesToAuthSelect() { using var vm = CreateViewModel(); vm.RefreshDisplayProviders(); @@ -269,11 +287,131 @@ public void ActivateSelectedProvider_Unconfigured_ApiKeyProvider_GoesToAuthSelec var anthropicIndex = vm.DisplayProviders.FindIndex(p => p.ProviderType == "anthropic"); vm.SelectedProviderIndex = anthropicIndex; vm.ActivateSelectedProvider(); + vm.AdvanceAfterName(); Assert.Equal(ProviderManagerState.AddSelectAuth, vm.CurrentState.Value); Assert.Equal("anthropic", vm.NewProviderType); } + [Fact] + public void TrySetNewProviderName_TrimsAndAcceptsUniqueName() + { + using var vm = CreateViewModel(); + vm.RefreshDisplayProviders(); + + Assert.True(vm.TrySetNewProviderName(" lab-a100 ", out var err)); + Assert.Equal("", err); + Assert.Equal("lab-a100", vm.NewProviderName); + } + + [Fact] + public void TrySetNewProviderName_RejectsEmptyAndWhitespace() + { + using var vm = CreateViewModel(); + vm.RefreshDisplayProviders(); + + Assert.False(vm.TrySetNewProviderName("", out var err1)); + Assert.NotEmpty(err1); + + Assert.False(vm.TrySetNewProviderName(" ", out var err2)); + Assert.NotEmpty(err2); + + Assert.False(vm.TrySetNewProviderName(null, out var err3)); + Assert.NotEmpty(err3); + } + + [Fact] + public void TrySetNewProviderName_RejectsCollisionCaseInsensitive() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "http://localhost:8080" + } + } + }); + + using var vm = CreateViewModel(); + vm.RefreshDisplayProviders(); + + Assert.False(vm.TrySetNewProviderName("MY-VLLM", out var err)); + Assert.Contains("my-vllm", err, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TrySetNewProviderName_OnFailure_PreservesCandidateForRedraw() + { + // When validation fails the user's typed text must survive on the + // view model so the next view build re-prefills the input with what + // they typed; otherwise their entry vanishes when ErrorMessage + // triggers a redraw. + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "http://localhost:8080" + } + } + }); + + using var vm = CreateViewModel(); + vm.RefreshDisplayProviders(); + vm.NewProviderName = "lab-default"; + + Assert.False(vm.TrySetNewProviderName("MY-VLLM", out _)); + + Assert.Equal("MY-VLLM", vm.NewProviderName); + } + + [Fact] + public async Task ConfirmRename_OnFailure_PreservesCandidateForRedraw() + { + // Same redraw-preservation rule as TrySetNewProviderName, but for the + // rename flow: a failed rename must leave the bad name on the view + // model so the next redraw re-prefills the input. + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "http://localhost:8080" + }, + ["my-ollama"] = new Dictionary + { + ["Type"] = "ollama", + ["Endpoint"] = "http://localhost:11434" + } + } + }); + + using var vm = CreateViewModel(); + await ActivateAndProbeAsync(vm); + + var idx = vm.DisplayProviders.FindIndex(p => p.ConfiguredName == "my-vllm"); + vm.DetailProvider = vm.DisplayProviders[idx]; + vm.CurrentState.Value = ProviderManagerState.RenameProvider; + vm.RenameNewName = "my-vllm"; + + // Collides with the other existing provider. + vm.ConfirmRename("my-ollama"); + + Assert.Equal(ProviderManagerState.RenameProvider, vm.CurrentState.Value); + Assert.NotEmpty(vm.ErrorMessage.Value); + Assert.Equal("my-ollama", vm.RenameNewName); + } + [Fact] public async Task ActivateSelectedProvider_Healthy_TransitionsToDetails() { @@ -421,6 +559,7 @@ public async Task AddProvider_WritesCorrectConfigStructure() var idx = vm.DisplayProviders.FindIndex(p => p.ProviderType == "openrouter"); vm.SelectedProviderIndex = idx; vm.ActivateSelectedProvider(); + vm.AdvanceAfterName(); vm.SelectAuthMethod(AuthMethod.ApiKey); vm.NewApiKey = "sk-test-key"; @@ -458,6 +597,7 @@ public async Task SubmitCredentials_WhenProbeThrows_ReportsFailureAndStopsProbin var idx = vm.DisplayProviders.FindIndex(p => p.ProviderType == "openrouter"); vm.SelectedProviderIndex = idx; vm.ActivateSelectedProvider(); + vm.AdvanceAfterName(); vm.SelectAuthMethod(AuthMethod.ApiKey); vm.NewApiKey = "sk-test"; @@ -479,6 +619,7 @@ public async Task SubmitCredentials_PublishesResultAfterIsProbingClears() var idx = vm.DisplayProviders.FindIndex(p => p.ProviderType == "openrouter"); vm.SelectedProviderIndex = idx; vm.ActivateSelectedProvider(); + vm.AdvanceAfterName(); vm.SelectAuthMethod(AuthMethod.ApiKey); vm.NewApiKey = "sk-test"; @@ -567,6 +708,22 @@ public async Task RemoveProvider_ReferencedByModelRole_IsRejected() Assert.Contains("Main", vm.RemoveBlockingRoles); } + [Fact] + public void GoBack_FromAddName_ReturnsToList() + { + using var vm = CreateViewModel(); + vm.RefreshDisplayProviders(); + vm.CurrentState.Value = ProviderManagerState.List; + + var idx = vm.DisplayProviders.FindIndex(p => p.ProviderType == "anthropic"); + vm.SelectedProviderIndex = idx; + vm.ActivateSelectedProvider(); + Assert.Equal(ProviderManagerState.AddName, vm.CurrentState.Value); + + vm.GoBack(); + Assert.Equal(ProviderManagerState.List, vm.CurrentState.Value); + } + [Fact] public void GoBack_FromAddSelectAuth_ReturnsToList() { @@ -577,6 +734,7 @@ public void GoBack_FromAddSelectAuth_ReturnsToList() var idx = vm.DisplayProviders.FindIndex(p => p.ProviderType == "anthropic"); vm.SelectedProviderIndex = idx; vm.ActivateSelectedProvider(); + vm.AdvanceAfterName(); Assert.Equal(ProviderManagerState.AddSelectAuth, vm.CurrentState.Value); vm.GoBack(); @@ -693,6 +851,129 @@ public void GoBack_FromAddSelectType_ReturnsToList() Assert.Equal(ProviderManagerState.List, vm.CurrentState.Value); } + [Fact] + public async Task AddProvider_UsesCustomNameWhenUserProvidesOne() + { + using var vm = CreateViewModel(); + await ActivateAndProbeAsync(vm); + + var idx = vm.DisplayProviders.FindIndex(p => p.ProviderType == "openrouter"); + vm.SelectedProviderIndex = idx; + vm.ActivateSelectedProvider(); + + // User edits the name on the AddName step. + Assert.True(vm.TrySetNewProviderName("lab-a100", out _)); + vm.AdvanceAfterName(); + + vm.SelectAuthMethod(AuthMethod.ApiKey); + vm.NewApiKey = "sk-test-key"; + vm.SubmitCredentials(); + + await vm.ProbeCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + vm.ConfirmAdd(); + await vm.EagerProbeCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var providerNames = config.RootElement.GetProperty("Providers").EnumerateObject() + .Select(p => p.Name).ToList(); + Assert.Single(providerNames); + Assert.Equal("lab-a100", providerNames[0]); + } + + [Fact] + public async Task StartRename_TransitionsToRenameProvider() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "http://localhost:8080" + } + } + }); + + using var vm = CreateViewModel(); + await ActivateAndProbeAsync(vm); + + var idx = vm.DisplayProviders.FindIndex(p => p.ConfiguredName == "my-vllm"); + vm.SelectedProviderIndex = idx; + vm.ActivateSelectedProvider(); + // Force into Details (probe outcome doesn't matter here). + vm.DetailProvider = vm.DisplayProviders[idx]; + + vm.StartRename(); + + Assert.Equal(ProviderManagerState.RenameProvider, vm.CurrentState.Value); + Assert.Equal("my-vllm", vm.RenameNewName); + } + + [Fact] + public async Task ConfirmRename_SwapsKeyAndReturnsToList() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "http://localhost:8080" + } + } + }); + + using var vm = CreateViewModel(); + await ActivateAndProbeAsync(vm); + + var idx = vm.DisplayProviders.FindIndex(p => p.ConfiguredName == "my-vllm"); + vm.DetailProvider = vm.DisplayProviders[idx]; + vm.CurrentState.Value = ProviderManagerState.Details; + + vm.ConfirmRename("lab-a100"); + await vm.EagerProbeCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(ProviderManagerState.List, vm.CurrentState.Value); + + var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var providers = config.RootElement.GetProperty("Providers"); + Assert.False(providers.TryGetProperty("my-vllm", out _)); + Assert.True(providers.TryGetProperty("lab-a100", out _)); + } + + [Fact] + public async Task ConfirmRename_EmptyName_KeepsCurrentStateAndSetsError() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-vllm"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "http://localhost:8080" + } + } + }); + + using var vm = CreateViewModel(); + await ActivateAndProbeAsync(vm); + + var idx = vm.DisplayProviders.FindIndex(p => p.ConfiguredName == "my-vllm"); + vm.DetailProvider = vm.DisplayProviders[idx]; + vm.CurrentState.Value = ProviderManagerState.RenameProvider; + + vm.ConfirmRename(" "); + + Assert.Equal(ProviderManagerState.RenameProvider, vm.CurrentState.Value); + Assert.NotEmpty(vm.ErrorMessage.Value); + } + private ProviderManagerViewModel CreateViewModel() { return new ProviderManagerViewModel(_paths, ProviderCommand.CreateDefaultRegistry(), _fakeProbe); diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 284fbba33..8e4533b16 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -44,6 +44,13 @@ WriteCrashLog(ex); throw; } +finally +{ + // Emit any buffered update notice now that the mode handler has returned + // (and any TUI has torn down its alt screen). Writing the notice from the + // background task directly would corrupt an active TUI mid-render. + UpdateCommand.EmitPendingNoticeIfReady(); +} static async Task RunAsync(string[] args) { @@ -74,10 +81,10 @@ static async Task RunAsync(string[] args) break; } - // Fire-and-forget update check for non-TUI modes only. - // TUI modes (chat, sessions, headless, init) must not run background checks - // that write to Console, as it corrupts the terminal UI. - if (mode is not ("chat" or "sessions" or "headless" or "init")) + // Kick off the update check in the background. The notice is buffered + // and emitted by EmitPendingNoticeIfReady in Main's finally — safe for + // every mode including TUIs, because we only print after the mode + // handler returns and the alt screen has been torn down. { var backgroundUpdateConfig = BuildCliConfig(); var backgroundDaemonConfig = DaemonConfig.BindFromConfiguration(backgroundUpdateConfig.GetSection("Daemon")); diff --git a/src/Netclaw.Cli/Provider/ProviderCommand.cs b/src/Netclaw.Cli/Provider/ProviderCommand.cs index 29e6e9f42..e27b7b236 100644 --- a/src/Netclaw.Cli/Provider/ProviderCommand.cs +++ b/src/Netclaw.Cli/Provider/ProviderCommand.cs @@ -32,11 +32,40 @@ public static Task RunAsync( "list" => Task.FromResult(RunList(paths, registry, writer)), "add" => RunAddAsync(args, paths, registry, writer), "remove" => Task.FromResult(RunRemove(args, paths, writer)), + "rename" => Task.FromResult(RunRename(args, paths, writer)), "help" or "-h" or "--help" => Task.FromResult(WriteHelp(registry, writer)), _ => Task.FromResult(WriteHelp(registry, writer)) }; } + private static int RunRename(string[] args, NetclawPaths paths, TextWriter writer) + { + if (args.Length < 4) + { + writer.WriteLine("Usage: netclaw provider rename "); + return 1; + } + + var oldName = args[2]; + var newName = args[3]; + + var result = ProviderRenamer.Rename(paths, oldName, newName); + if (!result.Success) + { + writer.WriteLine($"Error: {result.ErrorMessage}"); + return 1; + } + + writer.WriteLine($"Renamed provider '{oldName}' to '{newName}'."); + + if (result.ReassignedModelRoles.Count > 0) + { + writer.WriteLine($"Reassigned model role(s): {string.Join(", ", result.ReassignedModelRoles)}."); + } + + return 0; + } + private static int RunList(NetclawPaths paths, ProviderDescriptorRegistry registry, TextWriter writer) { var providers = LoadProviders(paths); @@ -381,14 +410,23 @@ internal static Dictionary LoadProviders(NetclawPaths pat /// Check which model roles reference the given provider name. /// internal static List GetReferencingModelRoles(string providerName, NetclawPaths paths) + => GetReferencingModelRoleEntries(providerName, paths).Select(e => e.Role).ToList(); + + /// + /// Like but also returns each role's current + /// ModelId so callers can build a fully copy-pasteable + /// netclaw model set command in their guidance output. + /// + internal static List<(string Role, string ModelId)> GetReferencingModelRoleEntries( + string providerName, NetclawPaths paths) { - var roles = new List(); + var entries = new List<(string, string)>(); if (!File.Exists(paths.NetclawConfigPath)) - return roles; + return entries; using var doc = JsonDocument.Parse(File.ReadAllText(paths.NetclawConfigPath)); if (!doc.RootElement.TryGetProperty("Models", out var models)) - return roles; + return entries; foreach (var roleName in new[] { "Main", "Fallback", "Compaction" }) { @@ -396,11 +434,14 @@ internal static List GetReferencingModelRoles(string providerName, Netcl role.TryGetProperty("Provider", out var provider) && string.Equals(provider.GetString(), providerName, StringComparison.OrdinalIgnoreCase)) { - roles.Add(roleName); + var modelId = role.TryGetProperty("ModelId", out var mid) + ? mid.GetString() ?? "" + : ""; + entries.Add((roleName, modelId)); } } - return roles; + return entries; } private static void WriteProviderGuidance(IProviderDescriptor descriptor, TextWriter writer) @@ -431,9 +472,10 @@ private static int WriteHelp(ProviderDescriptorRegistry registry, TextWriter wri writer.WriteLine("Usage: netclaw provider "); writer.WriteLine(); writer.WriteLine("Subcommands:"); - writer.WriteLine(" list List configured providers"); - writer.WriteLine(" add [options] Add a provider"); - writer.WriteLine(" remove Remove a provider"); + writer.WriteLine(" list List configured providers"); + writer.WriteLine(" add [options] Add a provider"); + writer.WriteLine(" rename Rename a provider (config key only)"); + writer.WriteLine(" remove Remove a provider"); writer.WriteLine(); writer.WriteLine("Run `netclaw provider` (no subcommand) for interactive TUI management."); writer.WriteLine(); @@ -448,6 +490,7 @@ private static int WriteHelp(ProviderDescriptorRegistry registry, TextWriter wri writer.WriteLine(" netclaw provider add my-ollama ollama --endpoint http://my-gpu-server:11434"); writer.WriteLine(" netclaw provider add my-anthropic anthropic --api-key sk-ant-..."); writer.WriteLine(" netclaw provider add my-openai openai --auth oauth-device"); + writer.WriteLine(" netclaw provider rename my-ollama lab-a100"); writer.WriteLine(" netclaw provider remove my-ollama"); return 0; } diff --git a/src/Netclaw.Cli/Provider/ProviderRenamer.cs b/src/Netclaw.Cli/Provider/ProviderRenamer.cs new file mode 100644 index 000000000..07741eb22 --- /dev/null +++ b/src/Netclaw.Cli/Provider/ProviderRenamer.cs @@ -0,0 +1,140 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Provider; + +/// +/// Renames a provider entry across netclaw.json and +/// .secrets/netclaw-secrets.json. Swaps the Providers +/// dictionary key and cascades the rename to any +/// Models.{Main,Fallback,Compaction}.Provider entries that pointed +/// at the old name, so the daemon never sees a dangling reference after +/// a rename. +/// +internal readonly record struct RenameResult( + bool Success, + string? ErrorMessage, + IReadOnlyList ReassignedModelRoles) +{ + public static RenameResult Ok(IReadOnlyList reassignedRoles) => + new(true, null, reassignedRoles); + + public static RenameResult Fail(string message) => + new(false, message, Array.Empty()); +} + +internal static class ProviderRenamer +{ + private static readonly string[] ModelRoleNames = ["Main", "Fallback", "Compaction"]; + + /// + /// Rename a provider, cascading the rename to any model roles that + /// reference it. + /// + /// + /// Validation rules: + /// + /// must exist in Providers in netclaw.json. + /// must be non-empty after trimming. + /// must not collide (case-insensitive) with any other + /// provider key already present in either file. + /// A case-only change (e.g. my-vllmMy-Vllm) is permitted and rewrites + /// the key in place. + /// + /// + public static RenameResult Rename(NetclawPaths paths, string oldName, string newName) + { + var trimmed = newName?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(trimmed)) + return RenameResult.Fail("Provider name cannot be empty."); + + var (config, secrets) = ConfigFileHelper.LoadConfigFiles(paths); + + var providers = ConfigFileHelper.GetSectionOrNull(config, "Providers"); + if (providers is null || !providers.ContainsKey(oldName)) + return RenameResult.Fail($"Provider '{oldName}' not found."); + + // Collision check: walk both config and secrets dictionaries. A key + // that case-insensitive-equals oldName is the entry we're renaming and + // is not a collision. Any other key that case-insensitive-equals the + // new name is a collision. + if (HasCollision(providers, oldName, trimmed)) + return RenameResult.Fail($"A provider named '{trimmed}' already exists."); + + var secretProviders = ConfigFileHelper.GetSectionOrNull(secrets, "Providers"); + if (secretProviders is not null && HasCollision(secretProviders, oldName, trimmed)) + return RenameResult.Fail($"A provider named '{trimmed}' already exists in secrets."); + + var entry = providers[oldName]; + providers.Remove(oldName); + providers[trimmed] = entry; + + var reassigned = CascadeRenameModelRoles(config, oldName, trimmed); + + ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); + + if (secretProviders is not null && secretProviders.TryGetValue(oldName, out var secretEntry)) + { + secretProviders.Remove(oldName); + secretProviders[trimmed] = secretEntry; + ConfigFileHelper.WriteSecretsFile(paths, secrets); + } + + return RenameResult.Ok(reassigned); + } + + private static List CascadeRenameModelRoles( + Dictionary config, string oldName, string newName) + { + var reassigned = new List(); + var models = ConfigFileHelper.GetSectionOrNull(config, "Models"); + if (models is null) return reassigned; + + foreach (var roleName in ModelRoleNames) + { + var role = ConfigFileHelper.GetSectionOrNull(models, roleName); + if (role is null || !role.TryGetValue("Provider", out var providerValue)) + continue; + + // The leaf value may still be a JsonElement (loaded straight from + // disk) or already a string (if the section was re-materialized + // earlier in this call). Normalize both. + var current = providerValue switch + { + JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(), + string s => s, + _ => null + }; + + if (current is not null + && string.Equals(current, oldName, StringComparison.OrdinalIgnoreCase)) + { + role["Provider"] = newName; + reassigned.Add(roleName); + } + } + + return reassigned; + } + + private static bool HasCollision( + Dictionary section, string oldName, string newName) + { + foreach (var key in section.Keys) + { + if (string.Equals(key, oldName, StringComparison.OrdinalIgnoreCase)) + continue; + + if (string.Equals(key, newName, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs index fe504715e..7a7851e81 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs @@ -36,6 +36,8 @@ public ProviderManagerPage(IClipboardService? clipboardService = null) private SelectionListNode? _authList; private TextInputNode? _apiKeyInput; private TextInputNode? _endpointInput; + private TextInputNode? _nameInput; + private TextInputNode? _renameInput; private SelectionListNode? _confirmList; private IFocusable? _lastFocusedList; @@ -87,6 +89,7 @@ private LayoutNode BuildContent() ProviderManagerState.Loading => BuildLoadingView(), ProviderManagerState.List => BuildProviderListView(), ProviderManagerState.AddSelectType => BuildAddSelectTypeView(), + ProviderManagerState.AddName => BuildAddNameView(), ProviderManagerState.AddSelectAuth => BuildAddAuthView(), ProviderManagerState.AddCredentials => BuildCredentialsView(), ProviderManagerState.AddOAuthDeviceFlow => BuildOAuthDeviceFlowView(), @@ -94,6 +97,7 @@ private LayoutNode BuildContent() ProviderManagerState.AddValidating => BuildValidatingView(), ProviderManagerState.AddComplete => BuildAddCompleteView(), ProviderManagerState.Details => BuildDetailsView(), + ProviderManagerState.RenameProvider => BuildRenameView(), ProviderManagerState.FixCredentials => BuildFixCredentialsView(), ProviderManagerState.RemoveConfirm => BuildRemoveConfirmView(), _ => Layouts.Empty() @@ -126,10 +130,16 @@ private LayoutNode BuildContent() private LayoutNode BuildStatusBar() { - return ViewModel.StatusMessage - .Select(msg => (ILayoutNode)(string.IsNullOrWhiteSpace(msg) - ? Layouts.Empty() - : new TextNode($" {msg}").WithForeground(Color.Green))) + // Combine StatusMessage (success/green) with ErrorMessage (error/red). + // ErrorMessage wins when both are set so the user sees the latest + // validation feedback immediately. + return ViewModel.ErrorMessage + .CombineLatest(ViewModel.StatusMessage, (err, status) => (err, status)) + .Select(t => (ILayoutNode)(!string.IsNullOrWhiteSpace(t.err) + ? new TextNode($" {t.err}").WithForeground(Color.Red) + : !string.IsNullOrWhiteSpace(t.status) + ? new TextNode($" {t.status}").WithForeground(Color.Green) + : Layouts.Empty())) .AsLayout() .Height(1); } @@ -147,8 +157,12 @@ private LayoutNode BuildKeyBindings() " [\u2191/\u2193] Navigate [Enter] Select [Esc] Quit [Ctrl+Q] Quit", ProviderManagerState.AddSelectType => " [\u2191/\u2193] Navigate [Enter] Select [Esc] Back [Ctrl+Q] Quit", + ProviderManagerState.AddName => + " [Enter] Continue [Esc] Cancel [Ctrl+Q] Quit", ProviderManagerState.Details => - " [K] Update key [R] Remove [V] Re-validate [Esc] Back [Ctrl+Q] Quit", + " [K] Update key [N] Rename [R] Remove [V] Re-validate [Esc] Back [Ctrl+Q] Quit", + ProviderManagerState.RenameProvider => + " [Enter] Confirm rename [Esc] Cancel [Ctrl+Q] Quit", ProviderManagerState.RemoveConfirm => " [Enter] Confirm [Esc] Cancel [Ctrl+Q] Quit", ProviderManagerState.AddComplete => @@ -289,6 +303,58 @@ private ILayoutNode BuildAddSelectTypeView() .WithChild(_providerList); } + private ILayoutNode BuildAddNameView() + { + var providerType = ViewModel.NewProviderType ?? "unknown"; + var descriptor = ViewModel.Registry.Get(providerType); + + var children = Layouts.Vertical(); + children.WithChild(new TextNode(" Name your provider").WithForeground(Color.White).Bold()); + children.WithChild(new TextNode("").Height(1)); + children.WithChild(new TextNode($" Type: {descriptor.DisplayName}").WithForeground(Color.White)); + children.WithChild(new TextNode("").Height(1)); + + _nameInput = new TextInputNode().WithPlaceholder($"my-{providerType}"); + _nameInput.Text = ViewModel.NewProviderName ?? string.Empty; + // Termina's Text setter leaves the cursor at position 0. Synthesize + // End so the user can immediately edit the suffix instead of having + // their first keystroke insert before the pre-filled name. + _nameInput.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + _nameInput.OnFocused(); + _lastFocusedInput = _nameInput; + + _nameInput.Submitted + .Subscribe(text => + { + if (ViewModel.TrySetNewProviderName(text, out var error)) + { + ViewModel.ErrorMessage.Value = ""; + ViewModel.AdvanceAfterName(); + } + else + { + ViewModel.ErrorMessage.Value = error; + ViewModel.RequestRedraw(); + } + }) + .DisposeWith(_stepSubs); + + children.WithChild(new PanelNode() + .WithTitle("Name") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Gray) + .WithContent(_nameInput) + .Height(3)); + + children.WithChild(new TextNode("").Height(1)); + children.WithChild(new TextNode(" This is how the provider appears in `netclaw provider list`") + .WithForeground(Color.Gray)); + children.WithChild(new TextNode(" and how model roles reference it. Press [Enter] to continue.") + .WithForeground(Color.Gray)); + + return children; + } + private ILayoutNode BuildAddAuthView() { var providerType = ViewModel.NewProviderType ?? "unknown"; @@ -573,6 +639,46 @@ private ILayoutNode BuildDetailsView() .WithChild(new TextNode($" Models: {modelCount} discovered").WithForeground(Color.White)); } + private ILayoutNode BuildRenameView() + { + var item = ViewModel.DetailProvider; + if (item is null) + return Layouts.Empty(); + + var children = Layouts.Vertical(); + children.WithChild(new TextNode($" Rename '{item.ConfiguredName}' ({item.DisplayName})") + .WithForeground(Color.White).Bold()); + children.WithChild(new TextNode("").Height(1)); + + _renameInput = new TextInputNode().WithPlaceholder(item.ConfiguredName ?? ""); + _renameInput.Text = ViewModel.RenameNewName ?? item.ConfiguredName ?? string.Empty; + // Termina's Text setter leaves the cursor at position 0. Synthesize + // End so the user can immediately edit the suffix instead of having + // their first keystroke insert before the pre-filled name. + _renameInput.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + _renameInput.OnFocused(); + _lastFocusedInput = _renameInput; + + _renameInput.Submitted + .Subscribe(text => ViewModel.ConfirmRename(text)) + .DisposeWith(_stepSubs); + + children.WithChild(new PanelNode() + .WithTitle("New name") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Gray) + .WithContent(_renameInput) + .Height(3)); + + children.WithChild(new TextNode("").Height(1)); + children.WithChild(new TextNode(" Renames the provider and cascades the change to any model") + .WithForeground(Color.Gray)); + children.WithChild(new TextNode(" role(s) that reference it. Restart the daemon for changes to take effect.") + .WithForeground(Color.Gray)); + + return children; + } + private ILayoutNode BuildFixCredentialsView() { var item = ViewModel.DetailProvider; @@ -758,6 +864,9 @@ private void HandleKeyPress(KeyPressed key) if (ViewModel.DetailProvider is not null) ViewModel.StartFixCredentials(ViewModel.DetailProvider); return; + case ConsoleKey.N: + ViewModel.StartRename(); + return; case ConsoleKey.R: ViewModel.StartRemove(); return; diff --git a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs index 6584d894c..acf4dddfe 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs @@ -24,6 +24,7 @@ public enum ProviderManagerState Loading, List, AddSelectType, + AddName, AddSelectAuth, AddCredentials, AddOAuthDeviceFlow, @@ -31,6 +32,7 @@ public enum ProviderManagerState AddValidating, AddComplete, Details, + RenameProvider, FixCredentials, RemoveConfirm } @@ -80,6 +82,7 @@ public sealed class ProviderManagerViewModel : ReactiveViewModel public ReactiveProperty CurrentState { get; } = new(ProviderManagerState.Loading); public ReactiveProperty StatusMessage { get; } = new(""); + public ReactiveProperty ErrorMessage { get; } = new(""); public ReactiveProperty IsProbing { get; } = new(false); public ReactiveProperty ProbeResult { get; } = new(null); public ReactiveProperty ProbeElapsedSeconds { get; } = new(0); @@ -124,6 +127,9 @@ public sealed class ProviderManagerViewModel : ReactiveViewModel public string? RemoveProviderName { get; set; } public List RemoveBlockingRoles { get; } = []; + // ── Rename flow state ── + public string? RenameNewName { get; set; } + /// /// Completes when the provider probe finishes. Used for testing. /// @@ -335,7 +341,10 @@ public void StartAddNewProvider() } /// - /// Start the add flow for a specific provider type (skips type selection). + /// Start the add flow for a specific provider type. Enters the + /// step first so the user + /// can confirm or override the auto-generated provider name before + /// any credential entry happens. /// public void StartAddForType(string type) { @@ -343,7 +352,22 @@ public void StartAddForType(string type) NewProviderType = type; NewProviderName = GenerateProviderName(type); - var descriptor = _registry.Get(type); + CurrentState.Value = ProviderManagerState.AddName; + NotifyStateChanged(); + } + + /// + /// Advance past the step into + /// the auth/credentials portion of the add flow. Routes to + /// directly for + /// endpoint-only providers (where there's nothing to authenticate), + /// otherwise to . + /// + public void AdvanceAfterName() + { + if (NewProviderType is null) return; + + var descriptor = _registry.Get(NewProviderType); if (descriptor.Auth.SupportedAuthMethods is [AuthMethod.None]) { NewAuthMethod = AuthMethod.None; @@ -357,6 +381,46 @@ public void StartAddForType(string type) NotifyStateChanged(); } + /// + /// Validate and apply a user-supplied provider name. Returns true on + /// success (sets to the trimmed input). + /// Returns false and populates on rejection. + /// + /// + /// Validation matches the existing collision check in + /// (case-insensitive comparison against + /// other configured providers). The config schema treats Providers as + /// open-keyed (additionalProperties: true with no propertyNames pattern), + /// so we don't enforce slug rules here — just non-empty and unique. + /// + public bool TrySetNewProviderName(string? proposed, out string error) + { + var trimmed = proposed?.Trim() ?? string.Empty; + + // Persist the candidate on both success and failure so a redraw + // triggered by ErrorMessage doesn't wipe out the user's input. + NewProviderName = trimmed; + + if (string.IsNullOrEmpty(trimmed)) + { + error = "Provider name cannot be empty."; + return false; + } + + foreach (var existing in DisplayProviders) + { + if (existing.ConfiguredName is not null && + string.Equals(existing.ConfiguredName, trimmed, StringComparison.OrdinalIgnoreCase)) + { + error = $"A provider named '{existing.ConfiguredName}' already exists."; + return false; + } + } + + error = string.Empty; + return true; + } + /// /// Start the fix-credentials flow for an unhealthy provider. /// @@ -566,6 +630,84 @@ public void ConfirmRemove() RefreshAndProbeAll(); } + /// + /// Begin a rename of the currently displayed Details provider. + /// Pre-fills with the existing configured name. + /// + public void StartRename() + { + if (DetailProvider is not { IsConfigured: true, ConfiguredName: not null }) + return; + + RenameNewName = DetailProvider.ConfiguredName; + ErrorMessage.Value = ""; + CurrentState.Value = ProviderManagerState.RenameProvider; + NotifyStateChanged(); + } + + /// + /// Apply the proposed rename. Validates and delegates the key swap to + /// . On success, refreshes the + /// provider list and returns to it. On failure, sets + /// and stays on the rename page. + /// + public void ConfirmRename(string? proposed) + { + if (DetailProvider is not { ConfiguredName: { } oldName }) + return; + + var trimmed = proposed?.Trim() ?? string.Empty; + + // Persist the candidate so a redraw triggered by ErrorMessage doesn't + // wipe out the user's input on the validation-failure path below. + RenameNewName = trimmed; + + // Exact match is a no-op so the user can re-confirm without writing. + // Case-only edits (e.g. "my-vllm" → "My-Vllm") fall through to the + // renamer, which rewrites the key in place. + if (string.Equals(trimmed, oldName, StringComparison.Ordinal)) + { + RenameNewName = null; + CurrentState.Value = ProviderManagerState.Details; + NotifyStateChanged(); + return; + } + + var result = Provider.ProviderRenamer.Rename(_paths, oldName, trimmed); + if (!result.Success) + { + ErrorMessage.Value = result.ErrorMessage ?? "Rename failed."; + RequestRedraw(); + return; + } + + StatusMessage.Value = result.ReassignedModelRoles.Count > 0 + ? $"Renamed '{oldName}' to '{trimmed}'. Reassigned model role(s): {string.Join(", ", result.ReassignedModelRoles)}. Restart daemon for changes to take effect." + : $"Renamed '{oldName}' to '{trimmed}'. Restart daemon for changes to take effect."; + + RenameNewName = null; + DetailProvider = null; + RefreshAndProbeAll(); + } + + /// + /// Cancel an in-progress rename and return to the Details view. + /// + public void CancelRename() + { + RenameNewName = null; + ErrorMessage.Value = ""; + if (DetailProvider is not null) + { + CurrentState.Value = ProviderManagerState.Details; + NotifyStateChanged(); + } + else + { + GoBackToList(); + } + } + /// /// Re-probe the detail provider inline from the Details state. /// @@ -626,7 +768,9 @@ public void GoBackToList() FixEndpoint = null; RemoveProviderName = null; RemoveBlockingRoles.Clear(); + RenameNewName = null; StatusMessage.Value = ""; + ErrorMessage.Value = ""; CurrentState.Value = ProviderManagerState.List; NotifyStateChanged(); } @@ -638,6 +782,9 @@ public void GoBack() case ProviderManagerState.AddSelectType: GoBackToList(); break; + case ProviderManagerState.AddName: + GoBackToList(); + break; case ProviderManagerState.AddSelectAuth: GoBackToList(); break; @@ -677,6 +824,9 @@ public void GoBack() case ProviderManagerState.RemoveConfirm: GoBackToList(); break; + case ProviderManagerState.RenameProvider: + CancelRename(); + break; default: Shutdown(); break; @@ -900,6 +1050,7 @@ public override void Dispose() OAuth.Dispose(); CurrentState.Dispose(); StatusMessage.Dispose(); + ErrorMessage.Dispose(); IsProbing.Dispose(); ProbeResult.Dispose(); ProbeElapsedSeconds.Dispose(); diff --git a/src/Netclaw.Cli/Update/UpdateCommand.cs b/src/Netclaw.Cli/Update/UpdateCommand.cs index cea4b9dc3..b8b376e75 100644 --- a/src/Netclaw.Cli/Update/UpdateCommand.cs +++ b/src/Netclaw.Cli/Update/UpdateCommand.cs @@ -353,8 +353,17 @@ internal static void WriteHelp() } /// - /// Quick background update check for CLI startup. - /// Prints a one-line notification if an update is available. + /// Holds the result of the most recent background update check so the + /// notice can be emitted at a safe time (after a TUI exits, after a CLI + /// command finishes writing its own output), instead of from inside the + /// background task itself — which would corrupt any running TUI. + /// + private static string? _pendingNotice; + + /// + /// Quick background update check for CLI startup. Stores a one-line + /// notification in a static buffer if an update is available; emitted by + /// when the program is about to exit. /// internal static async Task BackgroundUpdateCheckAsync(bool selfUpdateDisabled = false) { @@ -370,13 +379,30 @@ internal static async Task BackgroundUpdateCheckAsync(bool selfUpdateDisabled = var hint = selfUpdateDisabled ? "pull a newer container image to upgrade" : "run 'netclaw update'"; - Console.Error.WriteLine( - $"Update available: v{result.CurrentVersion} → v{result.LatestVersion} — {hint}"); + _pendingNotice = + $"Update available: v{result.CurrentVersion} → v{result.LatestVersion} — {hint}"; } } catch (Exception ex) { - Console.Error.WriteLine($"warn: background update check failed: {ex.Message}"); + // A failed background check must never write to stderr mid-TUI; + // Debug.WriteLine is a no-op in Release and bypasses the alt-screen. + System.Diagnostics.Debug.WriteLine( + $"background update check failed: {ex.Message}"); } } + + /// + /// Emit the buffered update notice (if the background check completed + /// and an update is available) to stderr. Safe to call from anywhere — + /// no-op if the check is still in flight or no update was found. + /// Intended to run after the mode handler returns, when any TUI has + /// already torn down its alt screen. + /// + internal static void EmitPendingNoticeIfReady() + { + var notice = Interlocked.Exchange(ref _pendingNotice, null); + if (notice is not null) + Console.Error.WriteLine(notice); + } } diff --git a/tests/smoke-interactive/assertions/_lib.sh b/tests/smoke-interactive/assertions/_lib.sh new file mode 100755 index 000000000..96246eca8 --- /dev/null +++ b/tests/smoke-interactive/assertions/_lib.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Shared helpers for tape post-tape assertion scripts. Source this from +# tests/smoke-interactive/assertions/.sh. +# +# Sources require these env vars (set by scripts/smoke/run-tape.sh): +# PROJECT_NAME docker compose project +# COMPOSE_FILE path to docker-compose.smoke.yml +# NETCLAW_HOME_IN per-tape NETCLAW_HOME inside the sandbox + +: "${PROJECT_NAME:?PROJECT_NAME must be set by run-tape.sh}" +: "${COMPOSE_FILE:?COMPOSE_FILE must be set by run-tape.sh}" +: "${NETCLAW_HOME_IN:?NETCLAW_HOME_IN must be set by run-tape.sh}" + +CONFIG_PATH="${NETCLAW_HOME_IN}/config/netclaw.json" +SOUL_PATH="${NETCLAW_HOME_IN}/identity/SOUL.md" + +compose() { + docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" "$@" +} + +in_sandbox() { + compose exec -T \ + -e "NETCLAW_HOME=${NETCLAW_HOME_IN}" \ + netclaw-sandbox "$@" +} + +# Cat the produced netclaw.json from the sandbox to stdout. Single docker +# exec — callers should capture once and re-use, not re-call per assertion. +read_config_json() { + in_sandbox cat "$CONFIG_PATH" 2>/dev/null +} + +# Assert a jq expression against a JSON blob passed by value. +# Usage: assert_field "$json_blob" +# `tostring` so booleans and missing paths produce comparable strings +# ("true"/"false"/"null") — `// empty` would collapse `false` to "". +# Sets `assert_fail` on mismatch (caller initialises and inspects). +assert_field() { + local jq_expr="$1" + local expected="$2" + local json="$3" + local actual + actual="$(printf '%s' "$json" | jq -r "${jq_expr} | tostring" 2>/dev/null | tr -d '\r')" + if [[ "$actual" != "$expected" ]]; then + printf 'FAIL: expected %s == %s, got %s\n' "$jq_expr" "$expected" "$actual" >&2 + assert_fail=1 + return 1 + fi + printf ' ok %s == %s\n' "$jq_expr" "$expected" +} diff --git a/tests/smoke-interactive/assertions/init-wizard.sh b/tests/smoke-interactive/assertions/init-wizard.sh index 410302684..606eb9e2f 100755 --- a/tests/smoke-interactive/assertions/init-wizard.sh +++ b/tests/smoke-interactive/assertions/init-wizard.sh @@ -2,65 +2,41 @@ # init-wizard.tape post-tape assertion. # # Validates that the wizard produced a usable, schema-valid state: -# 1) ${NETCLAW_HOME}/config/netclaw.json exists and parses as JSON -# 2) `netclaw doctor` (which runs ConfigSchemaDoctorCheck) exits 0 -# 3) Top-level fields the tape was supposed to set look right: -# - Providers["ollama"].Type == "ollama" -# - Providers["ollama"].Endpoint == "http://ollama:11434" -# - Models.Main.Provider == "ollama" -# - Models.Main.ModelId == "qwen2:0.5b" -# - Security.DeploymentPosture == "Personal" -# 4) Identity/SOUL.md contains the user name we typed (SmokeTester). -# -# Invoked by scripts/smoke/run-tape.sh with these env vars exported: -# PROJECT_NAME docker compose project -# COMPOSE_FILE path to docker-compose.smoke.yml -# TAPE_NAME short tape name (init-wizard) -# NETCLAW_HOME_IN per-tape NETCLAW_HOME inside the container +# 1) config/netclaw.json exists and parses as JSON +# 2) `netclaw doctor` (which runs ConfigSchemaDoctorCheck) does not +# report errors (exit 0 = clean; exit 2 = WARNs only — acceptable +# for Personal posture + HostAllowed shell which trips a warn) +# 3) Provider/model/posture fields in netclaw.json match what the +# tape typed +# 4) Identity/SOUL.md contains the typed user name set -euo pipefail -: "${PROJECT_NAME:?PROJECT_NAME must be set by run-tape.sh}" -: "${COMPOSE_FILE:?COMPOSE_FILE must be set by run-tape.sh}" -: "${NETCLAW_HOME_IN:?NETCLAW_HOME_IN must be set by run-tape.sh}" - -compose() { - docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" "$@" -} - -in_sandbox() { - compose exec -T \ - -e "NETCLAW_HOME=${NETCLAW_HOME_IN}" \ - netclaw-sandbox "$@" -} +. "$(dirname "$0")/_lib.sh" -config_path="${NETCLAW_HOME_IN}/config/netclaw.json" -soul_path="${NETCLAW_HOME_IN}/identity/SOUL.md" +assert_fail=0 -echo "init-wizard: checking config exists at ${config_path}..." -if ! in_sandbox test -f "$config_path"; then - echo "FAIL: ${config_path} does not exist after wizard run." >&2 - in_sandbox sh -lc "ls -la '$NETCLAW_HOME_IN' 2>&1; ls -la '$NETCLAW_HOME_IN/config' 2>&1" >&2 || true +echo "init-wizard: reading produced config..." +if ! in_sandbox test -f "$CONFIG_PATH"; then + echo "FAIL: ${CONFIG_PATH} does not exist after wizard run." >&2 + in_sandbox sh -lc "ls -la '$NETCLAW_HOME_IN' 2>&1" >&2 || true exit 1 fi -echo "init-wizard: validating config JSON parses..." -if ! in_sandbox sh -lc "jq empty < '$config_path'"; then - echo "FAIL: ${config_path} is not valid JSON." >&2 - in_sandbox cat "$config_path" >&2 || true +config_json="$(read_config_json)" +if ! printf '%s' "$config_json" | jq empty >/dev/null 2>&1; then + echo "FAIL: ${CONFIG_PATH} is not valid JSON." >&2 + printf '%s\n' "$config_json" >&2 exit 1 fi -echo "init-wizard: running 'netclaw doctor' against produced config..." +echo "init-wizard: running 'netclaw doctor'..." # DoctorRunner exit codes (src/Netclaw.Cli/Doctor/DoctorRunner.cs): -# 0 = all checks passed -# 1 = at least one check failed (treat as assertion failure) -# 2 = warnings only, no failures (acceptable in smoke — e.g. "Personal posture -# with HostAllowed shell" is an expected WARN for the Personal flow) +# 0 = all PASS, 1 = errors (fail), 2 = WARNs only (acceptable) doctor_status=0 in_sandbox netclaw doctor || doctor_status=$? if [[ $doctor_status -eq 1 ]]; then - echo "FAIL: netclaw doctor reported errors (exit code 1)." >&2 + echo "FAIL: netclaw doctor reported errors (exit 1)." >&2 exit 1 fi if [[ $doctor_status -ne 0 && $doctor_status -ne 2 ]]; then @@ -69,39 +45,23 @@ if [[ $doctor_status -ne 0 && $doctor_status -ne 2 ]]; then fi echo "init-wizard: checking expected fields in netclaw.json..." -fail=0 - -assert_field() { - local jq_expr="$1" - local expected="$2" - local actual - actual="$(in_sandbox sh -lc "jq -r '$jq_expr // empty' < '$config_path'" | tr -d '\r')" - if [[ "$actual" != "$expected" ]]; then - echo "FAIL: expected '${jq_expr}' == '${expected}', got '${actual}'." >&2 - fail=1 - else - echo " ok ${jq_expr} == '${expected}'" - fi -} - -assert_field '.Providers.ollama.Type' 'ollama' -assert_field '.Providers.ollama.Endpoint' 'http://ollama:11434' -assert_field '.Models.Main.Provider' 'ollama' -assert_field '.Models.Main.ModelId' 'qwen2:0.5b' -assert_field '.Security.DeploymentPosture' 'Personal' +assert_field '.Providers.ollama.Type' 'ollama' "$config_json" || : +assert_field '.Providers.ollama.Endpoint' 'http://ollama:11434' "$config_json" || : +assert_field '.Models.Main.Provider' 'ollama' "$config_json" || : +assert_field '.Models.Main.ModelId' 'qwen2:0.5b' "$config_json" || : +assert_field '.Security.DeploymentPosture' 'Personal' "$config_json" || : echo "init-wizard: checking identity/SOUL.md for typed user name..." -if ! in_sandbox grep -q 'Name: SmokeTester' "$soul_path"; then +if ! in_sandbox grep -q 'Name: SmokeTester' "$SOUL_PATH"; then echo "FAIL: identity/SOUL.md does not contain 'Name: SmokeTester'." >&2 - in_sandbox cat "$soul_path" >&2 | head -40 || true - fail=1 + in_sandbox cat "$SOUL_PATH" >&2 | head -40 || true + assert_fail=1 else echo " ok identity/SOUL.md contains 'Name: SmokeTester'" fi -if (( fail )); then - echo "--- netclaw.json contents ---" >&2 - in_sandbox cat "$config_path" >&2 || true +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 exit 1 fi diff --git a/tests/smoke-interactive/assertions/provider-add.sh b/tests/smoke-interactive/assertions/provider-add.sh new file mode 100755 index 000000000..d9df64931 --- /dev/null +++ b/tests/smoke-interactive/assertions/provider-add.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# provider-add.tape post-tape assertion. +# +# Validates that the TUI add flow wrote 'smoke-add-ollama' with the +# expected Type/Endpoint to netclaw.json, and that `netclaw provider +# list` shows the new row. +# +# Doctor is NOT run here — this tape produces a partial config (no +# Tools/Security/Models, which come from `netclaw init`). doctor would +# correctly [FAIL] on the missing sections, but those failures are +# orthogonal to the surface this tape tests. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "provider-add: reading produced config..." +if ! in_sandbox test -f "$CONFIG_PATH"; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + in_sandbox sh -lc "ls -la '$NETCLAW_HOME_IN' '$NETCLAW_HOME_IN/config' 2>&1" >&2 || true + exit 1 +fi + +config_json="$(read_config_json)" +if ! printf '%s' "$config_json" | jq empty >/dev/null 2>&1; then + echo "FAIL: ${CONFIG_PATH} is not valid JSON." >&2 + exit 1 +fi + +echo "provider-add: checking 'smoke-add-ollama' in config..." +assert_field '.Providers["smoke-add-ollama"].Type' 'ollama' "$config_json" || : +assert_field '.Providers["smoke-add-ollama"].Endpoint' 'http://ollama:11434' "$config_json" || : + +echo "provider-add: cross-checking 'netclaw provider list'..." +list_output="$(in_sandbox netclaw provider list 2>/dev/null | tr -d '\r')" +if ! echo "$list_output" | grep -qE '^smoke-add-ollama[[:space:]]+Ollama'; then + echo "FAIL: 'smoke-add-ollama' row missing or malformed in 'provider list' output." >&2 + printf -- '--- provider list ---\n%s\n' "$list_output" >&2 + assert_fail=1 +else + echo " ok 'smoke-add-ollama' present in provider list" +fi + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "provider-add: assertions passed." diff --git a/tests/smoke-interactive/assertions/provider-rename.sh b/tests/smoke-interactive/assertions/provider-rename.sh new file mode 100755 index 000000000..d6ff8fb36 --- /dev/null +++ b/tests/smoke-interactive/assertions/provider-rename.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# provider-rename.tape post-tape assertion. +# +# Validates the rename swapped the dictionary key in netclaw.json: +# - 'seed-ollama' is gone +# - 'renamed-ollama' exists with the original Type/Endpoint +# - `netclaw provider list` reflects the rename +# +# See provider-add.sh for why doctor is not run here. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "provider-rename: reading produced config..." +if ! in_sandbox test -f "$CONFIG_PATH"; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +# One jq pass extracts everything we care about. Saves three additional +# `docker compose exec` round trips compared to per-field exec. +assert_field '(.Providers | has("seed-ollama"))' 'false' "$config_json" || : +assert_field '(.Providers | has("renamed-ollama"))' 'true' "$config_json" || : +assert_field '.Providers["renamed-ollama"].Type' 'ollama' "$config_json" || : +assert_field '.Providers["renamed-ollama"].Endpoint' 'http://ollama:11434' "$config_json" || : + +echo "provider-rename: cross-checking 'netclaw provider list'..." +list_output="$(in_sandbox netclaw provider list 2>/dev/null | tr -d '\r')" +if echo "$list_output" | grep -qE '^seed-ollama[[:space:]]'; then + echo "FAIL: 'seed-ollama' still shown in provider list." >&2 + assert_fail=1 +else + echo " ok 'seed-ollama' absent from provider list" +fi +if ! echo "$list_output" | grep -qE '^renamed-ollama[[:space:]]+Ollama'; then + echo "FAIL: 'renamed-ollama' missing from provider list." >&2 + printf -- '--- provider list ---\n%s\n' "$list_output" >&2 + assert_fail=1 +else + echo " ok 'renamed-ollama' present in provider list" +fi + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "provider-rename: assertions passed." diff --git a/tests/smoke-interactive/assertions/tui-cleanup.sh b/tests/smoke-interactive/assertions/tui-cleanup.sh new file mode 100755 index 000000000..5f79a932c --- /dev/null +++ b/tests/smoke-interactive/assertions/tui-cleanup.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# tui-cleanup.tape post-tape assertion. +# +# The tape's own Wait+Screen anchors are the primary regression +# detector — if the alt screen corrupts during arrow navigation, the +# row anchors stop matching and the tape times out. This script +# confirms the seeded providers survived the TUI round-trip. +# +# See provider-add.sh for why doctor is not run here. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "tui-cleanup: checking seeded providers persisted across TUI exit..." +list_output="$(in_sandbox netclaw provider list 2>/dev/null | tr -d '\r')" + +for name in seed-a seed-b; do + if ! echo "$list_output" | grep -qE "^${name}[[:space:]]+Ollama"; then + echo "FAIL: provider '$name' missing from list after TUI exit." >&2 + assert_fail=1 + else + echo " ok '$name' still present" + fi +done + +if (( assert_fail )); then + printf -- '--- provider list ---\n%s\n' "$list_output" >&2 + exit 1 +fi + +echo "tui-cleanup: assertions passed." diff --git a/tests/smoke-interactive/tapes/provider-add.tape b/tests/smoke-interactive/tapes/provider-add.tape new file mode 100644 index 000000000..a485f972f --- /dev/null +++ b/tests/smoke-interactive/tapes/provider-add.tape @@ -0,0 +1,81 @@ +# provider-add.tape — drive the `netclaw provider` TUI through the +# add flow introduced in PR #997, including the new "Name your +# provider" step that lets the user override the generated default +# (`my-ollama`, `my-anthropic`, …) before any credential entry. +# +# Flow shape: when no providers are configured yet, the provider list +# shows every known provider TYPE as a "(not configured)" row plus +# the "+ Add new provider..." sentinel at the bottom. Activating a +# type row goes straight to the AddName step for that type. The +# sentinel only routes through the explicit AddSelectType step and +# is only useful when every type is already configured (and we want +# to add another instance of one). +# +# Synchronization: every step waits on a stable substring from +# src/Netclaw.Cli/Tui/ProviderManagerPage.cs. Each state transition +# is followed by a brief `Sleep 300ms` — Termina rebuilds the layout +# (including swapping the focused SelectionList) on every state +# transition, and vhs's screen anchor matches the rendered text +# slightly before the new list's input handler is wired up. + +Output "/tmp/tape-provider-add.gif" + +# ─── Launch ────────────────────────────────────────────────────────── +Type "netclaw provider" +Enter + +# List view: every type as unconfigured + the sentinel. Anchor on +# "Ollama" (row 2 — guaranteed to be present in the type list) rather +# than the sentinel so we know the type rows have rendered. +Wait+Screen@10s /Ollama/ +Sleep 300ms + +# Provider type list (alphabetical by TypeKey): anthropic, ollama, +# openai, openai-compatible, openrouter. With Anthropic highlighted +# by default, one Down lands on Ollama. +Down +Enter + +# ─── Step 1: Name your provider (NEW in #997) ──────────────────────── +Wait+Screen@10s /Name your provider/ +Sleep 300ms +# Default pre-filled is "my-ollama" (9 chars). Cursor is at end +# (explicit ConsoleKey.End synth in BuildAddNameView). Backspace +# clears all 9 chars; 16 backspaces is safely over. +Backspace 16 +Type "smoke-add-ollama" +Enter + +# ─── Step 2: Endpoint ──────────────────────────────────────────────── +Wait+Screen@10s /Endpoint/ +Sleep 300ms +# Default is "http://localhost:11434" (22 chars). +Right 32 +Backspace 32 +Type "http://ollama:11434" +Enter + +# ─── Step 3: Probe + ready-to-save ─────────────────────────────────── +# Probe is async; with qwen2:0.5b + all-minilm baked in the smoke +# ollama, this resolves in seconds. Wait directly on the "ready to +# save" success line (skips the transient "Validating..." frame). +Wait+Screen@45s /ready to save/ +Sleep 300ms +Enter + +# ─── Back at provider list ─────────────────────────────────────────── +# StatusMessage on the list view: "Added provider 'smoke-add-ollama'. +# Restart daemon for changes to take effect." +Wait+Screen@10s /Added provider 'smoke-add-ollama'/ +Sleep 300ms + +# ─── Exit TUI ──────────────────────────────────────────────────────── +Ctrl+Q +Wait+Screen@10s /TAPE\$/ + +Type "echo PROVIDER_ADD_EXIT=$?" +Enter +Wait+Screen@5s /PROVIDER_ADD_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke-interactive/tapes/provider-rename.tape b/tests/smoke-interactive/tapes/provider-rename.tape new file mode 100644 index 000000000..2210973fb --- /dev/null +++ b/tests/smoke-interactive/tapes/provider-rename.tape @@ -0,0 +1,71 @@ +# provider-rename.tape — drive the `netclaw provider` TUI rename +# action introduced in PR #997 ([N] from the Details view). +# +# The tape seeds an existing provider via the non-interactive CLI +# (`netclaw provider add ...`) before launching the TUI so we are +# testing the rename flow itself, not the add flow. The expectation +# is that after the rename: +# * the configured key in netclaw.json swaps from the old name to +# the new one +# * any Models.{Main,Fallback,Compaction}.Provider references are +# cascaded (or, if absent, the rename succeeds without warning) +# +# See assertions/provider-rename.sh for the post-tape checks. + +Output "/tmp/tape-provider-rename.gif" + +# ─── Seed an existing provider ─────────────────────────────────────── +# Non-interactive CLI add — bypasses the TUI entirely and writes +# straight to netclaw.json. +Type "netclaw provider add seed-ollama ollama --endpoint http://ollama:11434" +Enter +Wait+Screen@10s /Added provider 'seed-ollama'|TAPE\$/ + +# ─── Launch provider TUI ───────────────────────────────────────────── +Type "netclaw provider" +Enter + +# Wait for the list rendering and confirm our seeded provider is there. +Wait+Screen@10s /seed-ollama/ + +# List has two rows: 'seed-ollama' (highlighted) and '+ Add new +# provider...'. The seeded entry is at index 0 and highlighted by +# default — Enter activates the Details view. +Enter + +# ─── Details view ──────────────────────────────────────────────────── +# Details view shows "Provider: seed-ollama" along with [N] Rename +# in the key hints. Anchor on the rename hint so we know we landed. +Wait+Screen@10s /Provider: seed-ollama/ + +# Press N to start rename. +Type "N" + +# ─── Rename input ──────────────────────────────────────────────────── +Wait+Screen@10s /Rename 'seed-ollama'/ + +# Cursor sits at the end of the pre-filled name (PR #997 moves it +# there explicitly — see commit 1493ddd1). 'seed-ollama' is 11 chars; +# 16 backspaces is safely over. +Backspace 16 +Type "renamed-ollama" +Enter + +# ─── Success status ────────────────────────────────────────────────── +# StatusMessage on the list view: "Renamed 'seed-ollama' to +# 'renamed-ollama'. Restart daemon for changes to take effect." +Wait+Screen@10s /Renamed 'seed-ollama' to 'renamed-ollama'/ + +# Verify the list shows the new name and no longer the old one. +Wait+Screen@10s /renamed-ollama/ + +# ─── Exit TUI ──────────────────────────────────────────────────────── +Ctrl+Q +Wait+Screen@10s /TAPE\$/ + +Type "echo PROVIDER_RENAME_EXIT=$?" +Enter +Wait+Screen@5s /PROVIDER_RENAME_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke-interactive/tapes/tui-cleanup.tape b/tests/smoke-interactive/tapes/tui-cleanup.tape new file mode 100644 index 000000000..5fceb2965 --- /dev/null +++ b/tests/smoke-interactive/tapes/tui-cleanup.tape @@ -0,0 +1,90 @@ +# tui-cleanup.tape — regression coverage for the bug class fixed in +# PR #997, commit 21450c3d "fix(cli): buffer update-available notice; +# emit after mode handler exits". +# +# The underlying bug: any background task that writes to stderr/ +# stdout while a Termina TUI owns the alt screen corrupts the layout +# (missing panel borders, duplicate rows under arrow navigation). +# `BackgroundUpdateCheckAsync` was the most visible offender — it +# fires on every CLI invocation and used to write its "Update +# available" / "warn: background update check failed" lines straight +# to stderr. +# +# The fix moves those writes into a buffered `_pendingNotice` field +# emitted by `EmitPendingNoticeIfReady()` AFTER the mode handler +# returns (TUI torn down). This tape exercises the conditions that +# used to trigger the corruption: +# 1) Launch the provider TUI (which Aaron was navigating when he +# reproduced the bug on #997). +# 2) Wait long enough for BackgroundUpdateCheckAsync to plausibly +# fire (network probe to the update endpoint). +# 3) Navigate with arrows; if the background task wrote to stderr +# mid-render, the list rendering would drift and our screen +# anchors would no longer match. +# 4) Exit the TUI and verify we land on a clean shell prompt with +# no stray output between the TUI tear-down and the prompt. +# +# Note: the test cannot force `BackgroundUpdateCheckAsync` to find an +# update or emit a notice — that depends on the network response from +# the update endpoint. What it CAN do is keep the TUI up long enough +# that any background stderr write would manifest as visible +# corruption. The fix is observable as the absence of that +# corruption. + +Output "/tmp/tape-tui-cleanup.gif" + +# Seed a couple of providers so the list has multiple rows to +# arrow-navigate through — multiple rows is what triggered the +# "duplicate rows on arrow navigation" symptom in Aaron's report. +Type "netclaw provider add seed-a ollama --endpoint http://ollama:11434" +Enter +Wait+Screen@10s /Added provider 'seed-a'|TAPE\$/ + +Type "netclaw provider add seed-b ollama --endpoint http://ollama:11434" +Enter +Wait+Screen@10s /Added provider 'seed-b'|TAPE\$/ + +# ─── Launch provider TUI ──────────────────────────────────────────── +Type "netclaw provider" +Enter + +# Both seeded entries present. +Wait+Screen@10s /seed-a/ +Wait+Screen@10s /seed-b/ + +# Arrow-navigate up and down through the list. Each step has its own +# anchor on the row content — if stderr writes corrupted the alt +# screen, the row text wouldn't reappear unchanged. +Down +Wait+Screen@5s /seed-b/ +Down +Wait+Screen@5s /\+ Add new provider/ +Up +Wait+Screen@5s /seed-b/ +Up +Wait+Screen@5s /seed-a/ + +# ─── Exit and verify clean shell post-TUI ─────────────────────────── +Ctrl+Q + +# Anchor on the prompt. If the alt screen wasn't torn down cleanly, +# the prompt either wouldn't render or would be visually merged with +# TUI artefacts. The TAPE$ regex must match for this tape to pass. +Wait+Screen@10s /TAPE\$/ + +# Run a known echo and verify the output round-trips cleanly. A stray +# update-notice line emitted at the wrong time would interleave with +# this output; a buffered notice (the fix) renders BEFORE the prompt +# and leaves the round-trip uncorrupted. +Type "echo TUI_EXIT_SENTINEL" +Enter +Wait+Screen@5s /TUI_EXIT_SENTINEL/ + +# Also re-anchor on the prompt to ensure the line was processed and +# returned us to a working shell — not stuck in some half-state. +Type "echo POST_SENTINEL_$?" +Enter +Wait+Screen@5s /POST_SENTINEL_0/ + +Type "exit" +Enter