diff --git a/src/Capacitor.App/ViewModels/MainWindowViewModel.cs b/src/Capacitor.App/ViewModels/MainWindowViewModel.cs index 36eae092d..4ed1c4a15 100644 --- a/src/Capacitor.App/ViewModels/MainWindowViewModel.cs +++ b/src/Capacitor.App/ViewModels/MainWindowViewModel.cs @@ -236,10 +236,10 @@ public ISessionWorkspace? CurrentWorkspace { public bool HistoryImportVisible => HistoryImport is not null; public bool HistoryImportRunning => HistoryImport is { Running: true }; public string HistoryImportStatus => HistoryImport is not { } run ? "" - : run.Running ? $"Importing history · {run.Imported:N0} sessions uploaded" + : run.Running ? $"Importing history · {run.Processed:N0} sessions processed" : run.State is ImportRunState.Failed or ImportRunState.Cancelled - ? $"History import didn't finish · {run.Imported:N0} sessions uploaded. Reopen setup to retry." - : $"History imported · {run.Imported:N0} sessions uploaded"; + ? $"History import didn't finish · {run.Processed:N0} sessions processed. Reopen setup to retry." + : $"History imported · {run.Processed:N0} sessions processed"; ObservableAsPropertyHelper? _signInVisible; /// True while the rail reads Signed out and a sign-in action exists — the help flyout @@ -420,7 +420,7 @@ public MainWindowViewModel( this.WhenActivated(disposables => { if (historyImport is not null) - historyImport.WhenAnyValue(x => x.State, x => x.Imported, (state, imported) => (state, imported)) + historyImport.WhenAnyValue(x => x.State, x => x.Processed, (state, processed) => (state, processed)) .ObserveOn(RxSchedulers.MainThreadScheduler) .Subscribe(_ => { this.RaisePropertyChanged(nameof(HistoryImportStatus)); diff --git a/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs b/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs index 7235466ee..eb598ca8e 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/DoneStepViewModel.cs @@ -60,7 +60,7 @@ public DoneStepViewModel(Func facts, IUrlOpener? opener = null, Func< : Import is null ? $"kcap keeps what your coding agents do on {_current.MachineName}, and gives them tools to search it back — so the next time you hit something, what you already worked out is there to find." : Import.Running ? "What has landed is already searchable." : Import.Imported > 0 ? "Your agents can search all of it — ask one what you have already tried, and it looks here first." - : "No sessions were uploaded. Your history is still on this machine."; + : "No sessions were imported. Your history is still on this machine."; public bool ShowsFigures => Import is not null; @@ -100,7 +100,7 @@ public DoneStepViewModel(Func facts, IUrlOpener? opener = null, Func< public bool FailedVisible => Import is { State: ImportRunState.Failed or ImportRunState.Cancelled } or { Running: false, Failed: > 0 }; public string FailedTitle => Import is { Failed: 0 } ? "History import did not finish" - : Import is { Failed: 1 } ? "1 session failed to upload" : $"{Import?.Failed ?? 0} sessions failed to upload"; + : Import is { Failed: 1 } ? "1 session failed to import" : $"{Import?.Failed ?? 0} sessions failed to import"; public string FailedBody => _current.Local ? $"Your history is still on {_current.MachineName}. Go back to History to retry." diff --git a/src/Capacitor.App/ViewModels/Onboarding/HistoryImportRun.cs b/src/Capacitor.App/ViewModels/Onboarding/HistoryImportRun.cs index d64738490..e73ece191 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/HistoryImportRun.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/HistoryImportRun.cs @@ -23,6 +23,7 @@ public sealed partial class HistoryImportRun : ReactiveObject { int _processed; int _passImported; int _passFailed; + int _passProcessed; internal HistoryImportRun(int expected, int repositories, string windowCaption, int passes, Action post) { Expected = expected; @@ -90,8 +91,9 @@ async Task RunAsync(IKcapCli cli, IReadOnlyList passes) { anyFailed |= result.ExitCode != 0 || result.TimedOut; _post(() => { // A pass that died before its totals line keeps its live counts; the next pass's totals must not net them out. - _passImported = 0; - _passFailed = 0; + _passImported = 0; + _passFailed = 0; + _passProcessed = 0; PassesDone++; }); } @@ -112,12 +114,15 @@ void OnLine(StreamedLine line) => _post(() => { var text = line.Text.Trim(); if (Totals().Match(text) is { Success: true } m && - int.TryParse(m.Groups[1].Value, out var imported) && int.TryParse(m.Groups[3].Value, out var failed)) { + int.TryParse(m.Groups[1].Value, out var imported) && int.TryParse(m.Groups[2].Value, out var skipped) && + int.TryParse(m.Groups[3].Value, out var failed)) { // The closing line is authoritative: it also counts what never printed a session line. - Imported = Add(Imported, imported - _passImported); - Failed = Add(Failed, failed - _passFailed); - _passImported = 0; - _passFailed = 0; + Imported = Add(Imported, imported - _passImported); + Failed = Add(Failed, failed - _passFailed); + Processed = Add(Processed, (long)imported + skipped + failed - _passProcessed); + _passImported = 0; + _passFailed = 0; + _passProcessed = 0; return; } @@ -131,10 +136,11 @@ void OnLine(StreamedLine line) => _post(() => { return; } + _passProcessed++; Processed = Add(Processed, 1); }); - static int Add(int total, int delta) => (int)Math.Clamp((long)total + delta, 0, int.MaxValue); + static int Add(int total, long delta) => (int)Math.Clamp(total + delta, 0, int.MaxValue); // Each pass closes with this line. [GeneratedRegex(@"^(\d+) imported · (\d+) skipped · (\d+) failed$")] diff --git a/src/Capacitor.App/ViewModels/Onboarding/HistoryStepViewModel.cs b/src/Capacitor.App/ViewModels/Onboarding/HistoryStepViewModel.cs index 1c130f1b6..0a17c2a6e 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/HistoryStepViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/HistoryStepViewModel.cs @@ -48,9 +48,11 @@ public HistoryStepViewModel( public bool Applicable => true; public string SkipLabel => "Not now"; - public string Lede => - "kcap uploads the transcripts your harnesses have already written, so your past work is searchable " + - "from day one. Nothing leaves this machine until you choose."; + public string Lede => _onLocalPath() + ? "kcap imports the transcripts your harnesses have already written, so your past work is searchable " + + "from day one. Everything stays on this machine." + : "kcap uploads the transcripts your harnesses have already written, so your past work is searchable " + + "from day one. Nothing leaves this machine until you choose."; public string MachineName { get; } diff --git a/test/Capacitor.App.Tests.Unit/DoneStepViewModelTests.cs b/test/Capacitor.App.Tests.Unit/DoneStepViewModelTests.cs index a0357275a..019bdb8e3 100644 --- a/test/Capacitor.App.Tests.Unit/DoneStepViewModelTests.cs +++ b/test/Capacitor.App.Tests.Unit/DoneStepViewModelTests.cs @@ -68,7 +68,7 @@ public async Task The_restart_line_shows_only_when_new_hooks_need_one() { [Test] [NotInParallel("AvaloniaSession")] - public async Task Failed_uploads_are_named_with_where_they_still_are() { + public async Task Failed_imports_are_named_with_where_they_still_are() { var (visible, title, body, landed, unit) = await AvaloniaSession.DispatchAsync(async () => { var vm = new DoneStepViewModel(() => Facts(import: null)); var run = await FinishedRun(30, 3); @@ -79,7 +79,7 @@ public async Task Failed_uploads_are_named_with_where_they_still_are() { }); await Assert.That(visible).IsTrue(); - await Assert.That(title).IsEqualTo("3 sessions failed to upload"); + await Assert.That(title).IsEqualTo("3 sessions failed to import"); await Assert.That(body).Contains("test-mac"); await Assert.That(body).Contains("retry"); await Assert.That(landed).IsEqualTo("30"); diff --git a/test/Capacitor.App.Tests.Unit/HistoryImportRunTests.cs b/test/Capacitor.App.Tests.Unit/HistoryImportRunTests.cs new file mode 100644 index 000000000..76ef31a66 --- /dev/null +++ b/test/Capacitor.App.Tests.Unit/HistoryImportRunTests.cs @@ -0,0 +1,28 @@ +using Capacitor.App.Services; +using Capacitor.App.ViewModels.Onboarding; +using Capacitor.Cli.Core; + +namespace Capacitor.App.Tests.Unit; + +public class HistoryImportRunTests { + /// The totals line also counts sessions that never printed a line of their own, so it settles the + /// processed count without counting the printed ones twice. + [Test] + public async Task The_totals_line_settles_the_processed_count() { + var cli = new FakeKcapCli { + ImportBehavior = (_, onLine, _) => { + onLine(new StreamedLine(ProcessStreamKind.Stdout, " Loading s1 (claude)")); + onLine(new StreamedLine(ProcessStreamKind.Stdout, " Loading s2 (claude)")); + onLine(new StreamedLine(ProcessStreamKind.Stdout, " Skipping k1 (already current)")); + onLine(new StreamedLine(ProcessStreamKind.Stdout, " 5 imported · 2 skipped · 1 failed")); + return Task.FromResult(new StreamingResult(0, false, [])); + }, + }; + var run = new HistoryImportRun(0, 1, "from the last 90 days", 1, action => action()); + run.Start(cli, [new ImportRequest(ImportScopeChoice.Repo, null, [], ["a/b"])]); + await run.Completion; + + await Assert.That(run.Processed).IsEqualTo(8); + await Assert.That(run.Imported).IsEqualTo(5); + } +} diff --git a/test/Capacitor.App.Tests.Unit/HistoryStepViewModelTests.cs b/test/Capacitor.App.Tests.Unit/HistoryStepViewModelTests.cs index bb0d6488e..90cd1b154 100644 --- a/test/Capacitor.App.Tests.Unit/HistoryStepViewModelTests.cs +++ b/test/Capacitor.App.Tests.Unit/HistoryStepViewModelTests.cs @@ -195,7 +195,7 @@ public async Task The_run_counts_sessions_as_they_land() { await Assert.That(midway).IsEqualTo((2, 2, 6)); await Assert.That(h.Vm.Run!.Imported).IsEqualTo(6); await Assert.That(h.Vm.Run!.Failed).IsEqualTo(6); - await Assert.That(h.Vm.Run!.Processed).IsEqualTo(12); + await Assert.That(h.Vm.Run!.Processed).IsEqualTo(16); } /// A pass that dies before its totals line keeps what it counted, and the next pass's totals add to it.