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
48 changes: 48 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CLI tests

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: cli-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: ${{ matrix.os }} / ${{ matrix.framework }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
framework: [net10.0, net8.0]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: |
8.0.x
10.0.x
- name: Run complete suite
shell: pwsh
run: |
dotnet test tests/MandoCode.Tests/MandoCode.Tests.csproj -c Release -f ${{ matrix.framework }} --results-directory artifacts/tests --logger "trx;LogFileName=full.trx" --blame-hang-timeout 2m --verbosity minimal
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Repeat component tests
shell: pwsh
run: ./scripts/verify-component-stability.ps1 -Framework ${{ matrix.framework }} -Iterations 3
- name: Preserve test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-${{ matrix.os }}-${{ matrix.framework }}
path: artifacts/**/*.trx
if-no-files-found: warn
retention-days: 14
20 changes: 20 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to MandoCode will be documented in this file.

## [Unreleased]

### Changed

- Improve readability of integration menu navigation, delegation completion, component wiring, and Razor parameter lists; retain intent-focused comments and update outdated engine explanations.

- Add targeted mutation probes, repeated component checks, and a Windows/Linux test workflow for both supported .NET targets; document a terminal smoke checklist.

- Organize tests by unit, component, integration, and compatibility tiers; add a shared widget renderer harness and replace fixed waits in explorer and refresh-feedback tests.
- Strengthen workflow edge and registered-tool assertions, consolidate duplicate graph checks, and replace platform tests that could pass without asserting behavior.

- Separate CLI command handling, planning, attachments, integrations, request processing, lifecycle, and terminal effects into focused component code files.
- Centralize panel visibility and global agent shortcut routing; retain independent agent execution and nested menu state.
- Extract Ollama model inspection, pending image delivery, history serialization, and response-limit notices from the shared AI service.
- Separate component activity updates from direct terminal spinner rendering while preserving the public engine API used by Desktop.

### Fixed

- Complete pending menu/input requests when an agent closes, including model selection, directory selection, and plan approvals.
- Release callbacks only when the disposing component still owns them, preventing an old component from clearing callbacks registered by its replacement.
- Record diagnostic warnings for failed history serialization and context recap persistence.

## [0.16.0] - 2026-10-06

**A new way to work with agents in the terminal.** Built on RazorConsole 0.6.0, this release introduces multiple workspaces, independent agents, agent collaboration, portable context snapshots, and saved-agent history. A rebuilt interface keeps your conversation readable and your prompt within reach.
Expand Down
27 changes: 27 additions & 0 deletions docs/cli-maintainability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# CLI maintenance boundaries

The CLI is composed from Razor components. `App.razor` retains layout and coordinating state; its partial class files group commands, planning, attachments, integrations, request processing, lifecycle, and terminal effects. Partial files share component state and are not independent services. Extract reusable operations into services rather than adding unrelated responsibilities to these files.

## State and input

`AgentPresentationState` owns panel visibility policies. Multiple panels may remain mounted while a nested picker owns input. Explorer and Git regions coexist with the prompt; modal panels hide it. Request execution and pending input remain separate from presentation state.

`AgentKeyboardRouter` forwards Alt/Meta shortcuts to the selected workspace before menu-specific keys. Prompt cursor navigation and local selection stay with their components.

## Agent lifetime

`App.Lifecycle.cs` resolves per-agent services from the pane scope, subscribes to events, and releases pending requests on shutdown. `CallbackRegistrations` releases a callback only if it is still owned by that registration. Event delegates must also be detached on disposal. Shared workspace services are resolved from the host; cross-agent operations resolve the target agent's scope.

## Shared engine

`AIService` continues to own mutable conversation history, tool execution, and response orchestration. `OllamaModelInspector`, `PendingImageEvidence`, `ConversationHistoryCodec`, and `ResponseLimitNotice` isolate focused operations without changing the public API used by Desktop. Model identity/version checks remain in AIService so a late inspection cannot overwrite a newer selection.

## Output

`SpinnerService` is the public activity facade. A current `TuiSession` receives widget state updates; the terminal backend owns cursor-based rendering when there is no component session. Components should use the session-aware output path. Avoid introducing direct terminal writes in component flows.

`CommandAutocomplete` remains a compatibility adapter; the active CLI selectors use component input state. Check external consumers before removing public APIs.

## Verification

Run the CLI test project on both net10.0 and net8.0. Live-renderer tests cover multiple panes, focus, model menus, agent closure, and workspace switching; boundary tests cover nested panel policies, callback replacement, and verbatim command arguments. Verify the Desktop build when shared engine interfaces change. Interactive terminal testing is still valuable for resize, cursor behavior, and platform-specific clipboard integration.
21 changes: 21 additions & 0 deletions docs/code-readability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Readable code and comments

Code should make its intent and execution order visible to the next maintainer. Prefer a few clear statements over a long expression that mixes navigation, state changes, and output.

## Comments are part of the design

Keep comments that explain invariants, ownership, ordering, compatibility, and failure recovery. Examples include why a cancellation callback runs outside a lock, why a rendering failure still completes a waiting request, and why failed model calls retain completed tool evidence.

Update comments when implementation changes. Describe the current agent, history, and tool behavior rather than referencing an obsolete kernel, a private memory file, or a migration phase. Historical explanations remain useful when they describe a supported file format or compatibility requirement.

Use XML documentation for API contracts and focused comments near the constraint they explain. Avoid narrating obvious assignments or asserting that a method has no callers without a durable reason to keep that claim current.

## Structure and naming

- Separate state decisions, side effects, rendering, and completion handling into visible steps.
- Give nested mode decisions named methods or switch expressions. Preserve the precedence of cancellation, confirmation, and nested-picker behavior.
- Name intermediate values by their meaning, such as rowStart, cellOffset, and succeeded.
- Wrap substantial Razor declarations with one parameter per line. Group lifecycle setup by service resolution, callback ownership, and subscriptions.
- Keep extracted methods cohesive. Formatting alone does not resolve an oversized component's responsibilities, and a helper for every statement adds more navigation than clarity.

Readability edits should preserve behavior. Use the existing component and service regressions to verify that focus, draft state, output ordering, and cancellation still work.
81 changes: 81 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Testing MandoCode

Run the full CLI suite before submitting changes:

```powershell
dotnet test tests/MandoCode.Tests/MandoCode.Tests.csproj -c Release
```

This runs both net10.0 and net8.0. A green result does not replace an interactive terminal check for resize, native clipboard access, or terminal-specific keyboard sequences.

## Test tiers

Every test class has a `Category` trait. Mixed classes are assigned to the tier needed by their most involved cases; these categories are routing aids, not a coverage measurement.

- **Unit:** deterministic service, parsing, validation, and orchestration checks with controlled dependencies.
- **Component:** component handlers, rendered output, keyboard focus, and live widget layout checks. Some cases still use private-member reflection; migrate those assertions toward observable behavior when modifying their area.
- **Integration:** tests that exercise real filesystem, process, Git, or local server behavior without a live model.
- **Compatibility:** checks for the retained terminal autocomplete adapter. Legacy paste and unused planning heuristics within otherwise active unit classes additionally carry `Behavior=Compatibility`.

Examples:

```powershell
dotnet test tests/MandoCode.Tests/MandoCode.Tests.csproj -c Release -f net10.0 --filter Category=Unit
dotnet test tests/MandoCode.Tests/MandoCode.Tests.csproj -c Release -f net10.0 --filter Category=Component
dotnet test tests/MandoCode.Tests/MandoCode.Tests.csproj -c Release -f net10.0 --filter Category=Integration
dotnet test tests/MandoCode.Tests/MandoCode.Tests.csproj -c Release -f net10.0 --filter "Category=Compatibility|Behavior=Compatibility"
```

## Renderer and timing guidance

`WidgetRendererHarness` encapsulates RazorConsole's internal renderer construction, mounting, dispatcher, and snapshot access. The explorer and agent-survival scenarios use it; other renderer suites can migrate incrementally. Keep version-sensitive framework reflection here rather than duplicating it in new tests.

Use the renderer dispatcher to inspect or change component state. Wait for an observable condition with a bounded timeout, not a fixed sleep that assumes rendering has completed. A polling timeout is a failure bound, not a performance expectation.

Git refresh feedback uses an internal delay delegate and completion task. Tests hold that delay, verify the visible message, release it, and await completion without sleeping through the production two-second timeout. Production still uses Task.Delay and the existing cancellation token.

Global Console.Out and Spectre console replacements belong to the nonparallel `TUI console routing` collection. Restore them in finally blocks. Do not disable parallelism across the entire suite to hide an isolation problem.

## Keep useful coverage

Preserve multi-turn layout, pane isolation, input ownership, cancellation, image delivery, archive recovery, and checkpoint compatibility regressions. Test repetition across service and component layers can be intentional: a correct service does not guarantee correct focus or rendering.

Prefer exact behavior assertions over checks that merely find a node name or a nonempty result. The workflow topology suite now checks the actual triage destinations and compares graph shape across different plan sizes; separate build-only cases were redundant.

Platform tests must assert the current platform's behavior or explicitly report a skip. Never return early and count an unexecuted assertion as a pass. Retained compatibility coverage should be removed together with its supported production API, after checking consumers.

## Stability and targeted mutation checks

Run repeated component checks locally:

```powershell
./scripts/verify-component-stability.ps1 -Framework net10.0 -Iterations 5
./scripts/verify-component-stability.ps1 -Framework net8.0 -Iterations 5
```

Each iteration runs in a new test process and preserves a separate TRX result under ignored `artifacts/`. A failure stops the script; hang diagnostics bound stalled runs. Passing repeated runs reduces uncertainty but does not prove the absence of intermittent failures.

The focused mutation probe requires Python and .NET:

```powershell
python scripts/verify-targeted-mutations.py
```

It temporarily breaks four behaviors: prompt visibility, replacement callback ownership, argument casing, and queued approval cancellation. It requires an actual failing test result, rather than treating a compiler failure as a detected mutation. Every source file is restored byte-for-byte in a finally block. Do not run concurrent builds, tests, or edits while this probe is operating; forcibly terminating the process can interrupt restoration. The final report is written to `artifacts/mutation-results.json`. Rebuild after running the probe because the last compiled assembly contains a mutation. These four probes are a focused confidence check, not a suite-wide mutation score.

The GitHub workflow runs the full suite and three component repetitions for each combination of Windows/Linux and net10.0/net8.0. It retains TRX results even after failure. Workflow execution requires pushing these changes; local Windows results do not establish Linux compatibility.

## Interactive terminal smoke checklist

Run against the built CLI in a real terminal, with an available Ollama model. Record the terminal application, OS, framework, model, and result. These checks remain manual:

- Launch: immediately type a prompt without clicking; verify focus and submission.
- Paste a multiline code snippet with Ctrl+V: preserve indentation and newlines, do not submit until Enter; use arrows to navigate before reaching chat-scroll boundaries.
- Open `/model`, filter, select, and cancel: ensure the prompt remains visible and usable. Try an unavailable model and confirm actionable feedback.
- Create multiple agents/workspaces; start a request or snapshot in one and switch with Alt+arrows. Background output must stay with its originating agent.
- Open a menu and press Alt+W: the highlighted agent closes; other agents keep their drafts, focus, and pending operations.
- Generate several turns with code and tool output; resize narrow and wide. Preserve turn order, keep the prompt at the bottom, and keep temporary progress outside conversation history.
- Exercise `/`, `@`, file explorer, approval menus, and Escape/Tab navigation. Ensure keyboard focus returns to the selected agent's prompt.
- Copy an image and use Alt+V with a vision model; verify actual image submission. With text-only clipboard content, report that no image is available.

Do not mark this checklist passed based only on component test results.
18 changes: 18 additions & 0 deletions scripts/verify-component-stability.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
param(
[ValidateRange(1, 100)] [int] $Iterations = 5,
[ValidateSet("net10.0", "net8.0")] [string] $Framework = "net10.0",
[string] $ResultsDirectory = ""
)
$ErrorActionPreference = "Stop"
$repository = Split-Path $PSScriptRoot -Parent
$project = Join-Path $repository "tests/MandoCode.Tests/MandoCode.Tests.csproj"
if (!$ResultsDirectory) { $ResultsDirectory = Join-Path $repository "artifacts/component-stability/$Framework" }
& dotnet build $project -c Release -f $Framework --nologo --verbosity quiet
if ($LASTEXITCODE -ne 0) { throw "Test build failed ($LASTEXITCODE)." }
for ($iteration = 1; $iteration -le $Iterations; $iteration++) {
Write-Host "Component stability: $Framework run $iteration of $Iterations"
& dotnet test $project -c Release -f $Framework --no-build --filter "Category=Component" `
--results-directory $ResultsDirectory --logger "trx;LogFileName=component-$iteration.trx" `
--blame-hang-timeout 2m --verbosity quiet
if ($LASTEXITCODE -ne 0) { throw "Component run $iteration failed ($LASTEXITCODE). See $ResultsDirectory." }
}
24 changes: 24 additions & 0 deletions scripts/verify-targeted-mutations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from pathlib import Path
import subprocess,os,json
root=Path(__file__).resolve().parent.parent
mutations=[
('panel visibility','src/MandoCode/Services/Rendering/AgentPresentationState.cs','(_open & (ModalPanels | AgentPanel.DirectoryBrowser)) != 0','false','FullyQualifiedName~PresentationBoundaryTests.ClosingNestedPanel'),
('callback ownership','src/MandoCode/Services/Rendering/CallbackRegistrations.cs','if (Equals(read(), callback)) write(null);','write(null);','FullyQualifiedName~PresentationBoundaryTests.OldComponentDisposal'),
('argument casing','src/MandoCode/Services/Commands/CliCommand.cs','raw[(space + 1)..].Trim()','raw[(space + 1)..].Trim().ToLowerInvariant()','FullyQualifiedName~PresentationBoundaryTests.CommandParsing'),
('approval cancellation','src/MandoCode/Services/Approval/ApprovalPromptGate.cs','_gate.WaitAsync(cancellationToken)','_gate.WaitAsync(CancellationToken.None)','FullyQualifiedName~ApprovalPromptGateTests.QueuedAcquire_IsCancellable')]
results=[]
for name,file,old,new,filter in mutations:
p=root/file;original=p.read_bytes();source=original.decode('utf-8');assert source.count(old)==1,(name,source.count(old))
try:
p.write_bytes(source.replace(old,new).encode('utf-8'))
result=subprocess.run(['dotnet','test','tests/MandoCode.Tests/MandoCode.Tests.csproj','-c','Release','-f','net10.0','--no-restore','--filter',filter,'--verbosity','quiet'],capture_output=True,text=True,encoding='utf-8',errors='replace',timeout=60,cwd=root)
output=result.stdout+result.stderr
detected=result.returncode!=0 and 'Failed: 1' in output and '[FAIL]' in output
results.append({'mutation':name,'detected':detected,'exitCode':result.returncode})
print(name, 'DETECTED' if detected else 'CHECK FAILED',flush=True)
if not detected:print(output[-2200:],flush=True)
finally:p.write_bytes(original)
report=root/'artifacts/mutation-results.json'
report.parent.mkdir(parents=True,exist_ok=True)
report.write_text(json.dumps(results,indent=2))
if not all(result['detected'] for result in results): raise SystemExit(1)
6 changes: 4 additions & 2 deletions src/MandoCode/Components/AgentGitChanges.razor
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
private string _error = "", _root = "";
private bool _refreshing;
private string _refreshMessage = "";
internal Func<TimeSpan, CancellationToken, Task> FeedbackDelay { get; set; } = Task.Delay;
internal Task FeedbackCompletion { get; private set; } = Task.CompletedTask;
private int _refreshVersion;
private int VisibleRows => Math.Max(1, Height - 7);
private bool CanDiscard => _file is not null && _file.OriginalPath is null && _file.Label != "Added" && Pane?.IsBusy?.Invoke() != true;
Expand Down Expand Up @@ -182,7 +184,7 @@
{
_files = await GitChangesService.ReadAsync(Root, _shutdown.Token);
if (_file is not null) { _file = _files.FirstOrDefault(x => x.Path == _file.Path); if (_file is not null) _preview = await GitChangesService.PreviewAsync(Root, _file, _shutdown.Token); else await Back(); }
if (showFeedback) { _refreshMessage = refreshingFile ? "File refreshed" : "Changes refreshed"; _ = ClearRefreshMessage(version); }
if (showFeedback) { _refreshMessage = refreshingFile ? "File refreshed" : "Changes refreshed"; FeedbackCompletion = ClearRefreshMessage(version); }
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or OperationCanceledException) { _error = ex is OperationCanceledException ? "Git query timed out." : ex.Message; }
finally { _loading = false; _refreshing = false; Reveal(); }
Expand All @@ -191,7 +193,7 @@
{
try
{
await Task.Delay(TimeSpan.FromSeconds(2), _shutdown.Token);
await FeedbackDelay(TimeSpan.FromSeconds(2), _shutdown.Token);
await InvokeAsync(() => { if (version == _refreshVersion) { _refreshMessage = ""; StateHasChanged(); } });
}
catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) { }
Expand Down
Loading
Loading